@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
package/sdk.js
ADDED
|
@@ -0,0 +1,4336 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const crypto = require("crypto");
|
|
5
|
+
const { formatter } = require("./sdk-log-formatter");
|
|
6
|
+
|
|
7
|
+
// Load .env — use monorepo root .env when running inside the monorepo,
|
|
8
|
+
// otherwise fall back to default dotenv.config() for end users.
|
|
9
|
+
const _isMonorepo = __dirname.includes(require('path').join('mono', 'sdk'));
|
|
10
|
+
if (_isMonorepo) {
|
|
11
|
+
require('../shared/load-env');
|
|
12
|
+
} else {
|
|
13
|
+
require('dotenv').config();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get the file path of the caller (the file that called TestDriver)
|
|
18
|
+
* @returns {string|null} File path or null if not found
|
|
19
|
+
*/
|
|
20
|
+
function getCallerFilePath() {
|
|
21
|
+
const originalPrepareStackTrace = Error.prepareStackTrace;
|
|
22
|
+
try {
|
|
23
|
+
const err = new Error();
|
|
24
|
+
Error.prepareStackTrace = (_, stack) => stack;
|
|
25
|
+
const stack = err.stack;
|
|
26
|
+
Error.prepareStackTrace = originalPrepareStackTrace;
|
|
27
|
+
|
|
28
|
+
// Look for the first file that's not sdk.js, hooks.mjs, or node internals
|
|
29
|
+
for (const callSite of stack) {
|
|
30
|
+
const fileName = callSite.getFileName();
|
|
31
|
+
if (
|
|
32
|
+
fileName &&
|
|
33
|
+
!fileName.includes("sdk.js") &&
|
|
34
|
+
!fileName.includes("hooks.mjs") &&
|
|
35
|
+
!fileName.includes("hooks.js") &&
|
|
36
|
+
!fileName.includes("node_modules") &&
|
|
37
|
+
!fileName.includes("node:internal") &&
|
|
38
|
+
fileName !== "evalmachine.<anonymous>"
|
|
39
|
+
) {
|
|
40
|
+
return fileName;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
// Silently fail and return null
|
|
45
|
+
} finally {
|
|
46
|
+
Error.prepareStackTrace = originalPrepareStackTrace;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generate a hash of the caller file for use as a cache key
|
|
53
|
+
* @returns {string|null} Hash of the file or null if file not found
|
|
54
|
+
*/
|
|
55
|
+
function getCallerFileHash() {
|
|
56
|
+
const filePath = getCallerFilePath();
|
|
57
|
+
if (!filePath) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// Handle file:// URLs by converting to file system path
|
|
63
|
+
let fsPath = filePath;
|
|
64
|
+
if (filePath.startsWith("file://")) {
|
|
65
|
+
fsPath = filePath.replace("file://", "");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const fileContent = fs.readFileSync(fsPath, "utf-8");
|
|
69
|
+
const hash = crypto.createHash("sha256").update(fileContent).digest("hex");
|
|
70
|
+
// Return first 16 chars of hash for brevity
|
|
71
|
+
return hash.substring(0, 16);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// If we can't read the file, return null
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get detailed caller information including file path, line number, and column
|
|
80
|
+
* Used for automatic screenshot naming to identify which line of code triggered an action
|
|
81
|
+
* @param {number} [skipFrames=0] - Additional frames to skip in the stack trace
|
|
82
|
+
* @returns {{filePath: string|null, line: number|null, column: number|null, functionName: string|null}}
|
|
83
|
+
*/
|
|
84
|
+
function getCallerInfo(skipFrames = 0) {
|
|
85
|
+
const originalPrepareStackTrace = Error.prepareStackTrace;
|
|
86
|
+
try {
|
|
87
|
+
const err = new Error();
|
|
88
|
+
Error.prepareStackTrace = (_, stack) => stack;
|
|
89
|
+
const stack = err.stack;
|
|
90
|
+
Error.prepareStackTrace = originalPrepareStackTrace;
|
|
91
|
+
|
|
92
|
+
// Look for the first file that's not sdk.js, hooks.mjs, or node internals
|
|
93
|
+
let skipped = 0;
|
|
94
|
+
for (const callSite of stack) {
|
|
95
|
+
const fileName = callSite.getFileName();
|
|
96
|
+
if (
|
|
97
|
+
fileName &&
|
|
98
|
+
!fileName.includes("sdk.js") &&
|
|
99
|
+
!fileName.includes("hooks.mjs") &&
|
|
100
|
+
!fileName.includes("hooks.js") &&
|
|
101
|
+
!fileName.includes("node_modules") &&
|
|
102
|
+
!fileName.includes("node:internal") &&
|
|
103
|
+
fileName !== "evalmachine.<anonymous>"
|
|
104
|
+
) {
|
|
105
|
+
if (skipped < skipFrames) {
|
|
106
|
+
skipped++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
filePath: fileName,
|
|
111
|
+
line: callSite.getLineNumber(),
|
|
112
|
+
column: callSite.getColumnNumber(),
|
|
113
|
+
functionName: callSite.getFunctionName(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// Silently fail and return nulls
|
|
119
|
+
} finally {
|
|
120
|
+
Error.prepareStackTrace = originalPrepareStackTrace;
|
|
121
|
+
}
|
|
122
|
+
return { filePath: null, line: null, column: null, functionName: null };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Custom error class for element operation failures
|
|
127
|
+
* Includes debugging information like screenshots and AI responses
|
|
128
|
+
*/
|
|
129
|
+
class ElementNotFoundError extends Error {
|
|
130
|
+
constructor(message, debugInfo = {}) {
|
|
131
|
+
super(message);
|
|
132
|
+
this.name = "ElementNotFoundError";
|
|
133
|
+
// Sanitize aiResponse to remove base64 images before storing
|
|
134
|
+
this.aiResponse = this._sanitizeAiResponse(debugInfo.aiResponse);
|
|
135
|
+
this.description = debugInfo.description;
|
|
136
|
+
this.timestamp = new Date().toISOString();
|
|
137
|
+
this.screenshotPath = null;
|
|
138
|
+
|
|
139
|
+
// Capture stack trace but skip internal frames
|
|
140
|
+
if (Error.captureStackTrace) {
|
|
141
|
+
Error.captureStackTrace(this, ElementNotFoundError);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Write screenshot to temp directory immediately (don't store on error object)
|
|
145
|
+
// This prevents vitest from serializing huge base64 strings
|
|
146
|
+
if (debugInfo.screenshot) {
|
|
147
|
+
try {
|
|
148
|
+
const tempDir = path.join(os.tmpdir(), "testdriver-debug");
|
|
149
|
+
if (!fs.existsSync(tempDir)) {
|
|
150
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const filename = `screenshot-${Date.now()}.png`;
|
|
154
|
+
this.screenshotPath = path.join(tempDir, filename);
|
|
155
|
+
|
|
156
|
+
// Remove data:image/png;base64, prefix if present
|
|
157
|
+
const base64Data = debugInfo.screenshot.replace(
|
|
158
|
+
/^data:image\/\w+;base64,/,
|
|
159
|
+
"",
|
|
160
|
+
);
|
|
161
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
162
|
+
|
|
163
|
+
fs.writeFileSync(this.screenshotPath, buffer);
|
|
164
|
+
} catch {
|
|
165
|
+
// If screenshot save fails, don't break the error
|
|
166
|
+
// Can't emit from constructor, just skip logging
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Save cached image if available
|
|
171
|
+
this.cachedImagePath = null;
|
|
172
|
+
if (debugInfo.cachedImageUrl) {
|
|
173
|
+
this.cachedImagePath = debugInfo.cachedImageUrl;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Save pixel diff image if available
|
|
177
|
+
this.pixelDiffPath = null;
|
|
178
|
+
if (debugInfo.pixelDiffImage) {
|
|
179
|
+
try {
|
|
180
|
+
const tempDir = path.join(os.tmpdir(), "testdriver-debug");
|
|
181
|
+
if (!fs.existsSync(tempDir)) {
|
|
182
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const filename = `pixel-diff-error-${Date.now()}.png`;
|
|
186
|
+
this.pixelDiffPath = path.join(tempDir, filename);
|
|
187
|
+
|
|
188
|
+
const base64Data = debugInfo.pixelDiffImage.replace(
|
|
189
|
+
/^data:image\/\w+;base64,/,
|
|
190
|
+
"",
|
|
191
|
+
);
|
|
192
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
193
|
+
|
|
194
|
+
fs.writeFileSync(this.pixelDiffPath, buffer);
|
|
195
|
+
} catch {
|
|
196
|
+
// Silently skip logging error from constructor
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Extract similarity and input text from AI response
|
|
201
|
+
const similarity = this.aiResponse?.similarity ?? null;
|
|
202
|
+
const cacheHit =
|
|
203
|
+
this.aiResponse?.cacheHit ?? this.aiResponse?.cached ?? false;
|
|
204
|
+
const cacheStrategy = this.aiResponse?.cacheStrategy ?? null;
|
|
205
|
+
const cacheCreatedAt = this.aiResponse?.cacheCreatedAt ?? null;
|
|
206
|
+
const cacheDiffPercent = this.aiResponse?.cacheDiffPercent ?? null;
|
|
207
|
+
const threshold = debugInfo.threshold ?? null;
|
|
208
|
+
const inputText =
|
|
209
|
+
this.aiResponse?.input_text ?? this.aiResponse?.element ?? null;
|
|
210
|
+
|
|
211
|
+
// Enhance error message with debugging hints
|
|
212
|
+
this.message += `\n\n=== Debug Information ===`;
|
|
213
|
+
this.message += `\nElement searched for: "${this.description}"`;
|
|
214
|
+
|
|
215
|
+
if (threshold !== null) {
|
|
216
|
+
const similarityRequired = ((1 - threshold) * 100).toFixed(1);
|
|
217
|
+
this.message += `\nCache threshold: ${threshold} (${similarityRequired}% similarity required)`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (cacheHit) {
|
|
221
|
+
this.message += `\nCache: HIT`;
|
|
222
|
+
if (cacheStrategy) {
|
|
223
|
+
this.message += ` (${cacheStrategy} strategy)`;
|
|
224
|
+
}
|
|
225
|
+
if (cacheCreatedAt) {
|
|
226
|
+
const cacheAge = Math.round(
|
|
227
|
+
(Date.now() - new Date(cacheCreatedAt).getTime()) / 1000,
|
|
228
|
+
);
|
|
229
|
+
this.message += `\nCache created: ${new Date(cacheCreatedAt).toISOString()} (${cacheAge}s ago)`;
|
|
230
|
+
}
|
|
231
|
+
if (cacheDiffPercent !== null) {
|
|
232
|
+
this.message += `\nCache pixel diff: ${(cacheDiffPercent * 100).toFixed(2)}%`;
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
this.message += `\nCache: MISS`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (similarity !== null) {
|
|
239
|
+
const similarityPercent = (similarity * 100).toFixed(2);
|
|
240
|
+
this.message += `\nSimilarity score: ${similarityPercent}%`;
|
|
241
|
+
|
|
242
|
+
if (threshold !== null && similarity < 1 - threshold) {
|
|
243
|
+
this.message += ` (below threshold)`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (inputText) {
|
|
248
|
+
this.message += `\nInput text: "${inputText}"`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (this.screenshotPath) {
|
|
252
|
+
this.message += `\nCurrent screenshot: ${this.screenshotPath}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (this.cachedImagePath) {
|
|
256
|
+
this.message += `\nCached image URL: ${this.cachedImagePath}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (this.pixelDiffPath) {
|
|
260
|
+
this.message += `\nPixel diff image: ${this.pixelDiffPath}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (this.aiResponse) {
|
|
264
|
+
const responseText =
|
|
265
|
+
this.aiResponse.reasoning ||
|
|
266
|
+
this.aiResponse.response?.content?.[0]?.text ||
|
|
267
|
+
this.aiResponse.content?.[0]?.text ||
|
|
268
|
+
"No detailed response available";
|
|
269
|
+
this.message += `\n\nAI Response:\n${responseText}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Clean up stack trace to only show userland code
|
|
273
|
+
if (this.stack) {
|
|
274
|
+
const lines = this.stack.split("\n");
|
|
275
|
+
const filteredLines = [lines[0]]; // Keep error message line
|
|
276
|
+
|
|
277
|
+
// Skip frames until we find userland code (not sdk.js internals)
|
|
278
|
+
let foundUserland = false;
|
|
279
|
+
for (let i = 1; i < lines.length; i++) {
|
|
280
|
+
const line = lines[i];
|
|
281
|
+
|
|
282
|
+
// Skip internal Element method frames (click, hover, etc.)
|
|
283
|
+
if (
|
|
284
|
+
line.includes("Element.click") ||
|
|
285
|
+
line.includes("Element.hover") ||
|
|
286
|
+
line.includes("Element.doubleClick") ||
|
|
287
|
+
line.includes("Element.rightClick") ||
|
|
288
|
+
line.includes("Element.mouseDown") ||
|
|
289
|
+
line.includes("Element.mouseUp")
|
|
290
|
+
) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Once we hit userland code, include everything from there
|
|
295
|
+
if (!line.includes("sdk.js") || foundUserland) {
|
|
296
|
+
foundUserland = true;
|
|
297
|
+
filteredLines.push(line);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
this.stack = filteredLines.join("\n");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Sanitize AI response by removing large base64 data to prevent serialization issues
|
|
307
|
+
* @private
|
|
308
|
+
* @param {Object} response - AI response
|
|
309
|
+
* @returns {Object} Sanitized response
|
|
310
|
+
*/
|
|
311
|
+
_sanitizeAiResponse(response) {
|
|
312
|
+
if (!response) return null;
|
|
313
|
+
|
|
314
|
+
// Create shallow copy and remove large base64 fields
|
|
315
|
+
const sanitized = { ...response };
|
|
316
|
+
delete sanitized.croppedImage;
|
|
317
|
+
delete sanitized.screenshot;
|
|
318
|
+
delete sanitized.pixelDiffImage;
|
|
319
|
+
// Keep cachedImageUrl as it's just a URL string, not base64 data
|
|
320
|
+
|
|
321
|
+
return sanitized;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Custom error class for ai() failures
|
|
327
|
+
* Includes task execution details and retry information
|
|
328
|
+
*/
|
|
329
|
+
class AIError extends Error {
|
|
330
|
+
/**
|
|
331
|
+
* @param {string} message - Error message
|
|
332
|
+
* @param {Object} details - Additional details about the failure
|
|
333
|
+
* @param {string} details.task - The task that was attempted
|
|
334
|
+
* @param {number} details.tries - Number of check attempts made
|
|
335
|
+
* @param {number} details.maxTries - Maximum tries that were allowed
|
|
336
|
+
* @param {number} details.duration - Total execution time in milliseconds
|
|
337
|
+
* @param {Error} [details.cause] - The underlying error that caused the failure
|
|
338
|
+
*/
|
|
339
|
+
constructor(message, details = {}) {
|
|
340
|
+
super(message);
|
|
341
|
+
this.name = "AIError";
|
|
342
|
+
this.task = details.task;
|
|
343
|
+
this.tries = details.tries;
|
|
344
|
+
this.maxTries = details.maxTries;
|
|
345
|
+
this.duration = details.duration;
|
|
346
|
+
this.cause = details.cause;
|
|
347
|
+
this.timestamp = new Date().toISOString();
|
|
348
|
+
|
|
349
|
+
// Capture stack trace
|
|
350
|
+
if (Error.captureStackTrace) {
|
|
351
|
+
Error.captureStackTrace(this, AIError);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Enhance error message with execution details
|
|
355
|
+
this.message += `\n\n=== AI Execution Details ===`;
|
|
356
|
+
this.message += `\nTask: "${this.task}"`;
|
|
357
|
+
this.message += `\nTries: ${this.tries}/${this.maxTries}`;
|
|
358
|
+
this.message += `\nDuration: ${this.duration}ms`;
|
|
359
|
+
this.message += `\nTimestamp: ${this.timestamp}`;
|
|
360
|
+
|
|
361
|
+
if (this.cause) {
|
|
362
|
+
this.message += `\nUnderlying error: ${this.cause.message}`;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Element class representing a located or to-be-located element
|
|
369
|
+
*/
|
|
370
|
+
class Element {
|
|
371
|
+
constructor(description, sdk, system, commands) {
|
|
372
|
+
this.description = description;
|
|
373
|
+
this.sdk = sdk;
|
|
374
|
+
this.system = system;
|
|
375
|
+
this.commands = commands;
|
|
376
|
+
this.coordinates = null;
|
|
377
|
+
/* The above code is a JavaScript comment block that sets the `_found` property of an object to
|
|
378
|
+
`false`. The code snippet does not contain any executable code, it is just a comment. */
|
|
379
|
+
this._found = false;
|
|
380
|
+
this._response = null;
|
|
381
|
+
this._screenshot = null;
|
|
382
|
+
this._threshold = null; // Store the threshold used for this find
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Check if element was found
|
|
387
|
+
* @returns {boolean} True if element coordinates were located
|
|
388
|
+
*/
|
|
389
|
+
found() {
|
|
390
|
+
return this._found;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Serialize element to JSON safely (removes circular references)
|
|
395
|
+
* This is automatically called by JSON.stringify()
|
|
396
|
+
* @returns {Object} Serializable representation of the element
|
|
397
|
+
*/
|
|
398
|
+
toJSON() {
|
|
399
|
+
const result = {
|
|
400
|
+
description: this.description,
|
|
401
|
+
coordinates: this.coordinates,
|
|
402
|
+
found: this._found,
|
|
403
|
+
threshold: this._threshold,
|
|
404
|
+
x: this.coordinates?.x,
|
|
405
|
+
y: this.coordinates?.y,
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
// Include response metadata if available
|
|
409
|
+
if (this._response) {
|
|
410
|
+
result.cache = {
|
|
411
|
+
hit:
|
|
412
|
+
this._response.cacheHit ||
|
|
413
|
+
this._response.cache_hit ||
|
|
414
|
+
this._response.cached ||
|
|
415
|
+
false,
|
|
416
|
+
strategy: this._response.cacheStrategy,
|
|
417
|
+
createdAt: this._response.cacheCreatedAt,
|
|
418
|
+
diffPercent: this._response.cacheDiffPercent,
|
|
419
|
+
imageUrl: this._response.cachedImageUrl,
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
result.similarity = this._response.similarity;
|
|
423
|
+
result.confidence = this._response.confidence;
|
|
424
|
+
result.reasoning = this._response.reasoning;
|
|
425
|
+
result.selector = this._response.selector;
|
|
426
|
+
|
|
427
|
+
// Include AI response text if available
|
|
428
|
+
if (this._response.response?.content?.[0]?.text) {
|
|
429
|
+
result.aiResponse = this._response.response.content[0].text;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return result;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Find the element on screen
|
|
438
|
+
* @param {string} [newDescription] - Optional new description to search for
|
|
439
|
+
* @param {Object} [options] - Optional options object with cache thresholds, cacheKey, and/or timeout
|
|
440
|
+
* @param {number} [options.timeout] - Max time in ms to poll for element (polls every 5 seconds)
|
|
441
|
+
* @param {Object} [options.cache] - Cache configuration { thresholds: { screen, element } }
|
|
442
|
+
* @returns {Promise<Element>} This element instance
|
|
443
|
+
*/
|
|
444
|
+
async find(newDescription, options) {
|
|
445
|
+
// Handle timeout/polling option (default: 30s)
|
|
446
|
+
const timeout = typeof options === "object" && options?.timeout !== undefined
|
|
447
|
+
? options.timeout
|
|
448
|
+
: 10000;
|
|
449
|
+
if (timeout && timeout > 0) {
|
|
450
|
+
return this._findWithTimeout(newDescription, options, timeout);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const description = newDescription || this.description;
|
|
454
|
+
if (newDescription) {
|
|
455
|
+
this.description = newDescription;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Capture absolute timestamp at the very start of the command
|
|
459
|
+
// Frontend will calculate relative time using: timestamp - replay.clientStartDate
|
|
460
|
+
const absoluteTimestamp = Date.now();
|
|
461
|
+
const startTime = absoluteTimestamp;
|
|
462
|
+
let response = null;
|
|
463
|
+
let findError = null;
|
|
464
|
+
|
|
465
|
+
const debugMode =
|
|
466
|
+
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
467
|
+
|
|
468
|
+
// Log finding action
|
|
469
|
+
const { events } = require("./agent/events.js");
|
|
470
|
+
const findingMessage = formatter.formatElementFinding(description);
|
|
471
|
+
this.sdk.emitter.emit(events.log.log, findingMessage);
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
const screenshot = await this.system.captureScreenBase64();
|
|
475
|
+
// Only store screenshot in DEBUG mode to prevent memory leaks
|
|
476
|
+
if (debugMode) {
|
|
477
|
+
this._screenshot = screenshot;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Handle options - can be a number (cacheThreshold) or object with cacheKey/cacheThreshold/cache
|
|
481
|
+
let cacheKey = null;
|
|
482
|
+
let cacheThreshold = null;
|
|
483
|
+
let perCommandThresholds = null; // Per-command { screen, element } override
|
|
484
|
+
let zoom = false; // Default to disabled, enable with zoom: true
|
|
485
|
+
let perCommandAi = null; // Per-command AI config override
|
|
486
|
+
|
|
487
|
+
let minConfidence = null; // Minimum confidence threshold
|
|
488
|
+
let elementType = null; // Element type hint: "text", "image", "ui", or "any"
|
|
489
|
+
|
|
490
|
+
if (typeof options === "number") {
|
|
491
|
+
// Legacy: options is just a number threshold
|
|
492
|
+
cacheThreshold = options;
|
|
493
|
+
} else if (typeof options === "object" && options !== null) {
|
|
494
|
+
// New: options is an object with cacheKey and/or cacheThreshold
|
|
495
|
+
cacheKey = options.cacheKey || null;
|
|
496
|
+
cacheThreshold = options.cacheThreshold ?? null;
|
|
497
|
+
// zoom defaults to false unless explicitly set to true
|
|
498
|
+
zoom = options.zoom === true;
|
|
499
|
+
// Minimum confidence threshold: fail find if AI confidence is below this value
|
|
500
|
+
minConfidence = options.confidence ?? null;
|
|
501
|
+
// Element type hint for prompt wrapping
|
|
502
|
+
elementType = options.type ?? null;
|
|
503
|
+
// Per-command cache thresholds: { cache: { thresholds: { screen: 0.1, element: 0.2 } } }
|
|
504
|
+
if (typeof options.cache === "object" && options.cache?.thresholds) {
|
|
505
|
+
perCommandThresholds = options.cache.thresholds;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Use default cacheKey from SDK constructor if not provided in find() options
|
|
510
|
+
// BUT only if cache is not explicitly disabled via cache: false option
|
|
511
|
+
if (
|
|
512
|
+
!cacheKey &&
|
|
513
|
+
this.sdk.options?.cacheKey &&
|
|
514
|
+
!this.sdk._cacheExplicitlyDisabled
|
|
515
|
+
) {
|
|
516
|
+
cacheKey = this.sdk.options.cacheKey;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Determine threshold:
|
|
520
|
+
// - If cache is explicitly disabled, don't use cache even with cacheKey
|
|
521
|
+
// - If cacheKey is provided, enable cache with threshold
|
|
522
|
+
// - If no cacheKey, disable cache
|
|
523
|
+
let threshold;
|
|
524
|
+
let elementSimilarity;
|
|
525
|
+
if (this.sdk._cacheExplicitlyDisabled) {
|
|
526
|
+
// Cache explicitly disabled via cache: false option or TD_NO_CACHE env
|
|
527
|
+
threshold = -1;
|
|
528
|
+
elementSimilarity = -1;
|
|
529
|
+
cacheKey = null; // Clear any cacheKey to ensure cache is truly disabled
|
|
530
|
+
} else if (cacheKey) {
|
|
531
|
+
// cacheKey provided - enable cache with threshold
|
|
532
|
+
// Per-command thresholds > legacy cacheThreshold > global config
|
|
533
|
+
threshold = perCommandThresholds?.screen ?? cacheThreshold ?? this.sdk.cacheConfig?.thresholds?.find?.screen ?? 0.05;
|
|
534
|
+
elementSimilarity = perCommandThresholds?.element ?? this.sdk.cacheConfig?.thresholds?.find?.element ?? 0.8;
|
|
535
|
+
} else if (cacheThreshold !== null) {
|
|
536
|
+
// Explicit threshold provided without cacheKey
|
|
537
|
+
threshold = perCommandThresholds?.screen ?? cacheThreshold;
|
|
538
|
+
elementSimilarity = perCommandThresholds?.element ?? this.sdk.cacheConfig?.thresholds?.find?.element ?? 0.8;
|
|
539
|
+
} else {
|
|
540
|
+
// No cacheKey, no explicit threshold - disable cache
|
|
541
|
+
threshold = -1;
|
|
542
|
+
elementSimilarity = -1;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Store the threshold for debugging
|
|
546
|
+
this._threshold = threshold;
|
|
547
|
+
|
|
548
|
+
// Debug log threshold
|
|
549
|
+
if (debugMode) {
|
|
550
|
+
const { events } = require("./agent/events.js");
|
|
551
|
+
const autoGenMsg =
|
|
552
|
+
this.sdk._autoGeneratedCacheKey &&
|
|
553
|
+
cacheKey === this.sdk.options.cacheKey
|
|
554
|
+
? " (auto-generated from file hash)"
|
|
555
|
+
: "";
|
|
556
|
+
this.sdk.emitter.emit(
|
|
557
|
+
events.log.debug,
|
|
558
|
+
`🔍 find() threshold: ${threshold} (cache ${threshold < 0 ? "DISABLED" : "ENABLED"}${cacheKey ? `, cacheKey: ${cacheKey}${autoGenMsg}` : ""})`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
response = await this.sdk.apiClient.req("find", {
|
|
563
|
+
session: this.sdk.getSessionId(),
|
|
564
|
+
element: description,
|
|
565
|
+
image: screenshot,
|
|
566
|
+
threshold: threshold,
|
|
567
|
+
elementSimilarity: elementSimilarity,
|
|
568
|
+
cacheKey: cacheKey,
|
|
569
|
+
os: this.sdk.os,
|
|
570
|
+
resolution: this.sdk.resolution,
|
|
571
|
+
zoom: zoom,
|
|
572
|
+
confidence: minConfidence,
|
|
573
|
+
type: elementType,
|
|
574
|
+
ai: {
|
|
575
|
+
...this.sdk.aiConfig,
|
|
576
|
+
...(perCommandAi || {}),
|
|
577
|
+
top: { ...this.sdk.aiConfig?.top, ...(perCommandAi?.top || {}) },
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const duration = Date.now() - startTime;
|
|
582
|
+
|
|
583
|
+
if (response && response.coordinates) {
|
|
584
|
+
// Store response but clear large base64 data to prevent memory leaks
|
|
585
|
+
this._response = this._sanitizeResponse(response);
|
|
586
|
+
this.coordinates = response.coordinates;
|
|
587
|
+
this._found = true;
|
|
588
|
+
|
|
589
|
+
// Log debug information when element is found
|
|
590
|
+
this._logFoundDebug(response, duration);
|
|
591
|
+
} else {
|
|
592
|
+
this._response = this._sanitizeResponse(response);
|
|
593
|
+
this._found = false;
|
|
594
|
+
findError = "Element not found";
|
|
595
|
+
|
|
596
|
+
// Log not found
|
|
597
|
+
const duration = Date.now() - startTime;
|
|
598
|
+
const { events } = require("./agent/events.js");
|
|
599
|
+
const notFoundMessage = formatter.formatElementNotFound(description, {
|
|
600
|
+
duration: `${duration}ms`,
|
|
601
|
+
});
|
|
602
|
+
this.sdk.emitter.emit(events.log.log, notFoundMessage);
|
|
603
|
+
}
|
|
604
|
+
} catch (error) {
|
|
605
|
+
this._response = error.response
|
|
606
|
+
? this._sanitizeResponse(error.response)
|
|
607
|
+
: null;
|
|
608
|
+
this._found = false;
|
|
609
|
+
findError = error.message;
|
|
610
|
+
response = error.response;
|
|
611
|
+
|
|
612
|
+
// Log not found with error
|
|
613
|
+
const duration = Date.now() - startTime;
|
|
614
|
+
const { events } = require("./agent/events.js");
|
|
615
|
+
const notFoundMessage = formatter.formatElementNotFound(description, {
|
|
616
|
+
duration: `${duration}ms`,
|
|
617
|
+
error: error.message,
|
|
618
|
+
});
|
|
619
|
+
this.sdk.emitter.emit(events.log.log, notFoundMessage);
|
|
620
|
+
|
|
621
|
+
console.error("Error during find():", error);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Track find interaction once at the end (fire-and-forget, don't block)
|
|
625
|
+
const sessionId = this.sdk.getSessionId();
|
|
626
|
+
if (sessionId && this.sdk.apiClient) {
|
|
627
|
+
this.sdk.apiClient
|
|
628
|
+
.req("interaction/track", {
|
|
629
|
+
type: "find",
|
|
630
|
+
session: sessionId,
|
|
631
|
+
prompt: description,
|
|
632
|
+
timestamp: absoluteTimestamp, // Absolute epoch timestamp - frontend calculates relative using clientStartDate
|
|
633
|
+
success: this._found,
|
|
634
|
+
error: findError,
|
|
635
|
+
cacheHit:
|
|
636
|
+
response?.cacheHit ||
|
|
637
|
+
response?.cache_hit ||
|
|
638
|
+
response?.cached ||
|
|
639
|
+
false,
|
|
640
|
+
selector: response?.selector,
|
|
641
|
+
selectorUsed: !!response?.selector,
|
|
642
|
+
confidence: response?.confidence ?? null,
|
|
643
|
+
reasoning: response?.reasoning ?? null,
|
|
644
|
+
similarity: response?.similarity ?? null,
|
|
645
|
+
screenshotUrl: response?.screenshotKey ?? null,
|
|
646
|
+
})
|
|
647
|
+
.catch((err) => {
|
|
648
|
+
console.warn("Failed to track find interaction:", err.message);
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return this;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Find element with polling/timeout support
|
|
657
|
+
* @private
|
|
658
|
+
* @param {string} [newDescription] - Optional new description to search for
|
|
659
|
+
* @param {Object} options - Options object
|
|
660
|
+
* @param {number} timeout - Max time in ms to poll for element
|
|
661
|
+
* @returns {Promise<Element>} This element instance
|
|
662
|
+
*/
|
|
663
|
+
async _findWithTimeout(newDescription, options, timeout) {
|
|
664
|
+
const POLL_INTERVAL = 5000; // 5 seconds between attempts
|
|
665
|
+
const startTime = Date.now();
|
|
666
|
+
const description = newDescription || this.description;
|
|
667
|
+
|
|
668
|
+
// Log that we're starting a polling find
|
|
669
|
+
const { events } = require("./agent/events.js");
|
|
670
|
+
this.sdk.emitter.emit(
|
|
671
|
+
events.log.log,
|
|
672
|
+
`🔄 Polling for "${description}" (timeout: ${timeout}ms)`,
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
// Create options without timeout to avoid infinite recursion
|
|
676
|
+
const findOptions = typeof options === "object" ? { ...options } : {};
|
|
677
|
+
findOptions.timeout = 0;
|
|
678
|
+
|
|
679
|
+
let attempts = 0;
|
|
680
|
+
while (Date.now() - startTime < timeout) {
|
|
681
|
+
attempts++;
|
|
682
|
+
|
|
683
|
+
// Call the regular find (without timeout option)
|
|
684
|
+
await this.find(newDescription, findOptions);
|
|
685
|
+
|
|
686
|
+
if (this._found) {
|
|
687
|
+
this.sdk.emitter.emit(
|
|
688
|
+
events.log.log,
|
|
689
|
+
`✅ Found "${description}" after ${attempts} attempt(s)`,
|
|
690
|
+
);
|
|
691
|
+
return this;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const elapsed = Date.now() - startTime;
|
|
695
|
+
const remaining = timeout - elapsed;
|
|
696
|
+
|
|
697
|
+
if (remaining > POLL_INTERVAL) {
|
|
698
|
+
this.sdk.emitter.emit(
|
|
699
|
+
events.log.log,
|
|
700
|
+
`⏳ Element not found, retrying in 5s... (${Math.round(remaining / 1000)}s remaining)`,
|
|
701
|
+
);
|
|
702
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
|
|
703
|
+
} else if (remaining > 0) {
|
|
704
|
+
// Less than 5s remaining, wait the remaining time and try once more
|
|
705
|
+
await new Promise((resolve) => setTimeout(resolve, remaining));
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Final attempt after timeout
|
|
710
|
+
await this.find(newDescription, findOptions);
|
|
711
|
+
|
|
712
|
+
if (!this._found) {
|
|
713
|
+
this.sdk.emitter.emit(
|
|
714
|
+
events.log.log,
|
|
715
|
+
`❌ Element "${description}" not found after ${timeout}ms (${attempts} attempts)`,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return this;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Sanitize response by removing large base64 data to prevent memory leaks
|
|
724
|
+
* @private
|
|
725
|
+
* @param {Object} response - API response
|
|
726
|
+
* @returns {Object} Sanitized response
|
|
727
|
+
*/
|
|
728
|
+
_sanitizeResponse(response) {
|
|
729
|
+
if (!response) return null;
|
|
730
|
+
|
|
731
|
+
// Only keep base64 data in DEBUG mode
|
|
732
|
+
const debugMode =
|
|
733
|
+
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
734
|
+
if (debugMode) {
|
|
735
|
+
return response;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Create shallow copy and remove large base64 fields
|
|
739
|
+
const sanitized = { ...response };
|
|
740
|
+
delete sanitized.croppedImage;
|
|
741
|
+
delete sanitized.screenshot;
|
|
742
|
+
|
|
743
|
+
return sanitized;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Log debug information when element is successfully found
|
|
748
|
+
* @private
|
|
749
|
+
*/
|
|
750
|
+
async _logFoundDebug(response, duration) {
|
|
751
|
+
const debugInfo = {
|
|
752
|
+
description: this.description,
|
|
753
|
+
coordinates: this.coordinates,
|
|
754
|
+
duration: `${duration}ms`,
|
|
755
|
+
cacheHit:
|
|
756
|
+
response.cacheHit || response.cache_hit || response.cached || false,
|
|
757
|
+
cacheStrategy: response.cacheStrategy || null,
|
|
758
|
+
similarity: response.similarity ?? null,
|
|
759
|
+
confidence: response.confidence ?? null,
|
|
760
|
+
reasoning: response.reasoning ?? null,
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
// Emit element found as log:log event
|
|
764
|
+
const { events } = require("./agent/events.js");
|
|
765
|
+
const Dashcam = require("./lib/core/Dashcam");
|
|
766
|
+
const consoleUrl = Dashcam.getConsoleUrl(this.sdk.config?.TD_API_ROOT);
|
|
767
|
+
const meta = {
|
|
768
|
+
x: this.coordinates.x,
|
|
769
|
+
y: this.coordinates.y,
|
|
770
|
+
duration: debugInfo.duration,
|
|
771
|
+
cacheHit: debugInfo.cacheHit,
|
|
772
|
+
selectorId: this._response?.selector,
|
|
773
|
+
consoleUrl: consoleUrl,
|
|
774
|
+
validated: response.validated ?? null,
|
|
775
|
+
validationConfidence: response.validationConfidence ?? null,
|
|
776
|
+
coordsUpdated: response.coordsUpdated ?? null,
|
|
777
|
+
};
|
|
778
|
+
if (!debugInfo.cacheHit) {
|
|
779
|
+
meta.confidence = debugInfo.confidence;
|
|
780
|
+
meta.reasoning = debugInfo.reasoning;
|
|
781
|
+
}
|
|
782
|
+
const formattedMessage = formatter.formatElementFound(this.description, meta);
|
|
783
|
+
this.sdk.emitter.emit(events.log.log, formattedMessage);
|
|
784
|
+
|
|
785
|
+
// Log cache information in debug mode
|
|
786
|
+
const debugMode =
|
|
787
|
+
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
788
|
+
if (debugMode) {
|
|
789
|
+
const { events } = require("./agent/events.js");
|
|
790
|
+
this.sdk.emitter.emit(events.log.debug, "Element Found:");
|
|
791
|
+
this.sdk.emitter.emit(
|
|
792
|
+
events.log.debug,
|
|
793
|
+
` Description: ${debugInfo.description}`,
|
|
794
|
+
);
|
|
795
|
+
this.sdk.emitter.emit(
|
|
796
|
+
events.log.debug,
|
|
797
|
+
` Coordinates: (${this.coordinates.x}, ${this.coordinates.y})`,
|
|
798
|
+
);
|
|
799
|
+
this.sdk.emitter.emit(
|
|
800
|
+
events.log.debug,
|
|
801
|
+
` Duration: ${debugInfo.duration}`,
|
|
802
|
+
);
|
|
803
|
+
this.sdk.emitter.emit(
|
|
804
|
+
events.log.debug,
|
|
805
|
+
` Cache Hit: ${debugInfo.cacheHit ? "✅ YES" : "❌ NO"}`,
|
|
806
|
+
);
|
|
807
|
+
if (debugInfo.cacheHit) {
|
|
808
|
+
this.sdk.emitter.emit(
|
|
809
|
+
events.log.debug,
|
|
810
|
+
` Cache Strategy: ${debugInfo.cacheStrategy || "unknown"}`,
|
|
811
|
+
);
|
|
812
|
+
this.sdk.emitter.emit(
|
|
813
|
+
events.log.debug,
|
|
814
|
+
` Similarity: ${debugInfo.similarity !== null ? (debugInfo.similarity * 100).toFixed(2) + "%" : "N/A"}`,
|
|
815
|
+
);
|
|
816
|
+
if (response.cacheCreatedAt) {
|
|
817
|
+
const cacheAge = Math.round(
|
|
818
|
+
(Date.now() - new Date(response.cacheCreatedAt).getTime()) / 1000,
|
|
819
|
+
);
|
|
820
|
+
this.sdk.emitter.emit(
|
|
821
|
+
events.log.debug,
|
|
822
|
+
` Cache Age: ${cacheAge}s (created: ${new Date(response.cacheCreatedAt).toISOString()})`,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
if (response.cachedImageUrl) {
|
|
826
|
+
this.sdk.emitter.emit(
|
|
827
|
+
events.log.debug,
|
|
828
|
+
` Cached Image URL: ${response.cachedImageUrl}`,
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
if (response.cacheDiffPercent !== undefined) {
|
|
832
|
+
this.sdk.emitter.emit(
|
|
833
|
+
events.log.debug,
|
|
834
|
+
` Pixel Diff: ${(response.cacheDiffPercent * 100).toFixed(2)}%`,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (debugInfo.confidence !== null) {
|
|
839
|
+
this.sdk.emitter.emit(
|
|
840
|
+
events.log.debug,
|
|
841
|
+
` Confidence: ${(debugInfo.confidence * 100).toFixed(2)}%`,
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Log available response fields for debugging
|
|
846
|
+
this.sdk.emitter.emit(
|
|
847
|
+
events.log.debug,
|
|
848
|
+
` Has croppedImage: ${!!response.croppedImage}`,
|
|
849
|
+
);
|
|
850
|
+
this.sdk.emitter.emit(
|
|
851
|
+
events.log.debug,
|
|
852
|
+
` Has screenshot: ${!!response.screenshot}`,
|
|
853
|
+
);
|
|
854
|
+
this.sdk.emitter.emit(
|
|
855
|
+
events.log.debug,
|
|
856
|
+
` Has cachedImageUrl: ${!!response.cachedImageUrl}`,
|
|
857
|
+
);
|
|
858
|
+
this.sdk.emitter.emit(
|
|
859
|
+
events.log.debug,
|
|
860
|
+
` Has pixelDiffImage: ${!!response.pixelDiffImage}`,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Save cropped image with red circle if available
|
|
865
|
+
let croppedImagePath = null;
|
|
866
|
+
if (response.croppedImage) {
|
|
867
|
+
try {
|
|
868
|
+
const tempDir = path.join(os.tmpdir(), "testdriver-debug");
|
|
869
|
+
if (!fs.existsSync(tempDir)) {
|
|
870
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const filename = `element-found-${Date.now()}.png`;
|
|
874
|
+
croppedImagePath = path.join(tempDir, filename);
|
|
875
|
+
|
|
876
|
+
// Remove data:image/png;base64, prefix if present
|
|
877
|
+
const base64Data = response.croppedImage.replace(
|
|
878
|
+
/^data:image\/\w+;base64,/,
|
|
879
|
+
"",
|
|
880
|
+
);
|
|
881
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
882
|
+
|
|
883
|
+
fs.writeFileSync(croppedImagePath, buffer);
|
|
884
|
+
|
|
885
|
+
if (debugMode) {
|
|
886
|
+
const { events } = require("./agent/events.js");
|
|
887
|
+
this.sdk.emitter.emit(
|
|
888
|
+
events.log.debug,
|
|
889
|
+
` Debug Image: ${croppedImagePath}`,
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
} catch (err) {
|
|
893
|
+
const { events } = require("./agent/events.js");
|
|
894
|
+
const errorMsg = formatter.formatError(
|
|
895
|
+
"Failed to save debug image",
|
|
896
|
+
err,
|
|
897
|
+
);
|
|
898
|
+
this.sdk.emitter.emit(events.log.log, errorMsg);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Save cached screenshot if available and this was a cache hit
|
|
903
|
+
let cachedScreenshotPath = null;
|
|
904
|
+
if (debugInfo.cacheHit && response.screenshot) {
|
|
905
|
+
try {
|
|
906
|
+
const tempDir = path.join(os.tmpdir(), "testdriver-debug");
|
|
907
|
+
if (!fs.existsSync(tempDir)) {
|
|
908
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const filename = `cached-screenshot-${Date.now()}.png`;
|
|
912
|
+
cachedScreenshotPath = path.join(tempDir, filename);
|
|
913
|
+
|
|
914
|
+
// Remove data:image/png;base64, prefix if present
|
|
915
|
+
const base64Data = response.screenshot.replace(
|
|
916
|
+
/^data:image\/\w+;base64,/,
|
|
917
|
+
"",
|
|
918
|
+
);
|
|
919
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
920
|
+
|
|
921
|
+
fs.writeFileSync(cachedScreenshotPath, buffer);
|
|
922
|
+
|
|
923
|
+
if (debugMode) {
|
|
924
|
+
const { events } = require("./agent/events.js");
|
|
925
|
+
this.sdk.emitter.emit(
|
|
926
|
+
events.log.debug,
|
|
927
|
+
` Cached Screenshot: ${cachedScreenshotPath}`,
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
} catch (err) {
|
|
931
|
+
const { events } = require("./agent/events.js");
|
|
932
|
+
const errorMsg = formatter.formatError(
|
|
933
|
+
"Failed to save cached screenshot",
|
|
934
|
+
err,
|
|
935
|
+
);
|
|
936
|
+
this.sdk.emitter.emit(events.log.log, errorMsg);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Save pixel diff image if available and this was a cache hit
|
|
941
|
+
let pixelDiffPath = null;
|
|
942
|
+
if (debugInfo.cacheHit && response.pixelDiffImage) {
|
|
943
|
+
try {
|
|
944
|
+
const tempDir = path.join(os.tmpdir(), "testdriver-debug");
|
|
945
|
+
if (!fs.existsSync(tempDir)) {
|
|
946
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const filename = `pixel-diff-${Date.now()}.png`;
|
|
950
|
+
pixelDiffPath = path.join(tempDir, filename);
|
|
951
|
+
|
|
952
|
+
// Remove data:image/png;base64, prefix if present
|
|
953
|
+
const base64Data = response.pixelDiffImage.replace(
|
|
954
|
+
/^data:image\/\w+;base64,/,
|
|
955
|
+
"",
|
|
956
|
+
);
|
|
957
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
958
|
+
|
|
959
|
+
fs.writeFileSync(pixelDiffPath, buffer);
|
|
960
|
+
|
|
961
|
+
if (debugMode) {
|
|
962
|
+
const { events } = require("./agent/events.js");
|
|
963
|
+
this.sdk.emitter.emit(
|
|
964
|
+
events.log.debug,
|
|
965
|
+
` Pixel Diff Image: ${pixelDiffPath}`,
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
} catch (err) {
|
|
969
|
+
const { events } = require("./agent/events.js");
|
|
970
|
+
const errorMsg = formatter.formatError(
|
|
971
|
+
"Failed to save pixel diff image",
|
|
972
|
+
err,
|
|
973
|
+
);
|
|
974
|
+
this.sdk.emitter.emit(events.log.log, errorMsg);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Click on the element
|
|
981
|
+
* @param {ClickAction} [action='click'] - Type of click action
|
|
982
|
+
* @returns {Promise<Element>} This element instance for chaining
|
|
983
|
+
*/
|
|
984
|
+
async click(action = "click") {
|
|
985
|
+
if (!this._found || !this.coordinates) {
|
|
986
|
+
throw new ElementNotFoundError(
|
|
987
|
+
`Element "${this.description}" not found.`,
|
|
988
|
+
{
|
|
989
|
+
description: this.description,
|
|
990
|
+
aiResponse: this._response,
|
|
991
|
+
threshold: this._threshold,
|
|
992
|
+
},
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Log the action
|
|
997
|
+
const { events } = require("./agent/events.js");
|
|
998
|
+
const actionName = action === "click" ? "click" : action.replace("-", " ");
|
|
999
|
+
const formattedMessage = formatter.formatAction(
|
|
1000
|
+
actionName,
|
|
1001
|
+
this.description,
|
|
1002
|
+
);
|
|
1003
|
+
this.sdk.emitter.emit(events.log.log, formattedMessage);
|
|
1004
|
+
|
|
1005
|
+
// Prepare element metadata for interaction tracking
|
|
1006
|
+
const elementData = {
|
|
1007
|
+
prompt: this.description,
|
|
1008
|
+
elementType: this._response?.elementType,
|
|
1009
|
+
elementBounds: this._response?.elementBounds,
|
|
1010
|
+
croppedImageUrl: this._response?.savedImagePath,
|
|
1011
|
+
edgeDetectedImageUrl: this._response?.edgeSavedImagePath || null,
|
|
1012
|
+
cacheHit: this._response?.cacheHit,
|
|
1013
|
+
selectorUsed: !!this._response?.selector,
|
|
1014
|
+
selector: this._response?.selector,
|
|
1015
|
+
confidence: this._response?.confidence ?? null,
|
|
1016
|
+
reasoning: this._response?.reasoning ?? null,
|
|
1017
|
+
similarity: this._response?.similarity ?? null,
|
|
1018
|
+
screenshotUrl: this._response?.screenshotKey ?? null,
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
if (action === "hover") {
|
|
1022
|
+
await this.commands.hover(
|
|
1023
|
+
this.coordinates.x,
|
|
1024
|
+
this.coordinates.y,
|
|
1025
|
+
elementData,
|
|
1026
|
+
);
|
|
1027
|
+
} else {
|
|
1028
|
+
await this.commands.click(
|
|
1029
|
+
this.coordinates.x,
|
|
1030
|
+
this.coordinates.y,
|
|
1031
|
+
action,
|
|
1032
|
+
elementData,
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
return this;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Hover over the element
|
|
1041
|
+
* @returns {Promise<Element>} This element instance for chaining
|
|
1042
|
+
*/
|
|
1043
|
+
async hover() {
|
|
1044
|
+
if (!this._found || !this.coordinates) {
|
|
1045
|
+
throw new ElementNotFoundError(
|
|
1046
|
+
`Element "${this.description}" not found.`,
|
|
1047
|
+
{
|
|
1048
|
+
description: this.description,
|
|
1049
|
+
aiResponse: this._response,
|
|
1050
|
+
threshold: this._threshold,
|
|
1051
|
+
},
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Log the hover action
|
|
1056
|
+
const { events } = require("./agent/events.js");
|
|
1057
|
+
const formattedMessage = formatter.formatAction("hover", this.description);
|
|
1058
|
+
this.sdk.emitter.emit(events.log.log, formattedMessage);
|
|
1059
|
+
|
|
1060
|
+
// Prepare element metadata for interaction tracking
|
|
1061
|
+
const elementData = {
|
|
1062
|
+
prompt: this.description,
|
|
1063
|
+
elementType: this._response?.elementType,
|
|
1064
|
+
elementBounds: this._response?.elementBounds,
|
|
1065
|
+
croppedImageUrl: this._response?.savedImagePath,
|
|
1066
|
+
edgeDetectedImageUrl: this._response?.edgeSavedImagePath || null,
|
|
1067
|
+
cacheHit: this._response?.cacheHit,
|
|
1068
|
+
selectorUsed: !!this._response?.selector,
|
|
1069
|
+
selector: this._response?.selector,
|
|
1070
|
+
screenshotUrl: this._response?.screenshotKey ?? null,
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
await this.commands.hover(
|
|
1074
|
+
this.coordinates.x,
|
|
1075
|
+
this.coordinates.y,
|
|
1076
|
+
elementData,
|
|
1077
|
+
);
|
|
1078
|
+
|
|
1079
|
+
return this;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Double-click on the element
|
|
1084
|
+
* @returns {Promise<Element>} This element instance for chaining
|
|
1085
|
+
*/
|
|
1086
|
+
async doubleClick() {
|
|
1087
|
+
return this.click("double-click");
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Right-click on the element
|
|
1092
|
+
* @returns {Promise<Element>} This element instance for chaining
|
|
1093
|
+
*/
|
|
1094
|
+
async rightClick() {
|
|
1095
|
+
return this.click("right-click");
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Press mouse button down on this element
|
|
1100
|
+
* @returns {Promise<Element>} This element instance for chaining
|
|
1101
|
+
*/
|
|
1102
|
+
async mouseDown() {
|
|
1103
|
+
return this.click("mouseDown");
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Release mouse button on this element
|
|
1108
|
+
* @returns {Promise<Element>} This element instance for chaining
|
|
1109
|
+
*/
|
|
1110
|
+
async mouseUp() {
|
|
1111
|
+
return this.click("mouseUp");
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Get the coordinates of the element
|
|
1116
|
+
* @returns {{x: number, y: number, centerX: number, centerY: number}|null}
|
|
1117
|
+
*/
|
|
1118
|
+
getCoordinates() {
|
|
1119
|
+
return this.coordinates;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Get the x coordinate (top-left)
|
|
1124
|
+
* @returns {number|null}
|
|
1125
|
+
*/
|
|
1126
|
+
get x() {
|
|
1127
|
+
return this.coordinates?.x ?? null;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* Get the y coordinate (top-left)
|
|
1132
|
+
* @returns {number|null}
|
|
1133
|
+
*/
|
|
1134
|
+
get y() {
|
|
1135
|
+
return this.coordinates?.y ?? null;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Get the center x coordinate
|
|
1140
|
+
* @returns {number|null}
|
|
1141
|
+
*/
|
|
1142
|
+
get centerX() {
|
|
1143
|
+
return this.coordinates?.centerX ?? null;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Get the center y coordinate
|
|
1148
|
+
* @returns {number|null}
|
|
1149
|
+
*/
|
|
1150
|
+
get centerY() {
|
|
1151
|
+
return this.coordinates?.centerY ?? null;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Get the full API response data
|
|
1156
|
+
* @returns {Object|null}
|
|
1157
|
+
*/
|
|
1158
|
+
getResponse() {
|
|
1159
|
+
return this._response;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Get element screenshot if available
|
|
1164
|
+
* @returns {string|null} Base64 encoded screenshot
|
|
1165
|
+
*/
|
|
1166
|
+
get screenshot() {
|
|
1167
|
+
return this._response?.screenshot ?? null;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Get element confidence score if available
|
|
1172
|
+
* @returns {number|null}
|
|
1173
|
+
*/
|
|
1174
|
+
get confidence() {
|
|
1175
|
+
return this._response?.confidence ?? null;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Get model reasoning for why this element was selected
|
|
1180
|
+
* @returns {string|null}
|
|
1181
|
+
*/
|
|
1182
|
+
get reasoning() {
|
|
1183
|
+
return this._response?.reasoning ?? null;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Get element width if available
|
|
1188
|
+
* @returns {number|null}
|
|
1189
|
+
*/
|
|
1190
|
+
get width() {
|
|
1191
|
+
return this._response?.width ?? null;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Get element height if available
|
|
1196
|
+
* @returns {number|null}
|
|
1197
|
+
*/
|
|
1198
|
+
get height() {
|
|
1199
|
+
return this._response?.height ?? null;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Get element bounding box if available
|
|
1204
|
+
* @returns {Object|null}
|
|
1205
|
+
*/
|
|
1206
|
+
get boundingBox() {
|
|
1207
|
+
return this._response?.boundingBox ?? null;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Get element text content if available
|
|
1212
|
+
* @returns {string|null}
|
|
1213
|
+
*/
|
|
1214
|
+
get text() {
|
|
1215
|
+
return this._response?.text ?? null;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Get element label if available
|
|
1220
|
+
* @returns {string|null}
|
|
1221
|
+
*/
|
|
1222
|
+
get label() {
|
|
1223
|
+
return this._response?.label ?? null;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Save the debug screenshot to a file for manual inspection
|
|
1228
|
+
* @param {string} [filepath] - Path to save the screenshot (defaults to ./debug-screenshot-{timestamp}.png)
|
|
1229
|
+
* @returns {Promise<string>} Path to the saved screenshot
|
|
1230
|
+
*/
|
|
1231
|
+
async saveDebugScreenshot(filepath) {
|
|
1232
|
+
if (!this._screenshot) {
|
|
1233
|
+
throw new Error("No screenshot available.");
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const fs = require("fs").promises;
|
|
1237
|
+
const path = require("path");
|
|
1238
|
+
|
|
1239
|
+
const defaultPath = `./debug-screenshot-${Date.now()}.png`;
|
|
1240
|
+
const savePath = filepath || defaultPath;
|
|
1241
|
+
|
|
1242
|
+
// Remove data:image/png;base64, prefix if present
|
|
1243
|
+
const base64Data = this._screenshot.replace(/^data:image\/\w+;base64,/, "");
|
|
1244
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
1245
|
+
|
|
1246
|
+
await fs.writeFile(savePath, buffer);
|
|
1247
|
+
return path.resolve(savePath);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Get debug information about the last find operation
|
|
1252
|
+
* @returns {Object} Debug information including AI response and screenshot metadata
|
|
1253
|
+
*/
|
|
1254
|
+
getDebugInfo() {
|
|
1255
|
+
return {
|
|
1256
|
+
description: this.description,
|
|
1257
|
+
found: this._found,
|
|
1258
|
+
coordinates: this.coordinates,
|
|
1259
|
+
aiResponse: this._response,
|
|
1260
|
+
hasScreenshot: !!this._screenshot,
|
|
1261
|
+
screenshotSize: this._screenshot ? this._screenshot.length : 0,
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Clean up element resources to prevent memory leaks
|
|
1267
|
+
* Call this when you're done with the element
|
|
1268
|
+
*/
|
|
1269
|
+
destroy() {
|
|
1270
|
+
this._screenshot = null;
|
|
1271
|
+
this._response = null;
|
|
1272
|
+
this.coordinates = null;
|
|
1273
|
+
this.sdk = null;
|
|
1274
|
+
this.system = null;
|
|
1275
|
+
this.commands = null;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Creates a chainable promise that allows method chaining on find() results
|
|
1281
|
+
* This enables syntax like: await testdriver.find("button").click()
|
|
1282
|
+
*
|
|
1283
|
+
* @param {Promise<Element>} promise - The promise that resolves to an Element
|
|
1284
|
+
* @returns {Promise<Element> & ChainableElement} A promise with chainable element methods
|
|
1285
|
+
*/
|
|
1286
|
+
function createChainablePromise(promise) {
|
|
1287
|
+
// Define the chainable methods that should be available
|
|
1288
|
+
const chainableMethods = [
|
|
1289
|
+
"click",
|
|
1290
|
+
"hover",
|
|
1291
|
+
"doubleClick",
|
|
1292
|
+
"rightClick",
|
|
1293
|
+
"mouseDown",
|
|
1294
|
+
"mouseUp",
|
|
1295
|
+
];
|
|
1296
|
+
|
|
1297
|
+
// Create a new promise that wraps the original
|
|
1298
|
+
const chainablePromise = promise.then((element) => element);
|
|
1299
|
+
|
|
1300
|
+
// Add chainable methods to the promise
|
|
1301
|
+
for (const method of chainableMethods) {
|
|
1302
|
+
chainablePromise[method] = function (...args) {
|
|
1303
|
+
// Return a promise that waits for the element, then calls the method
|
|
1304
|
+
return promise.then((element) => element[method](...args));
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// Add getters for element properties (these return promises)
|
|
1309
|
+
Object.defineProperty(chainablePromise, "x", {
|
|
1310
|
+
get() {
|
|
1311
|
+
return promise.then((el) => el.x);
|
|
1312
|
+
},
|
|
1313
|
+
});
|
|
1314
|
+
Object.defineProperty(chainablePromise, "y", {
|
|
1315
|
+
get() {
|
|
1316
|
+
return promise.then((el) => el.y);
|
|
1317
|
+
},
|
|
1318
|
+
});
|
|
1319
|
+
Object.defineProperty(chainablePromise, "centerX", {
|
|
1320
|
+
get() {
|
|
1321
|
+
return promise.then((el) => el.centerX);
|
|
1322
|
+
},
|
|
1323
|
+
});
|
|
1324
|
+
Object.defineProperty(chainablePromise, "centerY", {
|
|
1325
|
+
get() {
|
|
1326
|
+
return promise.then((el) => el.centerY);
|
|
1327
|
+
},
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
// Add found() method
|
|
1331
|
+
chainablePromise.found = function () {
|
|
1332
|
+
return promise.then((el) => el.found());
|
|
1333
|
+
};
|
|
1334
|
+
|
|
1335
|
+
// Add getCoordinates() method
|
|
1336
|
+
chainablePromise.getCoordinates = function () {
|
|
1337
|
+
return promise.then((el) => el.getCoordinates());
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1340
|
+
// Add getResponse() method
|
|
1341
|
+
chainablePromise.getResponse = function () {
|
|
1342
|
+
return promise.then((el) => el.getResponse());
|
|
1343
|
+
};
|
|
1344
|
+
|
|
1345
|
+
return chainablePromise;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Normalize redraw options from new thresholds format or legacy format to internal format.
|
|
1350
|
+
* New: { enabled: true, thresholds: { screen: 0.05, network: true } }
|
|
1351
|
+
* Legacy: { enabled: true, diffThreshold: 0.1, screenRedraw: true, networkMonitor: true }
|
|
1352
|
+
* Internal: { enabled: true, screenRedraw: true, networkMonitor: false }
|
|
1353
|
+
* @param {Object} opts - raw redraw options
|
|
1354
|
+
* @returns {Object} normalised options the redraw subsystem expects
|
|
1355
|
+
*/
|
|
1356
|
+
function normalizeRedrawOptions(opts) {
|
|
1357
|
+
if (!opts || typeof opts !== "object") {
|
|
1358
|
+
return { enabled: !!opts };
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const result = { enabled: opts.enabled !== false };
|
|
1362
|
+
|
|
1363
|
+
// New thresholds format takes precedence
|
|
1364
|
+
if (opts.thresholds && typeof opts.thresholds === "object") {
|
|
1365
|
+
result.screenRedraw = opts.thresholds.screen !== false;
|
|
1366
|
+
result.networkMonitor = !!opts.thresholds.network;
|
|
1367
|
+
} else {
|
|
1368
|
+
// Legacy format fallback
|
|
1369
|
+
result.screenRedraw = opts.screenRedraw !== undefined ? opts.screenRedraw : true;
|
|
1370
|
+
result.networkMonitor = opts.networkMonitor !== undefined ? opts.networkMonitor : false;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
return result;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* TestDriver SDK
|
|
1378
|
+
*
|
|
1379
|
+
* This SDK provides programmatic access to TestDriver's AI-powered testing capabilities.
|
|
1380
|
+
* Automatically loads environment variables from .env file via dotenv.
|
|
1381
|
+
*
|
|
1382
|
+
* @example
|
|
1383
|
+
* const TestDriver = require('testdriverai');
|
|
1384
|
+
*
|
|
1385
|
+
* // API key loaded automatically from TD_API_KEY in .env
|
|
1386
|
+
* const client = new TestDriver();
|
|
1387
|
+
* await client.connect();
|
|
1388
|
+
*
|
|
1389
|
+
* // Pass options only (API key from .env)
|
|
1390
|
+
* const client = new TestDriver({ os: 'windows' });
|
|
1391
|
+
*
|
|
1392
|
+
* // Or pass API key explicitly
|
|
1393
|
+
* const client = new TestDriver('your-api-key');
|
|
1394
|
+
*
|
|
1395
|
+
* // New API
|
|
1396
|
+
* const element = await client.find('Submit button');
|
|
1397
|
+
* await element.click();
|
|
1398
|
+
*/
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* @typedef {'click' | 'right-click' | 'double-click' | 'hover' | 'mouseDown' | 'mouseUp'} ClickAction
|
|
1402
|
+
* @typedef {'up' | 'down' | 'left' | 'right'} ScrollDirection
|
|
1403
|
+
* @typedef {'keyboard' | 'mouse'} ScrollMethod
|
|
1404
|
+
* @typedef {'ai' | 'turbo'} TextMatchMethod
|
|
1405
|
+
* @typedef {'sh' | 'pwsh'} ExecLanguage
|
|
1406
|
+
* @typedef {'\\t' | '\n' | '\r' | ' ' | '!' | '"' | '#' | '$' | '%' | '&' | "'" | '(' | ')' | '*' | '+' | ',' | '-' | '.' | '/' | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | ':' | ';' | '<' | '=' | '>' | '?' | '@' | '[' | '\\' | ']' | '^' | '_' | '`' | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z' | '{' | '|' | '}' | '~' | 'accept' | 'add' | 'alt' | 'altleft' | 'altright' | 'apps' | 'backspace' | 'browserback' | 'browserfavorites' | 'browserforward' | 'browserhome' | 'browserrefresh' | 'browsersearch' | 'browserstop' | 'capslock' | 'clear' | 'convert' | 'ctrl' | 'ctrlleft' | 'ctrlright' | 'decimal' | 'del' | 'delete' | 'divide' | 'down' | 'end' | 'enter' | 'esc' | 'escape' | 'execute' | 'f1' | 'f10' | 'f11' | 'f12' | 'f13' | 'f14' | 'f15' | 'f16' | 'f17' | 'f18' | 'f19' | 'f2' | 'f20' | 'f21' | 'f22' | 'f23' | 'f24' | 'f3' | 'f4' | 'f5' | 'f6' | 'f7' | 'f8' | 'f9' | 'final' | 'fn' | 'hanguel' | 'hangul' | 'hanja' | 'help' | 'home' | 'insert' | 'junja' | 'kana' | 'kanji' | 'launchapp1' | 'launchapp2' | 'launchmail' | 'launchmediaselect' | 'left' | 'modechange' | 'multiply' | 'nexttrack' | 'nonconvert' | 'num0' | 'num1' | 'num2' | 'num3' | 'num4' | 'num5' | 'num6' | 'num7' | 'num8' | 'num9' | 'numlock' | 'pagedown' | 'pageup' | 'pause' | 'pgdn' | 'pgup' | 'playpause' | 'prevtrack' | 'print' | 'printscreen' | 'prntscrn' | 'prtsc' | 'prtscr' | 'return' | 'right' | 'scrolllock' | 'select' | 'separator' | 'shift' | 'shiftleft' | 'shiftright' | 'sleep' | 'space' | 'stop' | 'subtract' | 'tab' | 'up' | 'volumedown' | 'volumemute' | 'volumeup' | 'win' | 'winleft' | 'winright' | 'yen' | 'command' | 'option' | 'optionleft' | 'optionright'} KeyboardKey
|
|
1407
|
+
*/
|
|
1408
|
+
|
|
1409
|
+
const TestDriverAgent = require("./agent/index.js");
|
|
1410
|
+
const { events } = require("./agent/events.js");
|
|
1411
|
+
const { createMarkdownLogger } = require("./interfaces/logger.js");
|
|
1412
|
+
|
|
1413
|
+
// Track screenshot directories already cleaned in this process to avoid
|
|
1414
|
+
// concurrent tests in the same file from nuking each other's screenshots.
|
|
1415
|
+
const _cleanedScreenshotDirs = new Set();
|
|
1416
|
+
|
|
1417
|
+
class TestDriverSDK {
|
|
1418
|
+
constructor(apiKey, options = {}) {
|
|
1419
|
+
// Support calling with just options: new TestDriver({ os: 'windows' })
|
|
1420
|
+
if (typeof apiKey === 'object' && apiKey !== null) {
|
|
1421
|
+
options = apiKey;
|
|
1422
|
+
apiKey = null;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Use provided API key or fall back to environment variable
|
|
1426
|
+
const resolvedApiKey = apiKey || process.env.TD_API_KEY;
|
|
1427
|
+
|
|
1428
|
+
// Handle preview mode with backwards compatibility for headless option
|
|
1429
|
+
// Preview can be "browser" (default), "ide", or "none" (headless)
|
|
1430
|
+
let previewMode = options.preview || process.env.TD_PREVIEW;
|
|
1431
|
+
|
|
1432
|
+
// Backwards compatibility: headless: true maps to preview: "none"
|
|
1433
|
+
// headless: true takes precedence over any preview setting
|
|
1434
|
+
if (options.headless === true) {
|
|
1435
|
+
previewMode = "none";
|
|
1436
|
+
} else if (!previewMode) {
|
|
1437
|
+
previewMode = "browser"; // default
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// Set up environment with API key
|
|
1441
|
+
const channelConfig = require("./lib/resolve-channel.js");
|
|
1442
|
+
const environment = {
|
|
1443
|
+
TD_API_KEY: resolvedApiKey,
|
|
1444
|
+
TD_API_ROOT: options.apiRoot || process.env.TD_API_ROOT || channelConfig.channels[channelConfig.active],
|
|
1445
|
+
TD_RESOLUTION: options.resolution || "1366x768",
|
|
1446
|
+
TD_ANALYTICS: options.analytics !== false,
|
|
1447
|
+
TD_PREVIEW: previewMode,
|
|
1448
|
+
...options.environment,
|
|
1449
|
+
};
|
|
1450
|
+
|
|
1451
|
+
// Auto-detect CI environment (GitHub Actions, etc.) and pass through
|
|
1452
|
+
// This ensures the API creates fresh sandboxes instead of reusing hot-pool instances
|
|
1453
|
+
if (!environment.CI && process.env.CI) {
|
|
1454
|
+
environment.CI = process.env.CI;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// Create the underlying agent with minimal CLI args
|
|
1458
|
+
this.agent = new TestDriverAgent(environment, {
|
|
1459
|
+
command: "sdk",
|
|
1460
|
+
args: [],
|
|
1461
|
+
options: {
|
|
1462
|
+
os: options.os || "linux",
|
|
1463
|
+
preview: previewMode,
|
|
1464
|
+
},
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
// Auto-generate cache key from caller file hash if not explicitly provided
|
|
1468
|
+
// This allows caching to be tied to the specific test file
|
|
1469
|
+
if (!options.cacheKey) {
|
|
1470
|
+
const autoGeneratedKey = getCallerFileHash();
|
|
1471
|
+
if (autoGeneratedKey) {
|
|
1472
|
+
options.cacheKey = autoGeneratedKey;
|
|
1473
|
+
// Store flag to indicate this was auto-generated
|
|
1474
|
+
this._autoGeneratedCacheKey = true;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// Store options for later use
|
|
1479
|
+
this.options = options;
|
|
1480
|
+
|
|
1481
|
+
// Store os and resolution for API requests
|
|
1482
|
+
this.os = options.os || "linux";
|
|
1483
|
+
this.resolution = options.resolution || "1366x768";
|
|
1484
|
+
|
|
1485
|
+
// Store newSandbox preference from options
|
|
1486
|
+
this.newSandbox =
|
|
1487
|
+
options.newSandbox !== undefined ? options.newSandbox : true;
|
|
1488
|
+
|
|
1489
|
+
// Store headless preference from options
|
|
1490
|
+
this.headless = options.headless !== undefined ? options.headless : false;
|
|
1491
|
+
|
|
1492
|
+
// Store IP address if provided for direct connection
|
|
1493
|
+
this.ip = options.ip || null;
|
|
1494
|
+
|
|
1495
|
+
// Store EC2 instance ID for direct connections (used to provision Ably credentials via SSM)
|
|
1496
|
+
this.instanceId = options.instanceId || null;
|
|
1497
|
+
|
|
1498
|
+
// Store sandbox configuration options
|
|
1499
|
+
this.sandboxAmi = options.sandboxAmi || null;
|
|
1500
|
+
this.sandboxInstance = options.sandboxInstance || null;
|
|
1501
|
+
|
|
1502
|
+
// Store reconnect preference from options
|
|
1503
|
+
this.reconnect =
|
|
1504
|
+
options.reconnect !== undefined ? options.reconnect : false;
|
|
1505
|
+
|
|
1506
|
+
// Store dashcam preference (default: true)
|
|
1507
|
+
this.dashcamEnabled = options.dashcam !== false;
|
|
1508
|
+
|
|
1509
|
+
// Cache threshold configuration
|
|
1510
|
+
// threshold = pixel difference allowed (0.05 = 5% difference, 95% similarity)
|
|
1511
|
+
// By default, cache is DISABLED (threshold = -1) to avoid unnecessary AI costs
|
|
1512
|
+
// To enable cache, provide a cacheKey when calling find() or findAll()
|
|
1513
|
+
// Also support TD_NO_CACHE environment variable and cache: false option for backwards compatibility
|
|
1514
|
+
const cacheExplicitlyDisabled =
|
|
1515
|
+
options.cache === false || process.env.TD_NO_CACHE === "true";
|
|
1516
|
+
|
|
1517
|
+
// Track whether cache was explicitly disabled (not just default)
|
|
1518
|
+
this._cacheExplicitlyDisabled = cacheExplicitlyDisabled;
|
|
1519
|
+
|
|
1520
|
+
if (cacheExplicitlyDisabled) {
|
|
1521
|
+
// Explicit cache disabled via option or env var
|
|
1522
|
+
this.cacheThresholds = {
|
|
1523
|
+
find: -1,
|
|
1524
|
+
findAll: -1,
|
|
1525
|
+
assert: -1,
|
|
1526
|
+
};
|
|
1527
|
+
this.cacheConfig = {
|
|
1528
|
+
enabled: false,
|
|
1529
|
+
thresholds: {
|
|
1530
|
+
find: { screen: -1, element: -1 },
|
|
1531
|
+
assert: -1,
|
|
1532
|
+
},
|
|
1533
|
+
};
|
|
1534
|
+
} else {
|
|
1535
|
+
// Support cache object format: { cache: { thresholds: { find: { screen: 0.05, element: 0.8 }, assert: 0.05 } } }
|
|
1536
|
+
const cacheOpts = typeof options.cache === "object" ? options.cache : {};
|
|
1537
|
+
const thresholds = cacheOpts.thresholds || {};
|
|
1538
|
+
const findThresholds = typeof thresholds.find === "object" ? thresholds.find : {};
|
|
1539
|
+
|
|
1540
|
+
this.cacheConfig = {
|
|
1541
|
+
enabled: cacheOpts.enabled !== false,
|
|
1542
|
+
thresholds: {
|
|
1543
|
+
find: {
|
|
1544
|
+
screen: findThresholds.screen ?? 0.05, // Default: 5% pixel diff allowed
|
|
1545
|
+
element: findThresholds.element ?? 0.8, // Default: 80% OpenCV correlation
|
|
1546
|
+
},
|
|
1547
|
+
assert: thresholds.assert ?? 0.05, // Default: 5% pixel diff for assertions
|
|
1548
|
+
},
|
|
1549
|
+
};
|
|
1550
|
+
|
|
1551
|
+
// Legacy cacheThresholds - keep for backwards compatibility
|
|
1552
|
+
this.cacheThresholds = {
|
|
1553
|
+
find: options.cacheThreshold?.find ?? this.cacheConfig.thresholds.find.screen,
|
|
1554
|
+
findAll: options.cacheThreshold?.findAll ?? this.cacheConfig.thresholds.find.screen,
|
|
1555
|
+
assert: options.cacheThreshold?.assert ?? this.cacheConfig.thresholds.assert,
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// AI sampling configuration
|
|
1560
|
+
// Supports: { ai: { temperature: 0, top: { p: 1, k: 0 } } }
|
|
1561
|
+
// Can be overridden per find() or assert() call
|
|
1562
|
+
this.aiConfig = typeof options.ai === "object" ? {
|
|
1563
|
+
temperature: options.ai.temperature,
|
|
1564
|
+
top: {
|
|
1565
|
+
p: options.ai.top?.p,
|
|
1566
|
+
k: options.ai.top?.k,
|
|
1567
|
+
},
|
|
1568
|
+
} : {};
|
|
1569
|
+
|
|
1570
|
+
// Redraw configuration
|
|
1571
|
+
// Supports:
|
|
1572
|
+
// - redraw: { enabled: true, thresholds: { screen: 0.05, network: true } } (new)
|
|
1573
|
+
// - redraw: true/false (shorthand)
|
|
1574
|
+
// - redraw: { enabled: true, diffThreshold: 0.1, screenRedraw: true, networkMonitor: true } (legacy)
|
|
1575
|
+
// - redrawThreshold: 0.1 (legacy, deprecated)
|
|
1576
|
+
if (options.redraw !== undefined) {
|
|
1577
|
+
if (typeof options.redraw === "object") {
|
|
1578
|
+
this.redrawOptions = normalizeRedrawOptions(options.redraw);
|
|
1579
|
+
} else {
|
|
1580
|
+
this.redrawOptions = { enabled: !!options.redraw };
|
|
1581
|
+
}
|
|
1582
|
+
} else if (options.redrawThreshold !== undefined) {
|
|
1583
|
+
// Legacy API: redrawThreshold number or object (deprecated)
|
|
1584
|
+
this.redrawOptions =
|
|
1585
|
+
typeof options.redrawThreshold === "object"
|
|
1586
|
+
? normalizeRedrawOptions(options.redrawThreshold)
|
|
1587
|
+
: { enabled: true, screenRedraw: true, networkMonitor: false };
|
|
1588
|
+
} else {
|
|
1589
|
+
// Default: disabled (as of v7.3)
|
|
1590
|
+
this.redrawOptions = { enabled: false };
|
|
1591
|
+
}
|
|
1592
|
+
// Keep redrawThreshold for backwards compatibility in connect()
|
|
1593
|
+
this.redrawThreshold = this.redrawOptions;
|
|
1594
|
+
|
|
1595
|
+
// Track connection state
|
|
1596
|
+
this.connected = false;
|
|
1597
|
+
this.authenticated = false;
|
|
1598
|
+
|
|
1599
|
+
// Expose commonly used agent properties
|
|
1600
|
+
this.emitter = this.agent.emitter;
|
|
1601
|
+
this.config = this.agent.config;
|
|
1602
|
+
this.session = this.agent.session;
|
|
1603
|
+
this.apiClient = this.agent.sdk;
|
|
1604
|
+
this.analytics = this.agent.analytics;
|
|
1605
|
+
this.sandbox = this.agent.sandbox;
|
|
1606
|
+
this.system = this.agent.system;
|
|
1607
|
+
this.instance = null;
|
|
1608
|
+
|
|
1609
|
+
// Commands will be set up dynamically after connection
|
|
1610
|
+
this.commands = null;
|
|
1611
|
+
|
|
1612
|
+
// Set up logging if enabled (after emitter is exposed)
|
|
1613
|
+
this.loggingEnabled = options.logging !== false;
|
|
1614
|
+
|
|
1615
|
+
// Log buffer: structured entries collected during test execution.
|
|
1616
|
+
// Uploaded to S3 at cleanup so they can be displayed alongside dashcam replays.
|
|
1617
|
+
this._logBuffer = [];
|
|
1618
|
+
|
|
1619
|
+
// Set up event listeners once (they live for the lifetime of the SDK instance)
|
|
1620
|
+
this._setupLogging();
|
|
1621
|
+
|
|
1622
|
+
// Set up provision API
|
|
1623
|
+
this.provision = this._createProvisionAPI();
|
|
1624
|
+
|
|
1625
|
+
// Set up dashcam API lazily
|
|
1626
|
+
this._dashcam = null;
|
|
1627
|
+
|
|
1628
|
+
// Last-promise tracking for unawaited promise detection
|
|
1629
|
+
this._lastPromiseSettled = true;
|
|
1630
|
+
this._lastCommandName = null;
|
|
1631
|
+
|
|
1632
|
+
// Auto-screenshots configuration
|
|
1633
|
+
// When enabled, automatically captures screenshots before/after each command
|
|
1634
|
+
// Screenshots are saved to .testdriver/screenshots/<test>/ with descriptive names
|
|
1635
|
+
this.autoScreenshots = options.autoScreenshots === true;
|
|
1636
|
+
this._screenshotSequence = 0; // Counter for sequential screenshot naming
|
|
1637
|
+
|
|
1638
|
+
// Set up command methods that lazy-await connection
|
|
1639
|
+
this._setupCommandMethods();
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* Wait for the sandbox connection to complete
|
|
1644
|
+
* @returns {Promise<void>}
|
|
1645
|
+
*/
|
|
1646
|
+
async ready() {
|
|
1647
|
+
if (this.__connectionPromise) {
|
|
1648
|
+
await this.__connectionPromise;
|
|
1649
|
+
}
|
|
1650
|
+
if (!this.connected) {
|
|
1651
|
+
throw new Error("Not connected to sandbox. Call connect() first.");
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/**
|
|
1656
|
+
* Get or create the Dashcam instance
|
|
1657
|
+
* @returns {Dashcam} Dashcam instance (or no-op stub if dashcam is disabled)
|
|
1658
|
+
*/
|
|
1659
|
+
get dashcam() {
|
|
1660
|
+
if (!this._dashcam) {
|
|
1661
|
+
// If dashcam is disabled, return a no-op stub
|
|
1662
|
+
if (!this.dashcamEnabled) {
|
|
1663
|
+
this._dashcam = {
|
|
1664
|
+
start: async () => {},
|
|
1665
|
+
stop: async () => null,
|
|
1666
|
+
auth: async () => {},
|
|
1667
|
+
addFileLog: async () => {},
|
|
1668
|
+
addWebLog: async () => {},
|
|
1669
|
+
addApplicationLog: async () => {},
|
|
1670
|
+
addLog: async () => {},
|
|
1671
|
+
isRecording: async () => false,
|
|
1672
|
+
getElapsedTime: () => null,
|
|
1673
|
+
recording: false,
|
|
1674
|
+
url: null,
|
|
1675
|
+
};
|
|
1676
|
+
} else {
|
|
1677
|
+
const { Dashcam } = require("./lib/core/index.js");
|
|
1678
|
+
// Don't pass apiKey - let Dashcam use its default key
|
|
1679
|
+
this._dashcam = new Dashcam(this);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
return this._dashcam;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* Get milliseconds elapsed since dashcam started recording
|
|
1687
|
+
* @returns {number|null} Milliseconds since dashcam start, or null if not recording
|
|
1688
|
+
*/
|
|
1689
|
+
getDashcamElapsedTime() {
|
|
1690
|
+
if (this._dashcam) {
|
|
1691
|
+
return this._dashcam.getElapsedTime();
|
|
1692
|
+
}
|
|
1693
|
+
return null;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* Create the provision API with methods for launching applications
|
|
1698
|
+
* Automatically skips provisioning when reconnect mode is enabled
|
|
1699
|
+
* @private
|
|
1700
|
+
*/
|
|
1701
|
+
/**
|
|
1702
|
+
* Get the path to the dashcam-chrome extension
|
|
1703
|
+
* Uses preinstalled dashcam-chrome on both Linux and Windows
|
|
1704
|
+
* @returns {Promise<string>} Path to dashcam-chrome/build directory
|
|
1705
|
+
* @private
|
|
1706
|
+
*/
|
|
1707
|
+
async _getDashcamChromeExtensionPath() {
|
|
1708
|
+
if (this.os !== "windows") {
|
|
1709
|
+
return "/usr/lib/node_modules/dashcam-chrome/build";
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// dashcam-chrome is preinstalled on Windows at C:\Program Files\nodejs\node_modules\dashcam-chrome\build
|
|
1713
|
+
// Use the actual long path - we'll handle quoting in the chrome launch
|
|
1714
|
+
return "C:\\PROGRA~1\\nodejs\\node_modules\\dashcam-chrome\\build";
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* Extract domain pattern from a URL for web log tracking
|
|
1719
|
+
* @param {string} url - The URL to extract domain from
|
|
1720
|
+
* @returns {string} Domain pattern (e.g., "*://example.com/*")
|
|
1721
|
+
* @private
|
|
1722
|
+
*/
|
|
1723
|
+
_getUrlDomainPattern(url) {
|
|
1724
|
+
try {
|
|
1725
|
+
const parsed = new URL(url);
|
|
1726
|
+
// Use wildcard scheme and path to match all pages on the domain
|
|
1727
|
+
return `*://${parsed.hostname}*`;
|
|
1728
|
+
} catch (e) {
|
|
1729
|
+
// Fallback to ** if URL parsing fails
|
|
1730
|
+
console.warn(`[_getUrlDomainPattern] Failed to parse URL "${url}", using ** pattern`);
|
|
1731
|
+
return "**";
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/**
|
|
1736
|
+
* Wait for Chrome DevTools Protocol debugger to be ready on port 9222,
|
|
1737
|
+
* then wait for a page to report loaded.
|
|
1738
|
+
* Works on both Windows (PowerShell) and Linux (sh).
|
|
1739
|
+
* @param {number} [timeoutMs=60000] - Maximum time to wait in ms
|
|
1740
|
+
* @returns {Promise<void>}
|
|
1741
|
+
*/
|
|
1742
|
+
async _waitForChromeDebuggerReady(timeoutMs = 60000) {
|
|
1743
|
+
const shell = this.os === "windows" ? "pwsh" : "sh";
|
|
1744
|
+
// PowerShell: Use async connect with 2-second timeout to match Linux curl behavior.
|
|
1745
|
+
// TcpClient.Connect() is synchronous and can hang indefinitely, causing exec timeouts.
|
|
1746
|
+
const portCheckCmd = this.os === "windows"
|
|
1747
|
+
? `$tcp = New-Object System.Net.Sockets.TcpClient; $tcp.Connect('127.0.0.1', 9222); $tcp.Close(); echo 'open'`
|
|
1748
|
+
: `curl -s -o /dev/null --connect-timeout 2 http://localhost:9222 2>/dev/null && echo 'open' || echo 'closed'`;
|
|
1749
|
+
|
|
1750
|
+
const deadline = Date.now() + timeoutMs;
|
|
1751
|
+
|
|
1752
|
+
// Use commands.exec directly to bypass auto-screenshots wrapper.
|
|
1753
|
+
// The polling loop fires many rapid exec calls with short timeouts;
|
|
1754
|
+
// going through the wrapper adds 2-3 extra sandbox messages
|
|
1755
|
+
// (screenshot before/after/error) per iteration, overwhelming the
|
|
1756
|
+
// WebSocket and generating cascading "No pending promise" warnings
|
|
1757
|
+
// when timed-out responses arrive after the promise has been cleaned up.
|
|
1758
|
+
const execDirect = this.commands?.exec
|
|
1759
|
+
? (...args) => this.commands.exec(...args)
|
|
1760
|
+
: (...args) => this.exec(...args); // fallback if commands not ready
|
|
1761
|
+
|
|
1762
|
+
// Wait for port 9222 to be listening
|
|
1763
|
+
let portReady = false;
|
|
1764
|
+
while (Date.now() < deadline) {
|
|
1765
|
+
try {
|
|
1766
|
+
const result = await execDirect(shell, portCheckCmd, 10000, true);
|
|
1767
|
+
if (result && result.includes("open")) {
|
|
1768
|
+
portReady = true;
|
|
1769
|
+
break;
|
|
1770
|
+
}
|
|
1771
|
+
} catch (_) {
|
|
1772
|
+
// Port not ready yet
|
|
1773
|
+
}
|
|
1774
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1775
|
+
}
|
|
1776
|
+
if (!portReady) {
|
|
1777
|
+
throw new Error(
|
|
1778
|
+
`Chrome debugger port 9222 did not become available within ${timeoutMs}ms`,
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
_createProvisionAPI() {
|
|
1785
|
+
const self = this;
|
|
1786
|
+
|
|
1787
|
+
const provisionMethods = {
|
|
1788
|
+
/**
|
|
1789
|
+
* Launch Chrome browser
|
|
1790
|
+
* @param {Object} options - Chrome launch options
|
|
1791
|
+
* @param {string} [options.url='http://testdriver-sandbox.vercel.app/'] - URL to navigate to
|
|
1792
|
+
* @param {boolean} [options.maximized=true] - Start maximized
|
|
1793
|
+
* @param {boolean} [options.guest=false] - Use guest mode
|
|
1794
|
+
* @returns {Promise<void>}
|
|
1795
|
+
*/
|
|
1796
|
+
chrome: async (options = {}) => {
|
|
1797
|
+
const {
|
|
1798
|
+
url = "http://testdriver-sandbox.vercel.app/",
|
|
1799
|
+
maximized = true,
|
|
1800
|
+
guest = false,
|
|
1801
|
+
} = options;
|
|
1802
|
+
|
|
1803
|
+
// Store the URL for domain-specific web log tracking
|
|
1804
|
+
self._provisionedChromeUrl = url;
|
|
1805
|
+
|
|
1806
|
+
// Set up Chrome profile with preferences
|
|
1807
|
+
const shell = this.os === "windows" ? "pwsh" : "sh";
|
|
1808
|
+
const userDataDir =
|
|
1809
|
+
this.os === "windows"
|
|
1810
|
+
? "C:\\Users\\testdriver\\AppData\\Local\\TestDriver\\Chrome"
|
|
1811
|
+
: "/tmp/testdriver-chrome-profile";
|
|
1812
|
+
|
|
1813
|
+
// Create user data directory and Default profile directory
|
|
1814
|
+
const defaultProfileDir =
|
|
1815
|
+
this.os === "windows"
|
|
1816
|
+
? `${userDataDir}\\Default`
|
|
1817
|
+
: `${userDataDir}/Default`;
|
|
1818
|
+
|
|
1819
|
+
const createDirCmd =
|
|
1820
|
+
this.os === "windows"
|
|
1821
|
+
? `New-Item -ItemType Directory -Path "${defaultProfileDir}" -Force | Out-Null`
|
|
1822
|
+
: `mkdir -p "${defaultProfileDir}"`;
|
|
1823
|
+
|
|
1824
|
+
await this.exec(shell, createDirCmd, 60000, true);
|
|
1825
|
+
|
|
1826
|
+
// Write Chrome preferences
|
|
1827
|
+
const chromePrefs = {
|
|
1828
|
+
credentials_enable_service: false,
|
|
1829
|
+
profile: {
|
|
1830
|
+
password_manager_enabled: false,
|
|
1831
|
+
default_content_setting_values: {},
|
|
1832
|
+
},
|
|
1833
|
+
signin: {
|
|
1834
|
+
allowed: false,
|
|
1835
|
+
},
|
|
1836
|
+
sync: {
|
|
1837
|
+
requested: false,
|
|
1838
|
+
first_setup_complete: true,
|
|
1839
|
+
sync_all_os_types: false,
|
|
1840
|
+
},
|
|
1841
|
+
autofill: {
|
|
1842
|
+
enabled: false,
|
|
1843
|
+
},
|
|
1844
|
+
local_state: {
|
|
1845
|
+
browser: {
|
|
1846
|
+
has_seen_welcome_page: true,
|
|
1847
|
+
},
|
|
1848
|
+
},
|
|
1849
|
+
};
|
|
1850
|
+
|
|
1851
|
+
const prefsPath =
|
|
1852
|
+
this.os === "windows"
|
|
1853
|
+
? `${defaultProfileDir}\\Preferences`
|
|
1854
|
+
: `${defaultProfileDir}/Preferences`;
|
|
1855
|
+
|
|
1856
|
+
const prefsJson = JSON.stringify(chromePrefs, null, 2);
|
|
1857
|
+
const writePrefCmd =
|
|
1858
|
+
this.os === "windows"
|
|
1859
|
+
? // Use compact JSON and [System.IO.File]::WriteAllText to avoid Set-Content hanging issues
|
|
1860
|
+
`[System.IO.File]::WriteAllText("${prefsPath}", '${JSON.stringify(chromePrefs).replace(/'/g, "''")}')`
|
|
1861
|
+
: `cat > "${prefsPath}" << 'EOF'\n${prefsJson}\nEOF`;
|
|
1862
|
+
|
|
1863
|
+
await this.exec(shell, writePrefCmd, 60000, true);
|
|
1864
|
+
|
|
1865
|
+
// Build Chrome launch command
|
|
1866
|
+
const chromeArgs = [];
|
|
1867
|
+
if (maximized) chromeArgs.push("--start-maximized");
|
|
1868
|
+
if (guest) chromeArgs.push("--guest");
|
|
1869
|
+
chromeArgs.push(
|
|
1870
|
+
"--disable-fre",
|
|
1871
|
+
"--no-default-browser-check",
|
|
1872
|
+
"--no-first-run",
|
|
1873
|
+
"--no-experiments",
|
|
1874
|
+
"--disable-infobars",
|
|
1875
|
+
"--disable-features=StartupBrowserCreator",
|
|
1876
|
+
"--disable-features=ChromeWhatsNewUI",
|
|
1877
|
+
`--user-data-dir=${userDataDir}`,
|
|
1878
|
+
);
|
|
1879
|
+
|
|
1880
|
+
// Add remote debugging port for captcha solving support
|
|
1881
|
+
chromeArgs.push("--remote-debugging-port=9222");
|
|
1882
|
+
|
|
1883
|
+
// Add dashcam-chrome extension
|
|
1884
|
+
const dashcamChromePath = await this._getDashcamChromeExtensionPath();
|
|
1885
|
+
if (dashcamChromePath) {
|
|
1886
|
+
chromeArgs.push(`--load-extension=${dashcamChromePath}`);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// Launch Chrome
|
|
1890
|
+
|
|
1891
|
+
if (this.os === "windows") {
|
|
1892
|
+
const argsString = chromeArgs.map((arg) => `"${arg}"`).join(", ");
|
|
1893
|
+
await this.exec(
|
|
1894
|
+
shell,
|
|
1895
|
+
`Start-Process "C:\\ChromeForTesting\\chrome-win64\\chrome.exe" -ArgumentList ${argsString}, "${url}"`,
|
|
1896
|
+
30000,
|
|
1897
|
+
);
|
|
1898
|
+
} else {
|
|
1899
|
+
const argsString = chromeArgs.join(" ");
|
|
1900
|
+
await this.exec(
|
|
1901
|
+
shell,
|
|
1902
|
+
`chrome-for-testing ${argsString} "${url}" >/dev/null 2>&1 &`,
|
|
1903
|
+
30000,
|
|
1904
|
+
);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// Wait for Chrome debugger port and page to be ready
|
|
1908
|
+
await this._waitForChromeDebuggerReady();
|
|
1909
|
+
await this.focusApplication("Google Chrome");
|
|
1910
|
+
|
|
1911
|
+
// Add web log tracking with domain wildcard pattern, then start dashcam
|
|
1912
|
+
if (this.dashcamEnabled) {
|
|
1913
|
+
const domainPattern = this._getUrlDomainPattern(url);
|
|
1914
|
+
await this.dashcam.addWebLog(domainPattern, "Web Logs");
|
|
1915
|
+
|
|
1916
|
+
// Start dashcam recording after logs are configured
|
|
1917
|
+
if (!(await this.dashcam.isRecording())) {
|
|
1918
|
+
await this.dashcam.start();
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
},
|
|
1922
|
+
|
|
1923
|
+
/**
|
|
1924
|
+
* Launch Chrome browser with a custom extension loaded
|
|
1925
|
+
* @param {Object} options - Chrome extension launch options
|
|
1926
|
+
* @param {string} [options.extensionPath] - Local filesystem path to the unpacked extension directory
|
|
1927
|
+
* @param {string} [options.extensionId] - Chrome Web Store extension ID (e.g., "cjpalhdlnbpafiamejdnhcphjbkeiagm" for uBlock Origin)
|
|
1928
|
+
* @param {boolean} [options.maximized=true] - Start maximized
|
|
1929
|
+
* @returns {Promise<void>}
|
|
1930
|
+
* @example
|
|
1931
|
+
* // Load extension from local path
|
|
1932
|
+
* await testdriver.exec('sh', 'git clone https://github.com/user/extension.git /tmp/extension');
|
|
1933
|
+
* await testdriver.provision.chromeExtension({
|
|
1934
|
+
* extensionPath: '/tmp/extension'
|
|
1935
|
+
* });
|
|
1936
|
+
*
|
|
1937
|
+
* @example
|
|
1938
|
+
* // Load extension by Chrome Web Store ID
|
|
1939
|
+
* await testdriver.provision.chromeExtension({
|
|
1940
|
+
* extensionId: 'cjpalhdlnbpafiamejdnhcphjbkeiagm' // uBlock Origin
|
|
1941
|
+
* });
|
|
1942
|
+
*/
|
|
1943
|
+
chromeExtension: async (options = {}) => {
|
|
1944
|
+
const {
|
|
1945
|
+
extensionPath: providedExtensionPath,
|
|
1946
|
+
extensionId,
|
|
1947
|
+
maximized = true,
|
|
1948
|
+
} = options;
|
|
1949
|
+
|
|
1950
|
+
if (!providedExtensionPath && !extensionId) {
|
|
1951
|
+
throw new Error(
|
|
1952
|
+
"[provision.chromeExtension] Either extensionPath or extensionId is required",
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
let extensionPath = providedExtensionPath;
|
|
1957
|
+
const shell = this.os === "windows" ? "pwsh" : "sh";
|
|
1958
|
+
|
|
1959
|
+
// If extensionId is provided, download and extract the extension from Chrome Web Store
|
|
1960
|
+
if (extensionId && !extensionPath) {
|
|
1961
|
+
console.log(
|
|
1962
|
+
`[provision.chromeExtension] Downloading extension ${extensionId} from Chrome Web Store...`,
|
|
1963
|
+
);
|
|
1964
|
+
|
|
1965
|
+
const extensionDir =
|
|
1966
|
+
this.os === "windows"
|
|
1967
|
+
? `C:\\Users\\testdriver\\AppData\\Local\\TestDriver\\Extensions\\${extensionId}`
|
|
1968
|
+
: `/tmp/testdriver-extensions/${extensionId}`;
|
|
1969
|
+
|
|
1970
|
+
// Create extension directory
|
|
1971
|
+
const mkdirCmd =
|
|
1972
|
+
this.os === "windows"
|
|
1973
|
+
? `New-Item -ItemType Directory -Path "${extensionDir}" -Force | Out-Null`
|
|
1974
|
+
: `mkdir -p "${extensionDir}"`;
|
|
1975
|
+
await this.exec(shell, mkdirCmd, 60000, true);
|
|
1976
|
+
|
|
1977
|
+
// Download CRX from Chrome Web Store
|
|
1978
|
+
// The CRX download URL format for Chrome Web Store
|
|
1979
|
+
const crxUrl = `https://clients2.google.com/service/update2/crx?response=redirect&prodversion=131.0.0.0&acceptformat=crx2,crx3&x=id%3D${extensionId}%26installsource%3Dondemand%26uc`;
|
|
1980
|
+
const crxPath =
|
|
1981
|
+
this.os === "windows"
|
|
1982
|
+
? `${extensionDir}\\extension.crx`
|
|
1983
|
+
: `${extensionDir}/extension.crx`;
|
|
1984
|
+
|
|
1985
|
+
if (this.os === "windows") {
|
|
1986
|
+
await this.exec(
|
|
1987
|
+
"pwsh",
|
|
1988
|
+
`Invoke-WebRequest -Uri "${crxUrl}" -OutFile "${crxPath}"`,
|
|
1989
|
+
60000,
|
|
1990
|
+
true,
|
|
1991
|
+
);
|
|
1992
|
+
} else {
|
|
1993
|
+
await this.exec(
|
|
1994
|
+
"sh",
|
|
1995
|
+
`curl -L -o "${crxPath}" "${crxUrl}"`,
|
|
1996
|
+
60000,
|
|
1997
|
+
true,
|
|
1998
|
+
);
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// Extract the CRX file (CRX is a ZIP with a header)
|
|
2002
|
+
// Skip the CRX header and extract as ZIP
|
|
2003
|
+
if (this.os === "windows") {
|
|
2004
|
+
// PowerShell: Read CRX, skip header, extract ZIP
|
|
2005
|
+
await this.exec(
|
|
2006
|
+
"pwsh",
|
|
2007
|
+
`
|
|
2008
|
+
$crxBytes = [System.IO.File]::ReadAllBytes("${crxPath}")
|
|
2009
|
+
# CRX3 header: 4 bytes magic + 4 bytes version + 4 bytes header length + header
|
|
2010
|
+
$magic = [System.Text.Encoding]::ASCII.GetString($crxBytes[0..3])
|
|
2011
|
+
if ($magic -eq "Cr24") {
|
|
2012
|
+
$headerLen = [BitConverter]::ToUInt32($crxBytes, 8)
|
|
2013
|
+
$zipStart = 12 + $headerLen
|
|
2014
|
+
} else {
|
|
2015
|
+
# CRX2 format
|
|
2016
|
+
$zipStart = 16 + [BitConverter]::ToUInt32($crxBytes, 8) + [BitConverter]::ToUInt32($crxBytes, 12)
|
|
2017
|
+
}
|
|
2018
|
+
$zipBytes = $crxBytes[$zipStart..($crxBytes.Length - 1)]
|
|
2019
|
+
$zipPath = "${extensionDir}\\extension.zip"
|
|
2020
|
+
[System.IO.File]::WriteAllBytes($zipPath, $zipBytes)
|
|
2021
|
+
Expand-Archive -Path $zipPath -DestinationPath "${extensionDir}\\unpacked" -Force
|
|
2022
|
+
`,
|
|
2023
|
+
30000,
|
|
2024
|
+
true,
|
|
2025
|
+
);
|
|
2026
|
+
extensionPath = `${extensionDir}\\unpacked`;
|
|
2027
|
+
} else {
|
|
2028
|
+
// Linux: Use unzip with offset or python to extract
|
|
2029
|
+
await this.exec(
|
|
2030
|
+
"sh",
|
|
2031
|
+
`
|
|
2032
|
+
cd "${extensionDir}"
|
|
2033
|
+
# Extract CRX (skip header and unzip)
|
|
2034
|
+
# CRX3 format: magic(4) + version(4) + header_length(4) + header + zip
|
|
2035
|
+
python3 -c "
|
|
2036
|
+
import struct
|
|
2037
|
+
import zipfile
|
|
2038
|
+
import io
|
|
2039
|
+
import os
|
|
2040
|
+
|
|
2041
|
+
with open('extension.crx', 'rb') as f:
|
|
2042
|
+
data = f.read()
|
|
2043
|
+
|
|
2044
|
+
# Check magic number
|
|
2045
|
+
magic = data[:4]
|
|
2046
|
+
if magic == b'Cr24':
|
|
2047
|
+
# CRX3 format
|
|
2048
|
+
header_len = struct.unpack('<I', data[8:12])[0]
|
|
2049
|
+
zip_start = 12 + header_len
|
|
2050
|
+
else:
|
|
2051
|
+
# CRX2 format
|
|
2052
|
+
pub_key_len = struct.unpack('<I', data[8:12])[0]
|
|
2053
|
+
sig_len = struct.unpack('<I', data[12:16])[0]
|
|
2054
|
+
zip_start = 16 + pub_key_len + sig_len
|
|
2055
|
+
|
|
2056
|
+
zip_data = data[zip_start:]
|
|
2057
|
+
os.makedirs('unpacked', exist_ok=True)
|
|
2058
|
+
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
2059
|
+
zf.extractall('unpacked')
|
|
2060
|
+
"
|
|
2061
|
+
`,
|
|
2062
|
+
30000,
|
|
2063
|
+
true,
|
|
2064
|
+
);
|
|
2065
|
+
extensionPath = `${extensionDir}/unpacked`;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
console.log(
|
|
2069
|
+
`[provision.chromeExtension] Extension ${extensionId} extracted to ${extensionPath}`,
|
|
2070
|
+
);
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// Set up Chrome profile with preferences
|
|
2074
|
+
const userDataDir =
|
|
2075
|
+
this.os === "windows"
|
|
2076
|
+
? "C:\\Users\\testdriver\\AppData\\Local\\TestDriver\\Chrome"
|
|
2077
|
+
: "/tmp/testdriver-chrome-profile";
|
|
2078
|
+
|
|
2079
|
+
// Create user data directory and Default profile directory
|
|
2080
|
+
const defaultProfileDir =
|
|
2081
|
+
this.os === "windows"
|
|
2082
|
+
? `${userDataDir}\\Default`
|
|
2083
|
+
: `${userDataDir}/Default`;
|
|
2084
|
+
|
|
2085
|
+
const createDirCmd =
|
|
2086
|
+
this.os === "windows"
|
|
2087
|
+
? `New-Item -ItemType Directory -Path "${defaultProfileDir}" -Force | Out-Null`
|
|
2088
|
+
: `mkdir -p "${defaultProfileDir}"`;
|
|
2089
|
+
|
|
2090
|
+
await this.exec(shell, createDirCmd, 60000, true);
|
|
2091
|
+
|
|
2092
|
+
// Write Chrome preferences
|
|
2093
|
+
const chromePrefs = {
|
|
2094
|
+
credentials_enable_service: false,
|
|
2095
|
+
profile: {
|
|
2096
|
+
password_manager_enabled: false,
|
|
2097
|
+
default_content_setting_values: {},
|
|
2098
|
+
},
|
|
2099
|
+
signin: {
|
|
2100
|
+
allowed: false,
|
|
2101
|
+
},
|
|
2102
|
+
sync: {
|
|
2103
|
+
requested: false,
|
|
2104
|
+
first_setup_complete: true,
|
|
2105
|
+
sync_all_os_types: false,
|
|
2106
|
+
},
|
|
2107
|
+
autofill: {
|
|
2108
|
+
enabled: false,
|
|
2109
|
+
},
|
|
2110
|
+
local_state: {
|
|
2111
|
+
browser: {
|
|
2112
|
+
has_seen_welcome_page: true,
|
|
2113
|
+
},
|
|
2114
|
+
},
|
|
2115
|
+
};
|
|
2116
|
+
|
|
2117
|
+
const prefsPath =
|
|
2118
|
+
this.os === "windows"
|
|
2119
|
+
? `${defaultProfileDir}\\Preferences`
|
|
2120
|
+
: `${defaultProfileDir}/Preferences`;
|
|
2121
|
+
|
|
2122
|
+
const prefsJson = JSON.stringify(chromePrefs, null, 2);
|
|
2123
|
+
const writePrefCmd =
|
|
2124
|
+
this.os === "windows"
|
|
2125
|
+
? // Use compact JSON and [System.IO.File]::WriteAllText to avoid Set-Content hanging issues
|
|
2126
|
+
`[System.IO.File]::WriteAllText("${prefsPath}", '${JSON.stringify(chromePrefs).replace(/'/g, "''")}')`
|
|
2127
|
+
: `cat > "${prefsPath}" << 'EOF'\n${prefsJson}\nEOF`;
|
|
2128
|
+
|
|
2129
|
+
await this.exec(shell, writePrefCmd, 60000, true);
|
|
2130
|
+
|
|
2131
|
+
// Build Chrome launch command
|
|
2132
|
+
const chromeArgs = [];
|
|
2133
|
+
if (maximized) chromeArgs.push("--start-maximized");
|
|
2134
|
+
chromeArgs.push(
|
|
2135
|
+
"--disable-fre",
|
|
2136
|
+
"--no-default-browser-check",
|
|
2137
|
+
"--no-first-run",
|
|
2138
|
+
"--no-experiments",
|
|
2139
|
+
"--disable-infobars",
|
|
2140
|
+
"--disable-features=ChromeLabs",
|
|
2141
|
+
`--user-data-dir=${userDataDir}`,
|
|
2142
|
+
);
|
|
2143
|
+
|
|
2144
|
+
// Add remote debugging port for captcha solving support
|
|
2145
|
+
chromeArgs.push("--remote-debugging-port=9222");
|
|
2146
|
+
|
|
2147
|
+
// Add user extension and dashcam-chrome extension
|
|
2148
|
+
const dashcamChromePath = await this._getDashcamChromeExtensionPath();
|
|
2149
|
+
if (dashcamChromePath) {
|
|
2150
|
+
// Load both user extension and dashcam-chrome for web log capture
|
|
2151
|
+
chromeArgs.push(
|
|
2152
|
+
`--load-extension=${extensionPath},${dashcamChromePath}`,
|
|
2153
|
+
);
|
|
2154
|
+
} else {
|
|
2155
|
+
// If dashcam-chrome unavailable, just load user extension
|
|
2156
|
+
chromeArgs.push(`--load-extension=${extensionPath}`);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
// Launch Chrome (opens to New Tab by default)
|
|
2160
|
+
if (this.os === "windows") {
|
|
2161
|
+
const argsString = chromeArgs.map((arg) => `"${arg}"`).join(", ");
|
|
2162
|
+
await this.exec(
|
|
2163
|
+
shell,
|
|
2164
|
+
`Start-Process "C:\\ChromeForTesting\\chrome-win64\\chrome.exe" -ArgumentList ${argsString}`,
|
|
2165
|
+
30000,
|
|
2166
|
+
);
|
|
2167
|
+
} else {
|
|
2168
|
+
const argsString = chromeArgs.join(" ");
|
|
2169
|
+
await this.exec(
|
|
2170
|
+
shell,
|
|
2171
|
+
`chrome-for-testing ${argsString} >/dev/null 2>&1 &`,
|
|
2172
|
+
30000,
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
// Wait for Chrome debugger port and page to be ready
|
|
2177
|
+
await this._waitForChromeDebuggerReady();
|
|
2178
|
+
await this.focusApplication("Google Chrome");
|
|
2179
|
+
|
|
2180
|
+
// Start dashcam recording
|
|
2181
|
+
if (this.dashcamEnabled && !(await this.dashcam.isRecording())) {
|
|
2182
|
+
await this.dashcam.start();
|
|
2183
|
+
}
|
|
2184
|
+
},
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Launch VS Code
|
|
2188
|
+
* @param {Object} options - VS Code launch options
|
|
2189
|
+
* @param {string} [options.workspace] - Workspace/folder to open
|
|
2190
|
+
* @param {string[]} [options.extensions=[]] - Extensions to install
|
|
2191
|
+
* @returns {Promise<void>}
|
|
2192
|
+
*/
|
|
2193
|
+
vscode: async (options = {}) => {
|
|
2194
|
+
const { workspace = null, extensions = [] } = options;
|
|
2195
|
+
|
|
2196
|
+
const shell = this.os === "windows" ? "pwsh" : "sh";
|
|
2197
|
+
|
|
2198
|
+
// Install extensions if provided
|
|
2199
|
+
for (const extension of extensions) {
|
|
2200
|
+
console.log(`[provision.vscode] Installing extension: ${extension}`);
|
|
2201
|
+
await this.exec(
|
|
2202
|
+
shell,
|
|
2203
|
+
`code --install-extension ${extension} --force`,
|
|
2204
|
+
120000,
|
|
2205
|
+
true,
|
|
2206
|
+
);
|
|
2207
|
+
console.log(
|
|
2208
|
+
`[provision.vscode] ✅ Extension installed: ${extension}`,
|
|
2209
|
+
);
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
// Launch VS Code
|
|
2213
|
+
const workspaceArg = workspace ? `"${workspace}"` : "";
|
|
2214
|
+
|
|
2215
|
+
if (this.os === "windows") {
|
|
2216
|
+
await this.exec(
|
|
2217
|
+
shell,
|
|
2218
|
+
`Start-Process code -ArgumentList ${workspaceArg}`,
|
|
2219
|
+
30000,
|
|
2220
|
+
);
|
|
2221
|
+
} else {
|
|
2222
|
+
await this.exec(
|
|
2223
|
+
shell,
|
|
2224
|
+
`code ${workspaceArg} >/dev/null 2>&1 &`,
|
|
2225
|
+
30000,
|
|
2226
|
+
);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
// Wait for VS Code to start up
|
|
2230
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
2231
|
+
|
|
2232
|
+
// Wait for VS Code to be ready
|
|
2233
|
+
await this.focusApplication("Visual Studio Code");
|
|
2234
|
+
|
|
2235
|
+
// Start dashcam recording
|
|
2236
|
+
if (this.dashcamEnabled && !(await this.dashcam.isRecording())) {
|
|
2237
|
+
await this.dashcam.start();
|
|
2238
|
+
}
|
|
2239
|
+
},
|
|
2240
|
+
|
|
2241
|
+
/**
|
|
2242
|
+
* Download and install an application
|
|
2243
|
+
* @param {Object} options - Installer options
|
|
2244
|
+
* @param {string} options.url - URL to download the installer from
|
|
2245
|
+
* @param {string} [options.filename] - Filename to save as (auto-detected from URL if not provided)
|
|
2246
|
+
* @param {string} [options.appName] - Application name to focus after install
|
|
2247
|
+
* @param {boolean} [options.launch=true] - Whether to launch the app after installation
|
|
2248
|
+
* @returns {Promise<string>} Path to the downloaded file
|
|
2249
|
+
* @example
|
|
2250
|
+
* // Install a .deb package on Linux (auto-detected)
|
|
2251
|
+
* await testdriver.provision.installer({
|
|
2252
|
+
* url: 'https://example.com/app.deb',
|
|
2253
|
+
* appName: 'MyApp'
|
|
2254
|
+
* });
|
|
2255
|
+
*
|
|
2256
|
+
* @example
|
|
2257
|
+
* // Download and run custom commands
|
|
2258
|
+
* const filePath = await testdriver.provision.installer({
|
|
2259
|
+
* url: 'https://example.com/app.AppImage',
|
|
2260
|
+
* launch: false
|
|
2261
|
+
* });
|
|
2262
|
+
* await testdriver.exec('sh', `chmod +x "${filePath}" && "${filePath}" &`, 10000);
|
|
2263
|
+
*/
|
|
2264
|
+
installer: async (options = {}) => {
|
|
2265
|
+
const { url, filename, appName, launch = true } = options;
|
|
2266
|
+
|
|
2267
|
+
if (!url) {
|
|
2268
|
+
throw new Error("[provision.installer] url is required");
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
const shell = this.os === "windows" ? "pwsh" : "sh";
|
|
2272
|
+
|
|
2273
|
+
// Determine download directory
|
|
2274
|
+
const downloadDir =
|
|
2275
|
+
this.os === "windows" ? "C:\\Users\\testdriver\\Downloads" : "/tmp";
|
|
2276
|
+
|
|
2277
|
+
console.log(`[provision.installer] Downloading ${url}...`);
|
|
2278
|
+
|
|
2279
|
+
let actualFilePath;
|
|
2280
|
+
|
|
2281
|
+
// Download the file and get the actual filename (handles redirects)
|
|
2282
|
+
if (this.os === "windows") {
|
|
2283
|
+
// Simple approach: download first, then get the actual filename from the response
|
|
2284
|
+
const tempFile = `${downloadDir}\\installer_temp_${Date.now()}`;
|
|
2285
|
+
|
|
2286
|
+
const downloadScript = `
|
|
2287
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
2288
|
+
$response = Invoke-WebRequest -Uri "${url}" -OutFile "${tempFile}" -PassThru -UseBasicParsing
|
|
2289
|
+
|
|
2290
|
+
# Try to get filename from Content-Disposition header
|
|
2291
|
+
$filename = $null
|
|
2292
|
+
if ($response.Headers['Content-Disposition']) {
|
|
2293
|
+
if ($response.Headers['Content-Disposition'] -match 'filename=\\"?([^\\"]+)\\"?') {
|
|
2294
|
+
$filename = $matches[1]
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
# If no filename from header, try to get from URL or use default
|
|
2299
|
+
if (-not $filename) {
|
|
2300
|
+
$uri = [System.Uri]"${url}"
|
|
2301
|
+
$filename = [System.IO.Path]::GetFileName($uri.LocalPath)
|
|
2302
|
+
if (-not $filename -or $filename -eq '') {
|
|
2303
|
+
$filename = "installer"
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
# Move temp file to final location with proper filename
|
|
2308
|
+
$finalPath = Join-Path "${downloadDir}" $filename
|
|
2309
|
+
Move-Item -Path "${tempFile}" -Destination $finalPath -Force
|
|
2310
|
+
Write-Output $finalPath
|
|
2311
|
+
`;
|
|
2312
|
+
|
|
2313
|
+
const result = await this.exec(shell, downloadScript, 300000, true);
|
|
2314
|
+
actualFilePath = result ? result.trim() : null;
|
|
2315
|
+
|
|
2316
|
+
if (!actualFilePath) {
|
|
2317
|
+
throw new Error("[provision.installer] Failed to download file");
|
|
2318
|
+
}
|
|
2319
|
+
} else {
|
|
2320
|
+
// Use curl with options to get the final filename
|
|
2321
|
+
const tempMarker = `installer_${Date.now()}`;
|
|
2322
|
+
const downloadScript = `
|
|
2323
|
+
cd "${downloadDir}"
|
|
2324
|
+
curl -L -J -O -w "%{filename_effective}" "${url}" 2>/dev/null || echo "${tempMarker}"
|
|
2325
|
+
`;
|
|
2326
|
+
|
|
2327
|
+
const result = await this.exec(shell, downloadScript, 300000, true);
|
|
2328
|
+
const downloadedFile = result ? result.trim() : null;
|
|
2329
|
+
|
|
2330
|
+
if (downloadedFile && downloadedFile !== tempMarker) {
|
|
2331
|
+
actualFilePath = `${downloadDir}/${downloadedFile}`;
|
|
2332
|
+
} else {
|
|
2333
|
+
// Fallback: use curl without -J and specify output file
|
|
2334
|
+
const fallbackFilename = filename || "installer";
|
|
2335
|
+
actualFilePath = `${downloadDir}/${fallbackFilename}`;
|
|
2336
|
+
await this.exec(
|
|
2337
|
+
shell,
|
|
2338
|
+
`curl -L -o "${actualFilePath}" "${url}"`,
|
|
2339
|
+
300000,
|
|
2340
|
+
true,
|
|
2341
|
+
);
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
console.log(`[provision.installer] ✅ Downloaded to ${actualFilePath}`);
|
|
2346
|
+
|
|
2347
|
+
// Auto-detect install command based on file extension (use actualFilePath for extension detection)
|
|
2348
|
+
const actualFilename = actualFilePath.split(/[/\\]/).pop() || "";
|
|
2349
|
+
const ext = actualFilename.split(".").pop()?.toLowerCase();
|
|
2350
|
+
let installCommand = null;
|
|
2351
|
+
|
|
2352
|
+
if (this.os === "windows") {
|
|
2353
|
+
if (ext === "msi") {
|
|
2354
|
+
installCommand = `Start-Process msiexec -ArgumentList '/i', '"${actualFilePath}"', '/quiet', '/norestart' -Wait`;
|
|
2355
|
+
} else if (ext === "exe") {
|
|
2356
|
+
installCommand = `Start-Process "${actualFilePath}" -ArgumentList '/S' -Wait`;
|
|
2357
|
+
}
|
|
2358
|
+
} else if (this.os === "linux") {
|
|
2359
|
+
if (ext === "deb") {
|
|
2360
|
+
installCommand = `sudo dpkg -i "${actualFilePath}" && sudo apt-get install -f -y`;
|
|
2361
|
+
} else if (ext === "rpm") {
|
|
2362
|
+
installCommand = `sudo rpm -i "${actualFilePath}"`;
|
|
2363
|
+
} else if (ext === "appimage") {
|
|
2364
|
+
installCommand = `chmod +x "${actualFilePath}"`;
|
|
2365
|
+
} else if (ext === "sh") {
|
|
2366
|
+
installCommand = `chmod +x "${actualFilePath}" && "${actualFilePath}"`;
|
|
2367
|
+
}
|
|
2368
|
+
} else if (this.os === "darwin") {
|
|
2369
|
+
if (ext === "dmg") {
|
|
2370
|
+
installCommand = `hdiutil attach "${actualFilePath}" -mountpoint /Volumes/installer && cp -R /Volumes/installer/*.app /Applications/ && hdiutil detach /Volumes/installer`;
|
|
2371
|
+
} else if (ext === "pkg") {
|
|
2372
|
+
installCommand = `sudo installer -pkg "${actualFilePath}" -target /`;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
if (installCommand) {
|
|
2377
|
+
console.log(`[provision.installer] Installing...`);
|
|
2378
|
+
await this.exec(shell, installCommand, 300000, true);
|
|
2379
|
+
console.log(`[provision.installer] ✅ Installation complete`);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// Launch and focus the app if appName is provided and launch is true
|
|
2383
|
+
if (appName && launch) {
|
|
2384
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
2385
|
+
await this.focusApplication(appName);
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
// Start dashcam recording
|
|
2389
|
+
if (this.dashcamEnabled && !(await this.dashcam.isRecording())) {
|
|
2390
|
+
await this.dashcam.start();
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
return actualFilePath;
|
|
2394
|
+
},
|
|
2395
|
+
|
|
2396
|
+
/**
|
|
2397
|
+
* Launch Electron app
|
|
2398
|
+
* @param {Object} options - Electron launch options
|
|
2399
|
+
* @param {string} options.appPath - Path to Electron app (required)
|
|
2400
|
+
* @param {string[]} [options.args=[]] - Additional electron args
|
|
2401
|
+
* @returns {Promise<void>}
|
|
2402
|
+
*/
|
|
2403
|
+
electron: async (options = {}) => {
|
|
2404
|
+
const { appPath, args = [] } = options;
|
|
2405
|
+
|
|
2406
|
+
if (!appPath) {
|
|
2407
|
+
throw new Error("provision.electron requires appPath option");
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
const shell = this.os === "windows" ? "pwsh" : "sh";
|
|
2411
|
+
|
|
2412
|
+
const argsString = args.join(" ");
|
|
2413
|
+
|
|
2414
|
+
if (this.os === "windows") {
|
|
2415
|
+
await this.exec(
|
|
2416
|
+
shell,
|
|
2417
|
+
`Start-Process electron -ArgumentList "${appPath}", ${argsString}`,
|
|
2418
|
+
30000,
|
|
2419
|
+
);
|
|
2420
|
+
} else {
|
|
2421
|
+
await this.exec(
|
|
2422
|
+
shell,
|
|
2423
|
+
`electron "${appPath}" ${argsString} >/dev/null 2>&1 &`,
|
|
2424
|
+
30000,
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
await this.focusApplication("Electron");
|
|
2429
|
+
|
|
2430
|
+
// Start dashcam recording
|
|
2431
|
+
if (this.dashcamEnabled && !(await this.dashcam.isRecording())) {
|
|
2432
|
+
await this.dashcam.start();
|
|
2433
|
+
}
|
|
2434
|
+
},
|
|
2435
|
+
|
|
2436
|
+
/**
|
|
2437
|
+
* Initialize Dashcam recording with logging
|
|
2438
|
+
* @param {Object} options - Dashcam options
|
|
2439
|
+
* @param {string} [options.logPath] - Path to log file (auto-generated if not provided)
|
|
2440
|
+
* @param {string} [options.logName='TestDriver Log'] - Display name for the log
|
|
2441
|
+
* @param {boolean} [options.webLogs=true] - Enable web log tracking
|
|
2442
|
+
* @param {string} [options.title] - Custom title for the recording
|
|
2443
|
+
* @returns {Promise<void>}
|
|
2444
|
+
*/
|
|
2445
|
+
dashcam: async (options = {}) => {
|
|
2446
|
+
const {
|
|
2447
|
+
logPath,
|
|
2448
|
+
logName = "TestDriver Log",
|
|
2449
|
+
webLogs = true,
|
|
2450
|
+
title,
|
|
2451
|
+
} = options;
|
|
2452
|
+
|
|
2453
|
+
// Ensure dashcam is enabled
|
|
2454
|
+
if (!this.dashcamEnabled) {
|
|
2455
|
+
console.warn(
|
|
2456
|
+
"[provision.dashcam] Dashcam is not enabled. Skipping.",
|
|
2457
|
+
);
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
// Set custom title if provided
|
|
2462
|
+
if (title) {
|
|
2463
|
+
this.dashcam.setTitle(title);
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
// Add file log tracking
|
|
2467
|
+
const actualLogPath =
|
|
2468
|
+
logPath ||
|
|
2469
|
+
(this.os === "windows"
|
|
2470
|
+
? "C:\\Users\\testdriver\\testdriver.log"
|
|
2471
|
+
: "/tmp/testdriver.log");
|
|
2472
|
+
|
|
2473
|
+
await this.dashcam.addFileLog(actualLogPath, logName);
|
|
2474
|
+
|
|
2475
|
+
// Add web log tracking if enabled
|
|
2476
|
+
// Use domain pattern from provisioned Chrome URL if available
|
|
2477
|
+
if (webLogs) {
|
|
2478
|
+
const pattern = this._provisionedChromeUrl
|
|
2479
|
+
? this._getUrlDomainPattern(this._provisionedChromeUrl)
|
|
2480
|
+
: "**";
|
|
2481
|
+
await this.dashcam.addWebLog(pattern, "Web Logs");
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// Start recording if not already recording
|
|
2485
|
+
if (!(await this.dashcam.isRecording())) {
|
|
2486
|
+
await this.dashcam.start();
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
console.log("[provision.dashcam] ✅ Dashcam recording started");
|
|
2490
|
+
},
|
|
2491
|
+
};
|
|
2492
|
+
|
|
2493
|
+
// Wrap all provision methods with reconnect check using Proxy
|
|
2494
|
+
return new Proxy(provisionMethods, {
|
|
2495
|
+
get(target, prop) {
|
|
2496
|
+
const method = target[prop];
|
|
2497
|
+
if (typeof method === "function") {
|
|
2498
|
+
return async (...args) => {
|
|
2499
|
+
// Skip provisioning if reconnecting to existing sandbox
|
|
2500
|
+
if (self.reconnect) {
|
|
2501
|
+
console.log(
|
|
2502
|
+
`[provision.${prop}] Skipping provisioning (reconnect mode)`,
|
|
2503
|
+
);
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
return method(...args);
|
|
2507
|
+
};
|
|
2508
|
+
}
|
|
2509
|
+
return method;
|
|
2510
|
+
},
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
/**
|
|
2515
|
+
* Solve a captcha on the current page using 2captcha service
|
|
2516
|
+
* Requires Chrome to be launched with remote debugging (--remote-debugging-port=9222)
|
|
2517
|
+
*
|
|
2518
|
+
* @param {Object} options - Captcha solving options
|
|
2519
|
+
* @param {string} options.apiKey - 2captcha API key (required)
|
|
2520
|
+
* @param {string} [options.sitekey] - Captcha sitekey (auto-detected if not provided)
|
|
2521
|
+
* @param {string} [options.type='recaptcha_v3'] - Captcha type: 'recaptcha_v2', 'recaptcha_v3', 'hcaptcha', 'turnstile'
|
|
2522
|
+
* @param {string} [options.action='verify'] - Action parameter for reCAPTCHA v3
|
|
2523
|
+
* @param {boolean} [options.autoSubmit=true] - Automatically click submit button after solving
|
|
2524
|
+
* @param {number} [options.pollInterval=5000] - Polling interval in ms for 2captcha
|
|
2525
|
+
* @param {number} [options.timeout=120000] - Timeout in ms for solving
|
|
2526
|
+
* @returns {Promise<{success: boolean, message: string, token?: string}>}
|
|
2527
|
+
*
|
|
2528
|
+
* @example
|
|
2529
|
+
* // Auto-detect and solve captcha
|
|
2530
|
+
* await testdriver.captcha({
|
|
2531
|
+
* apiKey: 'your-2captcha-api-key'
|
|
2532
|
+
* });
|
|
2533
|
+
*
|
|
2534
|
+
* @example
|
|
2535
|
+
* // Solve with known sitekey
|
|
2536
|
+
* await testdriver.captcha({
|
|
2537
|
+
* apiKey: 'your-2captcha-api-key',
|
|
2538
|
+
* sitekey: '6LfB5_IbAAAAAMCtsjEHEHKqcB9iQocwwxTiihJu',
|
|
2539
|
+
* action: 'demo_action'
|
|
2540
|
+
* });
|
|
2541
|
+
*/
|
|
2542
|
+
async captcha(options = {}) {
|
|
2543
|
+
const {
|
|
2544
|
+
apiKey,
|
|
2545
|
+
sitekey,
|
|
2546
|
+
type = "recaptcha_v3",
|
|
2547
|
+
action = "verify",
|
|
2548
|
+
autoSubmit = true,
|
|
2549
|
+
pollInterval = 5000,
|
|
2550
|
+
timeout = 120000,
|
|
2551
|
+
} = options;
|
|
2552
|
+
|
|
2553
|
+
if (!apiKey) {
|
|
2554
|
+
throw new Error(
|
|
2555
|
+
"[captcha] apiKey is required. Get your API key at https://2captcha.com",
|
|
2556
|
+
);
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
const shell = this.os === "windows" ? "pwsh" : "sh";
|
|
2560
|
+
const isWindows = this.os === "windows";
|
|
2561
|
+
|
|
2562
|
+
// Paths for config and solver script
|
|
2563
|
+
const configPath = isWindows
|
|
2564
|
+
? "C:\\Users\\testdriver\\AppData\\Local\\Temp\\td-captcha-config.json"
|
|
2565
|
+
: "/tmp/td-captcha-config.json";
|
|
2566
|
+
const solverPath = isWindows
|
|
2567
|
+
? "C:\\Users\\testdriver\\AppData\\Local\\Temp\\td-captcha-solver.js"
|
|
2568
|
+
: "/tmp/td-captcha-solver.js";
|
|
2569
|
+
|
|
2570
|
+
// Ensure chrome-remote-interface is installed
|
|
2571
|
+
if (isWindows) {
|
|
2572
|
+
await this.exec(
|
|
2573
|
+
shell,
|
|
2574
|
+
"npm install -g chrome-remote-interface 2>$null; $true",
|
|
2575
|
+
60000,
|
|
2576
|
+
true,
|
|
2577
|
+
);
|
|
2578
|
+
} else {
|
|
2579
|
+
await this.exec(
|
|
2580
|
+
shell,
|
|
2581
|
+
"sudo npm install -g chrome-remote-interface 2>/dev/null || npm install -g chrome-remote-interface",
|
|
2582
|
+
60000,
|
|
2583
|
+
true,
|
|
2584
|
+
);
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
// Build config JSON for the solver
|
|
2588
|
+
const config = JSON.stringify({
|
|
2589
|
+
apiKey,
|
|
2590
|
+
sitekey: sitekey || null,
|
|
2591
|
+
type,
|
|
2592
|
+
action,
|
|
2593
|
+
autoSubmit,
|
|
2594
|
+
pollInterval,
|
|
2595
|
+
timeout,
|
|
2596
|
+
});
|
|
2597
|
+
|
|
2598
|
+
// Write config file
|
|
2599
|
+
if (isWindows) {
|
|
2600
|
+
// Use PowerShell's Set-Content with escaped JSON
|
|
2601
|
+
const escapedConfig = config.replace(/'/g, "''");
|
|
2602
|
+
await this.exec(
|
|
2603
|
+
shell,
|
|
2604
|
+
`[System.IO.File]::WriteAllText('${configPath}', '${escapedConfig}')`,
|
|
2605
|
+
5000,
|
|
2606
|
+
true,
|
|
2607
|
+
);
|
|
2608
|
+
} else {
|
|
2609
|
+
// Use heredoc for Linux
|
|
2610
|
+
await this.exec(
|
|
2611
|
+
shell,
|
|
2612
|
+
`cat > ${configPath} << 'CONFIGEOF'
|
|
2613
|
+
${config}
|
|
2614
|
+
CONFIGEOF`,
|
|
2615
|
+
5000,
|
|
2616
|
+
true,
|
|
2617
|
+
);
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
// Load the solver script from file (avoids escaping issues with string concatenation)
|
|
2621
|
+
const solverScriptPath = path.join(
|
|
2622
|
+
__dirname,
|
|
2623
|
+
"lib",
|
|
2624
|
+
"captcha",
|
|
2625
|
+
"solver.js",
|
|
2626
|
+
);
|
|
2627
|
+
const solverScript = fs.readFileSync(solverScriptPath, "utf8");
|
|
2628
|
+
|
|
2629
|
+
// Write the solver script to sandbox
|
|
2630
|
+
if (isWindows) {
|
|
2631
|
+
// For Windows, write the script using base64 encoding to avoid escaping issues
|
|
2632
|
+
const base64Script = Buffer.from(solverScript).toString("base64");
|
|
2633
|
+
await this.exec(
|
|
2634
|
+
shell,
|
|
2635
|
+
`[System.IO.File]::WriteAllBytes('${solverPath}', [System.Convert]::FromBase64String('${base64Script}'))`,
|
|
2636
|
+
10000,
|
|
2637
|
+
true,
|
|
2638
|
+
);
|
|
2639
|
+
} else {
|
|
2640
|
+
// Use heredoc for Linux
|
|
2641
|
+
await this.exec(
|
|
2642
|
+
shell,
|
|
2643
|
+
`cat > ${solverPath} << 'CAPTCHA_SOLVER_EOF'
|
|
2644
|
+
${solverScript}
|
|
2645
|
+
CAPTCHA_SOLVER_EOF`,
|
|
2646
|
+
10000,
|
|
2647
|
+
true,
|
|
2648
|
+
);
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
// Run the solver (capture output even on failure)
|
|
2652
|
+
let result;
|
|
2653
|
+
try {
|
|
2654
|
+
if (isWindows) {
|
|
2655
|
+
// Set environment variable and run node on Windows
|
|
2656
|
+
result = await this.exec(
|
|
2657
|
+
shell,
|
|
2658
|
+
`$env:NODE_PATH = (npm root -g).Trim(); $env:TD_CAPTCHA_CONFIG_PATH='${configPath}'; node '${solverPath}' 2>&1 | Out-String; Write-Output "EXIT_CODE:$LASTEXITCODE"`,
|
|
2659
|
+
timeout + 30000,
|
|
2660
|
+
);
|
|
2661
|
+
} else {
|
|
2662
|
+
result = await this.exec(
|
|
2663
|
+
shell,
|
|
2664
|
+
`NODE_PATH=/usr/lib/node_modules node ${solverPath} 2>&1; echo "EXIT_CODE:$?"`,
|
|
2665
|
+
timeout + 30000,
|
|
2666
|
+
);
|
|
2667
|
+
}
|
|
2668
|
+
} catch (err) {
|
|
2669
|
+
// If exec throws, try to get output from the error
|
|
2670
|
+
result = err.message || err.toString();
|
|
2671
|
+
if (err.responseData && err.responseData.stdout) {
|
|
2672
|
+
result = err.responseData.stdout;
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
const tokenMatch = result.match(/TOKEN:\s*(\S+)/);
|
|
2677
|
+
const success = result.includes('"success":true');
|
|
2678
|
+
const hasError = result.includes("ERROR:");
|
|
2679
|
+
|
|
2680
|
+
if (hasError && !success) {
|
|
2681
|
+
const errorMatch = result.match(/ERROR:\s*(.+)/);
|
|
2682
|
+
throw new Error(
|
|
2683
|
+
`[captcha] ${errorMatch ? errorMatch[1] : "Unknown error"}\nOutput: ${result}`,
|
|
2684
|
+
);
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
return {
|
|
2688
|
+
success,
|
|
2689
|
+
message: success
|
|
2690
|
+
? "Captcha solved successfully"
|
|
2691
|
+
: "Captcha solving failed",
|
|
2692
|
+
token: tokenMatch ? tokenMatch[1] : null,
|
|
2693
|
+
output: result,
|
|
2694
|
+
};
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
/**
|
|
2698
|
+
* Authenticate with TestDriver API
|
|
2699
|
+
* @returns {Promise<string>} Authentication token
|
|
2700
|
+
*/
|
|
2701
|
+
async auth() {
|
|
2702
|
+
if (this.authenticated) {
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
const token = await this.apiClient.auth();
|
|
2707
|
+
this.authenticated = true;
|
|
2708
|
+
return token;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
/**
|
|
2712
|
+
* Connect to a sandbox environment
|
|
2713
|
+
* @param {Object} options - Connection options
|
|
2714
|
+
* @param {string} options.sandboxId - Existing sandbox ID to reconnect to
|
|
2715
|
+
* @param {boolean} options.newSandbox - Force creation of a new sandbox
|
|
2716
|
+
* @param {string} options.ip - Direct IP address to connect to
|
|
2717
|
+
* @param {string} options.sandboxAmi - AMI to use for the sandbox
|
|
2718
|
+
* @param {string} options.sandboxInstance - Instance type for the sandbox
|
|
2719
|
+
* @param {string} options.os - Operating system for the sandbox (windows or linux)
|
|
2720
|
+
* @param {boolean} options.reuseConnection - Reuse recent connection if available (default: true)
|
|
2721
|
+
* @returns {Promise<Object>} Sandbox instance details
|
|
2722
|
+
*/
|
|
2723
|
+
async connect(connectOptions = {}) {
|
|
2724
|
+
if (this.connected) {
|
|
2725
|
+
throw new Error(
|
|
2726
|
+
"Already connected. Create a new TestDriver instance to connect again.",
|
|
2727
|
+
);
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
// Clean up screenshots folder for this test file before running.
|
|
2731
|
+
// Only clean once per directory per process to avoid concurrent tests
|
|
2732
|
+
// in the same file (--sequence.concurrent) from nuking each other's screenshots.
|
|
2733
|
+
if (this.testFile) {
|
|
2734
|
+
const testFileName = path.basename(
|
|
2735
|
+
this.testFile,
|
|
2736
|
+
path.extname(this.testFile),
|
|
2737
|
+
);
|
|
2738
|
+
const screenshotsDir = path.join(
|
|
2739
|
+
process.cwd(),
|
|
2740
|
+
".testdriver",
|
|
2741
|
+
"screenshots",
|
|
2742
|
+
testFileName,
|
|
2743
|
+
);
|
|
2744
|
+
if (!_cleanedScreenshotDirs.has(screenshotsDir)) {
|
|
2745
|
+
_cleanedScreenshotDirs.add(screenshotsDir);
|
|
2746
|
+
if (fs.existsSync(screenshotsDir)) {
|
|
2747
|
+
fs.rmSync(screenshotsDir, { recursive: true, force: true });
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
// Log environment info immediately so it's visible even if auth fails
|
|
2753
|
+
this._logEnvironmentInfo();
|
|
2754
|
+
|
|
2755
|
+
// Authenticate first if not already authenticated
|
|
2756
|
+
if (!this.authenticated) {
|
|
2757
|
+
await this.auth();
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// Initialize debugger server before connecting to sandbox
|
|
2761
|
+
// This ensures the debuggerUrl is available for renderSandbox
|
|
2762
|
+
await this._initializeDebugger();
|
|
2763
|
+
|
|
2764
|
+
// Map SDK connect options to agent buildEnv options
|
|
2765
|
+
// Use connectOptions.newSandbox if provided, otherwise fall back to this.newSandbox
|
|
2766
|
+
// Use connectOptions.headless if provided, otherwise fall back to this.headless
|
|
2767
|
+
const buildEnvOptions = {
|
|
2768
|
+
headless:
|
|
2769
|
+
connectOptions.headless !== undefined
|
|
2770
|
+
? connectOptions.headless
|
|
2771
|
+
: this.headless,
|
|
2772
|
+
new:
|
|
2773
|
+
connectOptions.newSandbox !== undefined
|
|
2774
|
+
? connectOptions.newSandbox
|
|
2775
|
+
: this.newSandbox,
|
|
2776
|
+
};
|
|
2777
|
+
|
|
2778
|
+
// Set agent properties for buildEnv to use
|
|
2779
|
+
if (connectOptions.sandboxId) {
|
|
2780
|
+
this.agent.sandboxId = connectOptions.sandboxId;
|
|
2781
|
+
}
|
|
2782
|
+
// Use IP from connectOptions if provided, otherwise fall back to constructor IP
|
|
2783
|
+
if (connectOptions.ip !== undefined) {
|
|
2784
|
+
this.agent.ip = connectOptions.ip;
|
|
2785
|
+
} else if (this.ip) {
|
|
2786
|
+
this.agent.ip = this.ip;
|
|
2787
|
+
}
|
|
2788
|
+
// Use instanceId from connectOptions if provided, otherwise fall back to constructor value
|
|
2789
|
+
// This allows the API to provision Ably credentials via SSM for direct connections
|
|
2790
|
+
if (connectOptions.instanceId !== undefined) {
|
|
2791
|
+
this.agent.instanceId = connectOptions.instanceId;
|
|
2792
|
+
} else if (this.instanceId) {
|
|
2793
|
+
this.agent.instanceId = this.instanceId;
|
|
2794
|
+
}
|
|
2795
|
+
// Use sandboxAmi from connectOptions if provided, otherwise fall back to constructor value
|
|
2796
|
+
if (connectOptions.sandboxAmi !== undefined) {
|
|
2797
|
+
this.agent.sandboxAmi = connectOptions.sandboxAmi;
|
|
2798
|
+
} else if (this.sandboxAmi) {
|
|
2799
|
+
this.agent.sandboxAmi = this.sandboxAmi;
|
|
2800
|
+
}
|
|
2801
|
+
// Use sandboxInstance from connectOptions if provided, otherwise fall back to constructor value
|
|
2802
|
+
if (connectOptions.sandboxInstance !== undefined) {
|
|
2803
|
+
this.agent.sandboxInstance = connectOptions.sandboxInstance;
|
|
2804
|
+
} else if (this.sandboxInstance) {
|
|
2805
|
+
this.agent.sandboxInstance = this.sandboxInstance;
|
|
2806
|
+
}
|
|
2807
|
+
// Use os from connectOptions if provided, otherwise fall back to this.os
|
|
2808
|
+
if (connectOptions.os !== undefined) {
|
|
2809
|
+
this.agent.sandboxOs = connectOptions.os;
|
|
2810
|
+
this.os = connectOptions.os; // Update this.os to match
|
|
2811
|
+
} else {
|
|
2812
|
+
this.agent.sandboxOs = this.os;
|
|
2813
|
+
}
|
|
2814
|
+
// Use keepAlive from connectOptions if provided
|
|
2815
|
+
if (connectOptions.keepAlive !== undefined) {
|
|
2816
|
+
this.agent.keepAlive = connectOptions.keepAlive;
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
// Set redrawThreshold on agent's cliArgs.options
|
|
2820
|
+
this.agent.cliArgs.options.redrawThreshold = this.redrawThreshold;
|
|
2821
|
+
|
|
2822
|
+
// Pass test file name to agent for debugger display
|
|
2823
|
+
if (this.testFile) {
|
|
2824
|
+
this.agent.testFile = this.testFile;
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
// Use the agent's buildEnv method which handles all the connection logic
|
|
2828
|
+
await this.agent.buildEnv(buildEnvOptions);
|
|
2829
|
+
|
|
2830
|
+
// Get the instance from the agent
|
|
2831
|
+
this.instance = this.agent.instance;
|
|
2832
|
+
|
|
2833
|
+
// Ensure this.os reflects the actual sandbox OS (important for vitest reporter)
|
|
2834
|
+
// After buildEnv, agent.sandboxOs should contain the correct OS value
|
|
2835
|
+
if (this.agent.sandboxOs) {
|
|
2836
|
+
this.os = this.agent.sandboxOs;
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
// Also ensure sandbox.os is set for consistency
|
|
2840
|
+
if (this.agent.sandbox && this.os) {
|
|
2841
|
+
this.agent.sandbox.os = this.os;
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
// Expose the agent's commands, parser, and commander
|
|
2845
|
+
this.commands = this.agent.commands;
|
|
2846
|
+
|
|
2847
|
+
// Recreate commands with dashcam elapsed time support
|
|
2848
|
+
const { createCommands } = require("./agent/lib/commands.js");
|
|
2849
|
+
const commandsResult = createCommands(
|
|
2850
|
+
this.agent.emitter,
|
|
2851
|
+
this.agent.system,
|
|
2852
|
+
this.agent.sandbox,
|
|
2853
|
+
this.agent.config,
|
|
2854
|
+
this.agent.session,
|
|
2855
|
+
() => this.agent.sourceMapper?.currentFilePath || this.agent.thisFile,
|
|
2856
|
+
this.agent.cliArgs.options.redrawThreshold,
|
|
2857
|
+
() => this.getDashcamElapsedTime(), // Pass dashcam elapsed time function
|
|
2858
|
+
);
|
|
2859
|
+
this.commands = commandsResult.commands;
|
|
2860
|
+
this.agent.commands = commandsResult.commands;
|
|
2861
|
+
this.agent.redraw = commandsResult.redraw;
|
|
2862
|
+
|
|
2863
|
+
// Command methods are already set up in constructor with lazy-await
|
|
2864
|
+
// They will use this.commands which is now populated
|
|
2865
|
+
|
|
2866
|
+
this.connected = true;
|
|
2867
|
+
this.analytics.track("sdk.connect", {
|
|
2868
|
+
sandboxId: this.instance?.instanceId,
|
|
2869
|
+
});
|
|
2870
|
+
|
|
2871
|
+
return this.instance;
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
/**
|
|
2875
|
+
* Disconnect from the sandbox
|
|
2876
|
+
* Note: After disconnecting, you cannot reconnect with the same SDK instance.
|
|
2877
|
+
* Create a new TestDriver instance if you need to connect again.
|
|
2878
|
+
* @returns {Promise<void>}
|
|
2879
|
+
*/
|
|
2880
|
+
async disconnect() {
|
|
2881
|
+
// Track disconnect event if we were connected
|
|
2882
|
+
if (this.connected && this.instance) {
|
|
2883
|
+
this.analytics.track("sdk.disconnect");
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
// Clean up redraw interval if active
|
|
2887
|
+
if (this.agent?.redraw?.cleanup) {
|
|
2888
|
+
try {
|
|
2889
|
+
this.agent.redraw.cleanup();
|
|
2890
|
+
} catch (err) {
|
|
2891
|
+
// Ignore cleanup errors
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
|
|
2896
|
+
|
|
2897
|
+
// Always close the sandbox WebSocket connection to clean up resources
|
|
2898
|
+
// This ensures we don't leave orphaned connections even if connect() failed
|
|
2899
|
+
// Must be awaited so presence.leave() completes before we return —
|
|
2900
|
+
// otherwise the concurrency counter on the API stays stale.
|
|
2901
|
+
if (this.sandbox && typeof this.sandbox.close === "function") {
|
|
2902
|
+
await this.sandbox.close();
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
// Remove all event listeners on the emitter to release references
|
|
2906
|
+
if (this.emitter && typeof this.emitter.removeAllListeners === "function") {
|
|
2907
|
+
this.emitter.removeAllListeners();
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
this.connected = false;
|
|
2911
|
+
this.instance = null;
|
|
2912
|
+
this.commands = null;
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
/**
|
|
2916
|
+
* Get the current session ID
|
|
2917
|
+
* Used for tracking and associating dashcam recordings with test results
|
|
2918
|
+
* @returns {string|null} The session ID or null if not connected
|
|
2919
|
+
*/
|
|
2920
|
+
getSessionId() {
|
|
2921
|
+
return this.session?.get() || null;
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
// ====================================
|
|
2925
|
+
// Element Finding API
|
|
2926
|
+
// ====================================
|
|
2927
|
+
|
|
2928
|
+
/**
|
|
2929
|
+
* Find an element by description
|
|
2930
|
+
* Automatically locates the element and returns it
|
|
2931
|
+
*
|
|
2932
|
+
* @param {string} description - Description of the element to find
|
|
2933
|
+
* @param {number | Object} [options] - Cache options: number for threshold, or object with {cacheKey, cache: { thresholds: { screen, element } }}
|
|
2934
|
+
* @returns {Promise<Element> & ChainableElement} Element instance that has been located, with chainable methods
|
|
2935
|
+
*
|
|
2936
|
+
* @example
|
|
2937
|
+
* // Find and click immediately (chainable)
|
|
2938
|
+
* await client.find('the sign in button').click();
|
|
2939
|
+
*
|
|
2940
|
+
* @example
|
|
2941
|
+
* // Find and click (traditional)
|
|
2942
|
+
* const element = await client.find('the sign in button');
|
|
2943
|
+
* await element.click();
|
|
2944
|
+
*
|
|
2945
|
+
* @example
|
|
2946
|
+
* // Find with cache key to enable caching
|
|
2947
|
+
* const element = await client.find('login button', { cacheKey: 'my-test-run' });
|
|
2948
|
+
*
|
|
2949
|
+
* @example
|
|
2950
|
+
* // Find with custom cache threshold (legacy)
|
|
2951
|
+
* const element = await client.find('login button', 0.05);
|
|
2952
|
+
*
|
|
2953
|
+
* @example
|
|
2954
|
+
* // Poll until element is found
|
|
2955
|
+
* let element;
|
|
2956
|
+
* while (!element?.found()) {
|
|
2957
|
+
* element = await client.find('login button');
|
|
2958
|
+
* if (!element.found()) {
|
|
2959
|
+
* await new Promise(resolve => setTimeout(resolve, 1000));
|
|
2960
|
+
* }
|
|
2961
|
+
* }
|
|
2962
|
+
* await element.click();
|
|
2963
|
+
*/
|
|
2964
|
+
find(description, options) {
|
|
2965
|
+
// Wrap in async IIFE to support lazy-await and promise tracking
|
|
2966
|
+
const findPromise = (async () => {
|
|
2967
|
+
// Lazy-await: wait for connection if still pending
|
|
2968
|
+
if (this.__connectionPromise) {
|
|
2969
|
+
await this.__connectionPromise;
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
// Warn if previous command may not have been awaited
|
|
2973
|
+
if (this._lastCommandName && !this._lastPromiseSettled) {
|
|
2974
|
+
console.warn(
|
|
2975
|
+
`⚠️ Warning: Previous ${this._lastCommandName}() may not have been awaited.\n` +
|
|
2976
|
+
` Add "await" before the call: await testdriver.${this._lastCommandName}(...)\n` +
|
|
2977
|
+
` Unawaited promises can cause race conditions and flaky tests.`,
|
|
2978
|
+
);
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
this._ensureConnected();
|
|
2982
|
+
|
|
2983
|
+
// Get caller info for auto-screenshot naming
|
|
2984
|
+
const callerInfo = this.autoScreenshots ? getCallerInfo() : null;
|
|
2985
|
+
|
|
2986
|
+
// Track this promise for unawaited detection
|
|
2987
|
+
this._lastCommandName = "find";
|
|
2988
|
+
this._lastPromiseSettled = false;
|
|
2989
|
+
|
|
2990
|
+
// Take "before" screenshot if enabled
|
|
2991
|
+
if (this.autoScreenshots) {
|
|
2992
|
+
await this._saveAutoScreenshot("find", "before", callerInfo, description);
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
const element = new Element(
|
|
2996
|
+
description,
|
|
2997
|
+
this,
|
|
2998
|
+
this.system,
|
|
2999
|
+
this.commands,
|
|
3000
|
+
);
|
|
3001
|
+
const result = await element.find(null, options);
|
|
3002
|
+
|
|
3003
|
+
// Take "after" screenshot if enabled
|
|
3004
|
+
if (this.autoScreenshots) {
|
|
3005
|
+
await this._saveAutoScreenshot("find", "after", callerInfo, description);
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
this._lastPromiseSettled = true;
|
|
3009
|
+
return result;
|
|
3010
|
+
})();
|
|
3011
|
+
|
|
3012
|
+
// Create a chainable promise that allows direct method chaining
|
|
3013
|
+
// e.g., await testdriver.find("button").click()
|
|
3014
|
+
return createChainablePromise(findPromise);
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
/**
|
|
3018
|
+
* Find all elements matching a description
|
|
3019
|
+
* Automatically locates all matching elements and returns them as an array
|
|
3020
|
+
*
|
|
3021
|
+
* @param {string} description - Description of the elements to find
|
|
3022
|
+
* @param {number | Object} [options] - Cache options: number for threshold, or object with {cacheKey, cache: { thresholds: { screen } }}
|
|
3023
|
+
* @returns {Promise<Element[]>} Array of Element instances that have been located
|
|
3024
|
+
*
|
|
3025
|
+
* @example
|
|
3026
|
+
* // Find all buttons and click the first one
|
|
3027
|
+
* const buttons = await client.findAll('button');
|
|
3028
|
+
* if (buttons.length > 0) {
|
|
3029
|
+
* await buttons[0].click();
|
|
3030
|
+
* }
|
|
3031
|
+
*
|
|
3032
|
+
* @example
|
|
3033
|
+
* // Find all list items with cache key to enable caching
|
|
3034
|
+
* const items = await client.findAll('list item', { cacheKey: 'my-test-run' });
|
|
3035
|
+
* for (const item of items) {
|
|
3036
|
+
* console.log(`Found item at (${item.x}, ${item.y})`);
|
|
3037
|
+
* }
|
|
3038
|
+
*/
|
|
3039
|
+
async findAll(description, options) {
|
|
3040
|
+
// Lazy-await: wait for connection if still pending
|
|
3041
|
+
if (this.__connectionPromise) {
|
|
3042
|
+
await this.__connectionPromise;
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
// Warn if previous command may not have been awaited
|
|
3046
|
+
if (this._lastCommandName && !this._lastPromiseSettled) {
|
|
3047
|
+
console.warn(
|
|
3048
|
+
`⚠️ Warning: Previous ${this._lastCommandName}() may not have been awaited.\n` +
|
|
3049
|
+
` Add "await" before the call: await testdriver.${this._lastCommandName}(...)\n` +
|
|
3050
|
+
` Unawaited promises can cause race conditions and flaky tests.`,
|
|
3051
|
+
);
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
this._ensureConnected();
|
|
3055
|
+
|
|
3056
|
+
// Get caller info for auto-screenshot naming
|
|
3057
|
+
const callerInfo = this.autoScreenshots ? getCallerInfo() : null;
|
|
3058
|
+
|
|
3059
|
+
// Track this promise for unawaited detection
|
|
3060
|
+
this._lastCommandName = "findAll";
|
|
3061
|
+
this._lastPromiseSettled = false;
|
|
3062
|
+
|
|
3063
|
+
// Take "before" screenshot if enabled
|
|
3064
|
+
if (this.autoScreenshots) {
|
|
3065
|
+
await this._saveAutoScreenshot("findAll", "before", callerInfo, description);
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
// Capture absolute timestamp at the very start of the command
|
|
3069
|
+
// Frontend will calculate relative time using: timestamp - replay.clientStartDate
|
|
3070
|
+
const absoluteTimestamp = Date.now();
|
|
3071
|
+
const startTime = absoluteTimestamp;
|
|
3072
|
+
|
|
3073
|
+
const { events } = require("./agent/events.js");
|
|
3074
|
+
|
|
3075
|
+
try {
|
|
3076
|
+
const screenshot = await this.system.captureScreenBase64();
|
|
3077
|
+
|
|
3078
|
+
// Handle options - can be a number (cacheThreshold) or object with cacheKey/cacheThreshold/cache
|
|
3079
|
+
let cacheKey = null;
|
|
3080
|
+
let cacheThreshold = null;
|
|
3081
|
+
let perCommandThresholds = null; // Per-command { screen } override (findAll has no element threshold)
|
|
3082
|
+
|
|
3083
|
+
if (typeof options === "number") {
|
|
3084
|
+
// Legacy: options is just a number threshold
|
|
3085
|
+
cacheThreshold = options;
|
|
3086
|
+
} else if (typeof options === "object" && options !== null) {
|
|
3087
|
+
// New: options is an object with cacheKey and/or cacheThreshold
|
|
3088
|
+
cacheKey = options.cacheKey || null;
|
|
3089
|
+
cacheThreshold = options.cacheThreshold ?? null;
|
|
3090
|
+
// Per-command cache thresholds: { cache: { thresholds: { screen: 0.1 } } }
|
|
3091
|
+
if (typeof options.cache === "object" && options.cache?.thresholds) {
|
|
3092
|
+
perCommandThresholds = options.cache.thresholds;
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
// Use default cacheKey from SDK constructor if not provided in findAll() options
|
|
3097
|
+
// BUT only if cache is not explicitly disabled via cache: false option
|
|
3098
|
+
if (
|
|
3099
|
+
!cacheKey &&
|
|
3100
|
+
this.options?.cacheKey &&
|
|
3101
|
+
!this._cacheExplicitlyDisabled
|
|
3102
|
+
) {
|
|
3103
|
+
cacheKey = this.options.cacheKey;
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
// Determine threshold:
|
|
3107
|
+
// - If cache is explicitly disabled, don't use cache even with cacheKey
|
|
3108
|
+
// - If cacheKey is provided, enable cache with threshold
|
|
3109
|
+
// - If no cacheKey, disable cache
|
|
3110
|
+
let threshold;
|
|
3111
|
+
if (this._cacheExplicitlyDisabled) {
|
|
3112
|
+
// Cache explicitly disabled via cache: false option or TD_NO_CACHE env
|
|
3113
|
+
threshold = -1;
|
|
3114
|
+
cacheKey = null; // Clear any cacheKey to ensure cache is truly disabled
|
|
3115
|
+
} else if (cacheKey) {
|
|
3116
|
+
// cacheKey provided - enable cache with threshold (findAll only uses screen, no element)
|
|
3117
|
+
threshold = perCommandThresholds?.screen ?? cacheThreshold ?? this.cacheConfig?.thresholds?.find?.screen ?? 0.05;
|
|
3118
|
+
} else if (cacheThreshold !== null) {
|
|
3119
|
+
// Explicit threshold provided without cacheKey
|
|
3120
|
+
threshold = perCommandThresholds?.screen ?? cacheThreshold;
|
|
3121
|
+
} else {
|
|
3122
|
+
// No cacheKey, no explicit threshold - disable cache
|
|
3123
|
+
threshold = -1;
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
// Debug log threshold
|
|
3127
|
+
const debugMode =
|
|
3128
|
+
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
3129
|
+
if (debugMode) {
|
|
3130
|
+
const autoGenMsg =
|
|
3131
|
+
this._autoGeneratedCacheKey && cacheKey === this.options.cacheKey
|
|
3132
|
+
? " (auto-generated from file hash)"
|
|
3133
|
+
: "";
|
|
3134
|
+
this.emitter.emit(
|
|
3135
|
+
events.log.debug,
|
|
3136
|
+
`🔍 findAll() threshold: ${threshold} (cache ${threshold < 0 ? "DISABLED" : "ENABLED"}${cacheKey ? `, cacheKey: ${cacheKey}${autoGenMsg}` : ""})`,
|
|
3137
|
+
);
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
const response = await this.apiClient.req(
|
|
3141
|
+
"/api/v7.0.0/testdriver/find-all",
|
|
3142
|
+
{
|
|
3143
|
+
session: this.getSessionId(),
|
|
3144
|
+
element: description,
|
|
3145
|
+
image: screenshot,
|
|
3146
|
+
threshold: threshold,
|
|
3147
|
+
cacheKey: cacheKey,
|
|
3148
|
+
os: this.os,
|
|
3149
|
+
resolution: this.resolution,
|
|
3150
|
+
},
|
|
3151
|
+
);
|
|
3152
|
+
|
|
3153
|
+
const duration = Date.now() - startTime;
|
|
3154
|
+
|
|
3155
|
+
if (response && response.elements && response.elements.length > 0) {
|
|
3156
|
+
// Single log at the end - found elements
|
|
3157
|
+
const formattedMessage = formatter.formatElementsFound(
|
|
3158
|
+
description,
|
|
3159
|
+
response.elements.length,
|
|
3160
|
+
{
|
|
3161
|
+
duration: duration,
|
|
3162
|
+
cacheHit: response.cached || false,
|
|
3163
|
+
},
|
|
3164
|
+
);
|
|
3165
|
+
this.emitter.emit(events.log.narration, formattedMessage, true);
|
|
3166
|
+
|
|
3167
|
+
// Create Element instances for each found element
|
|
3168
|
+
const elements = response.elements.map((elementData) => {
|
|
3169
|
+
const element = new Element(
|
|
3170
|
+
description,
|
|
3171
|
+
this,
|
|
3172
|
+
this.system,
|
|
3173
|
+
this.commands,
|
|
3174
|
+
);
|
|
3175
|
+
|
|
3176
|
+
// Set element as found with its coordinates
|
|
3177
|
+
element.coordinates = elementData.coordinates;
|
|
3178
|
+
element._found = true;
|
|
3179
|
+
element._response = this._sanitizeResponseForElement(
|
|
3180
|
+
response,
|
|
3181
|
+
elementData,
|
|
3182
|
+
);
|
|
3183
|
+
|
|
3184
|
+
// Only store screenshot in DEBUG mode
|
|
3185
|
+
const debugMode =
|
|
3186
|
+
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
3187
|
+
if (debugMode) {
|
|
3188
|
+
element._screenshot = screenshot;
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
return element;
|
|
3192
|
+
});
|
|
3193
|
+
|
|
3194
|
+
// Track successful findAll interaction (fire-and-forget, don't block)
|
|
3195
|
+
const sessionId = this.getSessionId();
|
|
3196
|
+
if (sessionId && this.apiClient) {
|
|
3197
|
+
this.apiClient
|
|
3198
|
+
.req("interaction/track", {
|
|
3199
|
+
type: "findAll",
|
|
3200
|
+
session: sessionId,
|
|
3201
|
+
prompt: description,
|
|
3202
|
+
timestamp: absoluteTimestamp, // Absolute epoch timestamp - frontend calculates relative using clientStartDate
|
|
3203
|
+
success: true,
|
|
3204
|
+
input: { count: elements.length },
|
|
3205
|
+
cacheHit: response.cached || false,
|
|
3206
|
+
selector: response.selector,
|
|
3207
|
+
selectorUsed: !!response.selector,
|
|
3208
|
+
screenshotUrl: response.screenshotKey ?? null,
|
|
3209
|
+
})
|
|
3210
|
+
.catch((err) => {
|
|
3211
|
+
console.warn("Failed to track findAll interaction:", err.message);
|
|
3212
|
+
});
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
// Log debug information when elements are found
|
|
3216
|
+
if (process.env.VERBOSE || process.env.TD_DEBUG) {
|
|
3217
|
+
this.emitter.emit(
|
|
3218
|
+
events.log.debug,
|
|
3219
|
+
`✓ Found ${elements.length} element(s): "${description}"`,
|
|
3220
|
+
);
|
|
3221
|
+
this.emitter.emit(
|
|
3222
|
+
events.log.debug,
|
|
3223
|
+
` Cache: ${response.cached ? "HIT" : "MISS"}`,
|
|
3224
|
+
);
|
|
3225
|
+
this.emitter.emit(events.log.debug, ` Time: ${duration}ms`);
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
// Take "after" screenshot if enabled
|
|
3229
|
+
if (this.autoScreenshots) {
|
|
3230
|
+
await this._saveAutoScreenshot("findAll", "after", callerInfo, description);
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
this._lastPromiseSettled = true;
|
|
3234
|
+
return elements;
|
|
3235
|
+
} else {
|
|
3236
|
+
const duration = Date.now() - startTime;
|
|
3237
|
+
|
|
3238
|
+
// Single log at the end - no elements found
|
|
3239
|
+
const formattedMessage = formatter.formatElementsFound(
|
|
3240
|
+
description,
|
|
3241
|
+
0,
|
|
3242
|
+
{
|
|
3243
|
+
duration: duration,
|
|
3244
|
+
cacheHit: response?.cached || false,
|
|
3245
|
+
},
|
|
3246
|
+
);
|
|
3247
|
+
this.emitter.emit(events.log.narration, formattedMessage, true);
|
|
3248
|
+
|
|
3249
|
+
// No elements found - track interaction (fire-and-forget, don't block)
|
|
3250
|
+
const sessionId = this.getSessionId();
|
|
3251
|
+
if (sessionId && this.apiClient) {
|
|
3252
|
+
this.apiClient
|
|
3253
|
+
.req("interaction/track", {
|
|
3254
|
+
type: "findAll",
|
|
3255
|
+
session: sessionId,
|
|
3256
|
+
prompt: description,
|
|
3257
|
+
timestamp: absoluteTimestamp, // Absolute epoch timestamp - frontend calculates relative using clientStartDate
|
|
3258
|
+
success: false,
|
|
3259
|
+
error: "No elements found",
|
|
3260
|
+
input: { count: 0 },
|
|
3261
|
+
cacheHit: response?.cached || false,
|
|
3262
|
+
selector: response?.selector,
|
|
3263
|
+
selectorUsed: !!response?.selector,
|
|
3264
|
+
screenshotUrl: response?.screenshotKey ?? null,
|
|
3265
|
+
})
|
|
3266
|
+
.catch((err) => {
|
|
3267
|
+
console.warn("Failed to track findAll interaction:", err.message);
|
|
3268
|
+
});
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
// Take "after" screenshot if enabled (no elements found)
|
|
3272
|
+
if (this.autoScreenshots) {
|
|
3273
|
+
await this._saveAutoScreenshot("findAll", "after", callerInfo, description);
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
// No elements found - return empty array
|
|
3277
|
+
this._lastPromiseSettled = true;
|
|
3278
|
+
return [];
|
|
3279
|
+
}
|
|
3280
|
+
} catch (error) {
|
|
3281
|
+
const duration = Date.now() - startTime;
|
|
3282
|
+
|
|
3283
|
+
// Single log at the end - error
|
|
3284
|
+
const formattedMessage = formatter.formatElementsFound(
|
|
3285
|
+
description,
|
|
3286
|
+
0,
|
|
3287
|
+
{
|
|
3288
|
+
duration: duration,
|
|
3289
|
+
},
|
|
3290
|
+
);
|
|
3291
|
+
this.emitter.emit(events.log.narration, formattedMessage, true);
|
|
3292
|
+
|
|
3293
|
+
// Track findAll error interaction (fire-and-forget, don't block)
|
|
3294
|
+
const sessionId = this.getSessionId();
|
|
3295
|
+
if (sessionId && this.apiClient) {
|
|
3296
|
+
this.apiClient
|
|
3297
|
+
.req("interaction/track", {
|
|
3298
|
+
type: "findAll",
|
|
3299
|
+
session: sessionId,
|
|
3300
|
+
prompt: description,
|
|
3301
|
+
timestamp: absoluteTimestamp, // Absolute epoch timestamp - frontend calculates relative using clientStartDate
|
|
3302
|
+
success: false,
|
|
3303
|
+
error: error.message,
|
|
3304
|
+
input: { count: 0 },
|
|
3305
|
+
})
|
|
3306
|
+
.catch((err) => {
|
|
3307
|
+
console.warn("Failed to track findAll interaction:", err.message);
|
|
3308
|
+
});
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
// Take "error" screenshot if enabled
|
|
3312
|
+
if (this.autoScreenshots) {
|
|
3313
|
+
await this._saveAutoScreenshot("findAll", "error", callerInfo, description);
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
this._lastPromiseSettled = true;
|
|
3317
|
+
return [];
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
/**
|
|
3322
|
+
* Sanitize response for individual element in findAll results
|
|
3323
|
+
* @private
|
|
3324
|
+
* @param {Object} response - Full API response
|
|
3325
|
+
* @param {Object} elementData - Individual element data
|
|
3326
|
+
* @returns {Object} Sanitized response for this element
|
|
3327
|
+
*/
|
|
3328
|
+
_sanitizeResponseForElement(response, elementData) {
|
|
3329
|
+
const debugMode =
|
|
3330
|
+
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
3331
|
+
|
|
3332
|
+
// Combine global response data with element-specific data
|
|
3333
|
+
const sanitized = {
|
|
3334
|
+
coordinates: elementData.coordinates,
|
|
3335
|
+
cached: response.cached || false,
|
|
3336
|
+
elementType: response.elementType,
|
|
3337
|
+
extractedText: response.extractedText,
|
|
3338
|
+
confidence: elementData.confidence,
|
|
3339
|
+
similarity: elementData.similarity,
|
|
3340
|
+
boundingBox: elementData.boundingBox,
|
|
3341
|
+
width: elementData.width,
|
|
3342
|
+
height: elementData.height,
|
|
3343
|
+
text: elementData.text,
|
|
3344
|
+
label: elementData.label,
|
|
3345
|
+
};
|
|
3346
|
+
|
|
3347
|
+
// Only keep large data in debug mode
|
|
3348
|
+
if (debugMode) {
|
|
3349
|
+
sanitized.croppedImage = elementData.croppedImage;
|
|
3350
|
+
sanitized.screenshot = response.screenshot;
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
return sanitized;
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
// ====================================
|
|
3357
|
+
// Command Methods Setup
|
|
3358
|
+
// ====================================
|
|
3359
|
+
|
|
3360
|
+
/**
|
|
3361
|
+
* Dynamically set up command methods based on available commands
|
|
3362
|
+
* This creates camelCase methods that wrap the underlying command functions
|
|
3363
|
+
* When autoScreenshots is enabled, captures before/after screenshots for each command
|
|
3364
|
+
* @private
|
|
3365
|
+
*/
|
|
3366
|
+
_setupCommandMethods() {
|
|
3367
|
+
// Mapping from internal command names to SDK method names
|
|
3368
|
+
const commandMapping = {
|
|
3369
|
+
"hover-text": "hoverText",
|
|
3370
|
+
"hover-image": "hoverImage",
|
|
3371
|
+
"match-image": "matchImage",
|
|
3372
|
+
type: "type",
|
|
3373
|
+
"press-keys": "pressKeys",
|
|
3374
|
+
click: "click",
|
|
3375
|
+
hover: "hover",
|
|
3376
|
+
scroll: "scroll",
|
|
3377
|
+
wait: "wait",
|
|
3378
|
+
"wait-for-text": "waitForText",
|
|
3379
|
+
"wait-for-image": "waitForImage",
|
|
3380
|
+
"scroll-until-text": "scrollUntilText",
|
|
3381
|
+
"scroll-until-image": "scrollUntilImage",
|
|
3382
|
+
"focus-application": "focusApplication",
|
|
3383
|
+
extract: "extract",
|
|
3384
|
+
assert: "assert",
|
|
3385
|
+
exec: "exec",
|
|
3386
|
+
};
|
|
3387
|
+
|
|
3388
|
+
// Helper to extract a description from command args for screenshot naming
|
|
3389
|
+
const getDescriptionFromArgs = (methodName, args) => {
|
|
3390
|
+
if (!args || args.length === 0) return "";
|
|
3391
|
+
const firstArg = args[0];
|
|
3392
|
+
|
|
3393
|
+
switch (methodName) {
|
|
3394
|
+
case "type":
|
|
3395
|
+
// For type, use the text being typed (truncated)
|
|
3396
|
+
return typeof firstArg === "string" ? firstArg.substring(0, 20) : "";
|
|
3397
|
+
case "pressKeys":
|
|
3398
|
+
// For pressKeys, show the keys
|
|
3399
|
+
return Array.isArray(firstArg) ? firstArg.join("+") : String(firstArg);
|
|
3400
|
+
case "click":
|
|
3401
|
+
case "hover":
|
|
3402
|
+
// For click/hover, try to get coordinates or prompt
|
|
3403
|
+
if (typeof firstArg === "object" && firstArg !== null) {
|
|
3404
|
+
return firstArg.prompt || `${firstArg.x},${firstArg.y}`;
|
|
3405
|
+
}
|
|
3406
|
+
return typeof firstArg === "number" ? `${firstArg},${args[1]}` : "";
|
|
3407
|
+
case "scroll":
|
|
3408
|
+
// For scroll, show direction
|
|
3409
|
+
return typeof firstArg === "string" ? firstArg : "down";
|
|
3410
|
+
case "waitForText":
|
|
3411
|
+
case "scrollUntilText":
|
|
3412
|
+
// For text-based commands, use the text
|
|
3413
|
+
if (typeof firstArg === "object" && firstArg !== null) {
|
|
3414
|
+
return firstArg.text || "";
|
|
3415
|
+
}
|
|
3416
|
+
return typeof firstArg === "string" ? firstArg : "";
|
|
3417
|
+
case "focusApplication":
|
|
3418
|
+
// For focus, use the app name
|
|
3419
|
+
return typeof firstArg === "string" ? firstArg : "";
|
|
3420
|
+
case "assert":
|
|
3421
|
+
case "extract":
|
|
3422
|
+
// For assert/extract, use the assertion/description
|
|
3423
|
+
return typeof firstArg === "string" ? firstArg.substring(0, 30) : "";
|
|
3424
|
+
case "exec":
|
|
3425
|
+
// For exec, show the language
|
|
3426
|
+
if (typeof firstArg === "object" && firstArg !== null) {
|
|
3427
|
+
return firstArg.language || "code";
|
|
3428
|
+
}
|
|
3429
|
+
return typeof firstArg === "string" ? firstArg : "code";
|
|
3430
|
+
default:
|
|
3431
|
+
return typeof firstArg === "string" ? firstArg.substring(0, 20) : "";
|
|
3432
|
+
}
|
|
3433
|
+
};
|
|
3434
|
+
|
|
3435
|
+
// Create SDK methods that lazy-await connection then forward to this.commands
|
|
3436
|
+
for (const [commandName, methodName] of Object.entries(commandMapping)) {
|
|
3437
|
+
// Use closure to capture sdk reference instead of .bind(this)
|
|
3438
|
+
// This allows Error.captureStackTrace to correctly exclude the method from stack traces
|
|
3439
|
+
const sdk = this;
|
|
3440
|
+
const methodFn = async function (...args) {
|
|
3441
|
+
// Lazy-await: wait for connection if still pending
|
|
3442
|
+
if (sdk.__connectionPromise) {
|
|
3443
|
+
await sdk.__connectionPromise;
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
// Warn if previous command may not have been awaited
|
|
3447
|
+
if (sdk._lastCommandName && !sdk._lastPromiseSettled) {
|
|
3448
|
+
console.warn(
|
|
3449
|
+
`⚠️ Warning: Previous ${sdk._lastCommandName}() may not have been awaited.\n` +
|
|
3450
|
+
` Add "await" before the call: await testdriver.${sdk._lastCommandName}(...)\n` +
|
|
3451
|
+
` Unawaited promises can cause race conditions and flaky tests.`,
|
|
3452
|
+
);
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
sdk._ensureConnected();
|
|
3456
|
+
|
|
3457
|
+
// Capture the call site for better error reporting AND for auto-screenshots
|
|
3458
|
+
const callSite = {};
|
|
3459
|
+
Error.captureStackTrace(callSite, methodFn);
|
|
3460
|
+
|
|
3461
|
+
// Get caller info for auto-screenshot naming
|
|
3462
|
+
const callerInfo = sdk.autoScreenshots ? getCallerInfo() : null;
|
|
3463
|
+
const description = sdk.autoScreenshots ? getDescriptionFromArgs(methodName, args) : "";
|
|
3464
|
+
|
|
3465
|
+
// Track this promise for unawaited detection
|
|
3466
|
+
sdk._lastCommandName = methodName;
|
|
3467
|
+
sdk._lastPromiseSettled = false;
|
|
3468
|
+
|
|
3469
|
+
try {
|
|
3470
|
+
// Take "before" screenshot if enabled
|
|
3471
|
+
if (sdk.autoScreenshots) {
|
|
3472
|
+
await sdk._saveAutoScreenshot(methodName, "before", callerInfo, description);
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
let result;
|
|
3476
|
+
// Special handling for assert to inject SDK options (cacheKey, os, resolution, threshold)
|
|
3477
|
+
// similar to how find() handles these in the Element class
|
|
3478
|
+
// Note: assert does NOT use elementSimilarity (template matching not relevant for assertions)
|
|
3479
|
+
if (commandName === 'assert') {
|
|
3480
|
+
const assertion = args[0];
|
|
3481
|
+
const userOptions = args[1] || {};
|
|
3482
|
+
|
|
3483
|
+
// Support per-command cache threshold override: { cache: { threshold: 0.05 } }
|
|
3484
|
+
const perCommandThreshold = typeof userOptions.cache === "object"
|
|
3485
|
+
? userOptions.cache.threshold
|
|
3486
|
+
: undefined;
|
|
3487
|
+
|
|
3488
|
+
// Merge SDK defaults with user options (user options take precedence)
|
|
3489
|
+
const mergedOptions = {
|
|
3490
|
+
cacheKey: userOptions.cacheKey ?? sdk.options.cacheKey,
|
|
3491
|
+
os: userOptions.os ?? sdk.os,
|
|
3492
|
+
resolution: userOptions.resolution ?? sdk.resolution,
|
|
3493
|
+
threshold: perCommandThreshold ?? userOptions.threshold ?? (sdk.cacheConfig?.thresholds?.assert ?? sdk.cacheThresholds?.assert ?? 0.05),
|
|
3494
|
+
ai: {
|
|
3495
|
+
...sdk.aiConfig,
|
|
3496
|
+
...(typeof userOptions.ai === "object" ? userOptions.ai : {}),
|
|
3497
|
+
top: {
|
|
3498
|
+
...sdk.aiConfig?.top,
|
|
3499
|
+
...(typeof userOptions.ai === "object" ? userOptions.ai?.top : {}),
|
|
3500
|
+
},
|
|
3501
|
+
},
|
|
3502
|
+
};
|
|
3503
|
+
|
|
3504
|
+
// Note: commands.assert takes (assertion, options), shouldThrow is determined internally
|
|
3505
|
+
result = await sdk.commands[commandName](assertion, mergedOptions);
|
|
3506
|
+
} else {
|
|
3507
|
+
result = await sdk.commands[commandName](...args);
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
// Take "after" screenshot if enabled
|
|
3511
|
+
if (sdk.autoScreenshots) {
|
|
3512
|
+
await sdk._saveAutoScreenshot(methodName, "after", callerInfo, description);
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
sdk._lastPromiseSettled = true;
|
|
3516
|
+
return result;
|
|
3517
|
+
} catch (error) {
|
|
3518
|
+
// Take "error" screenshot if enabled (instead of "after")
|
|
3519
|
+
if (sdk.autoScreenshots) {
|
|
3520
|
+
await sdk._saveAutoScreenshot(methodName, "error", callerInfo, description);
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
sdk._lastPromiseSettled = true;
|
|
3524
|
+
// Ensure we have a proper Error object with a message
|
|
3525
|
+
let properError = error;
|
|
3526
|
+
if (!(error instanceof Error)) {
|
|
3527
|
+
const errorMessage =
|
|
3528
|
+
error?.message || error?.reason || JSON.stringify(error);
|
|
3529
|
+
properError = new Error(errorMessage);
|
|
3530
|
+
if (error?.code) properError.code = error.code;
|
|
3531
|
+
if (error?.fullError) properError.fullError = error.fullError;
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
// Replace the stack trace to point to the actual caller instead of SDK internals
|
|
3535
|
+
if (Error.captureStackTrace && callSite.stack) {
|
|
3536
|
+
const errorMessage = properError.stack?.split("\n")[0];
|
|
3537
|
+
const callerStack = callSite.stack?.split("\n").slice(1);
|
|
3538
|
+
properError.stack = errorMessage + "\n" + callerStack.join("\n");
|
|
3539
|
+
}
|
|
3540
|
+
throw properError;
|
|
3541
|
+
}
|
|
3542
|
+
};
|
|
3543
|
+
this[methodName] = methodFn;
|
|
3544
|
+
|
|
3545
|
+
// Preserve the original function's name for better debugging
|
|
3546
|
+
Object.defineProperty(this[methodName], "name", {
|
|
3547
|
+
value: methodName,
|
|
3548
|
+
writable: false,
|
|
3549
|
+
});
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
// ====================================
|
|
3554
|
+
// Helper Methods
|
|
3555
|
+
// ====================================
|
|
3556
|
+
|
|
3557
|
+
/**
|
|
3558
|
+
* Capture a screenshot of the current screen and save it to .testdriver/screenshots
|
|
3559
|
+
* @param {string} [filename] - Custom filename (without .png extension)
|
|
3560
|
+
* @returns {Promise<string>} The file path where the screenshot was saved
|
|
3561
|
+
*
|
|
3562
|
+
* @example
|
|
3563
|
+
* // Capture a screenshot with auto-generated filename
|
|
3564
|
+
* const screenshotPath = await testdriver.screenshot();
|
|
3565
|
+
*
|
|
3566
|
+
* @example
|
|
3567
|
+
* // Capture with custom filename
|
|
3568
|
+
* const screenshotPath = await testdriver.screenshot("login-page");
|
|
3569
|
+
* // Saves to: .testdriver/screenshots/<test>/login-page.png
|
|
3570
|
+
*/
|
|
3571
|
+
async screenshot(filename) {
|
|
3572
|
+
this._ensureConnected();
|
|
3573
|
+
|
|
3574
|
+
const finalFilename = filename
|
|
3575
|
+
? filename.endsWith(".png")
|
|
3576
|
+
? filename
|
|
3577
|
+
: `${filename}.png`
|
|
3578
|
+
: `screenshot-${Date.now()}.png`;
|
|
3579
|
+
|
|
3580
|
+
const base64Data = await this.system.captureScreenBase64(1, false, false);
|
|
3581
|
+
|
|
3582
|
+
// Save to .testdriver/screenshots/<test-file-name> directory
|
|
3583
|
+
let screenshotsDir = path.join(process.cwd(), ".testdriver", "screenshots");
|
|
3584
|
+
if (this.testFile) {
|
|
3585
|
+
const testFileName = path.basename(
|
|
3586
|
+
this.testFile,
|
|
3587
|
+
path.extname(this.testFile),
|
|
3588
|
+
);
|
|
3589
|
+
screenshotsDir = path.join(screenshotsDir, testFileName);
|
|
3590
|
+
}
|
|
3591
|
+
if (!fs.existsSync(screenshotsDir)) {
|
|
3592
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
const filePath = path.join(screenshotsDir, finalFilename);
|
|
3596
|
+
|
|
3597
|
+
// Remove data:image/png;base64, prefix if present
|
|
3598
|
+
const cleanBase64 = base64Data.replace(/^data:image\/\w+;base64,/, "");
|
|
3599
|
+
const buffer = Buffer.from(cleanBase64, "base64");
|
|
3600
|
+
|
|
3601
|
+
fs.writeFileSync(filePath, buffer);
|
|
3602
|
+
|
|
3603
|
+
this.emitter.emit("log:info", `📸 Screenshot saved to: ${filePath}`);
|
|
3604
|
+
|
|
3605
|
+
return filePath;
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
/**
|
|
3609
|
+
* Parse the current screen using OmniParser v2 to detect all UI elements
|
|
3610
|
+
* Returns structured data with element types, bounding boxes, and content
|
|
3611
|
+
* Requires enterprise or self-hosted plan.
|
|
3612
|
+
*
|
|
3613
|
+
* @returns {Promise<ParseResult>} Parsed screen elements
|
|
3614
|
+
*
|
|
3615
|
+
* @typedef {Object} ParseResult
|
|
3616
|
+
* @property {ParsedElement[]} elements - Array of detected UI elements
|
|
3617
|
+
* @property {string} annotatedImageUrl - URL of the annotated screenshot
|
|
3618
|
+
* @property {number} imageWidth - Width of the analyzed image
|
|
3619
|
+
* @property {number} imageHeight - Height of the analyzed image
|
|
3620
|
+
*
|
|
3621
|
+
* @typedef {Object} ParsedElement
|
|
3622
|
+
* @property {number} index - Element index
|
|
3623
|
+
* @property {string} type - Element type (e.g. "text", "icon", "button")
|
|
3624
|
+
* @property {string} content - Text content or description
|
|
3625
|
+
* @property {string} interactivity - Interactivity level (e.g. "clickable", "non-interactive")
|
|
3626
|
+
* @property {Object} bbox - Bounding box in pixel coordinates
|
|
3627
|
+
* @property {number} bbox.x0 - Left edge X coordinate
|
|
3628
|
+
* @property {number} bbox.y0 - Top edge Y coordinate
|
|
3629
|
+
* @property {number} bbox.x1 - Right edge X coordinate
|
|
3630
|
+
* @property {number} bbox.y1 - Bottom edge Y coordinate
|
|
3631
|
+
* @property {Object} boundingBox - Bounding box as {left, top, width, height}
|
|
3632
|
+
* @property {number} boundingBox.left - Left position
|
|
3633
|
+
* @property {number} boundingBox.top - Top position
|
|
3634
|
+
* @property {number} boundingBox.width - Element width
|
|
3635
|
+
* @property {number} boundingBox.height - Element height
|
|
3636
|
+
*
|
|
3637
|
+
* @example
|
|
3638
|
+
* // Get all elements on screen
|
|
3639
|
+
* const result = await testdriver.parse();
|
|
3640
|
+
* console.log(`Found ${result.elements.length} elements`);
|
|
3641
|
+
*
|
|
3642
|
+
* @example
|
|
3643
|
+
* // Find clickable elements
|
|
3644
|
+
* const result = await testdriver.parse();
|
|
3645
|
+
* const clickable = result.elements.filter(e => e.interactivity === 'clickable');
|
|
3646
|
+
*
|
|
3647
|
+
* @example
|
|
3648
|
+
* // Find text content
|
|
3649
|
+
* const result = await testdriver.parse();
|
|
3650
|
+
* const textElements = result.elements.filter(e => e.type === 'text');
|
|
3651
|
+
* textElements.forEach(e => console.log(e.content));
|
|
3652
|
+
*/
|
|
3653
|
+
async parse() {
|
|
3654
|
+
this._ensureConnected();
|
|
3655
|
+
|
|
3656
|
+
const { events } = require("./agent/events.js");
|
|
3657
|
+
this.emitter.emit(events.log.log, "🔍 Running OmniParser screen analysis...");
|
|
3658
|
+
|
|
3659
|
+
const screenshot = await this.system.captureScreenBase64();
|
|
3660
|
+
|
|
3661
|
+
const response = await this.apiClient.req("parse", {
|
|
3662
|
+
session: this.getSessionId(),
|
|
3663
|
+
image: screenshot,
|
|
3664
|
+
});
|
|
3665
|
+
|
|
3666
|
+
if (response.error) {
|
|
3667
|
+
throw new Error(response.error);
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
this.emitter.emit(
|
|
3671
|
+
events.log.log,
|
|
3672
|
+
`✅ Parse complete: ${response.elements?.length || 0} elements detected`,
|
|
3673
|
+
);
|
|
3674
|
+
|
|
3675
|
+
// Output elements as a formatted table
|
|
3676
|
+
if (response.elements && response.elements.length > 0) {
|
|
3677
|
+
const tableOutput = formatter.formatParseElements(response.elements);
|
|
3678
|
+
this.emitter.emit(events.log.log, tableOutput);
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3681
|
+
return response;
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
/**
|
|
3685
|
+
* Save an automatic screenshot with descriptive naming
|
|
3686
|
+
* Used internally when autoScreenshots is enabled
|
|
3687
|
+
* @private
|
|
3688
|
+
* @param {string} actionName - Name of the action (click, type, hover, etc.)
|
|
3689
|
+
* @param {string} phase - 'before' or 'after'
|
|
3690
|
+
* @param {Object} callerInfo - Caller information from getCallerInfo()
|
|
3691
|
+
* @param {string} [description] - Optional description of the action target
|
|
3692
|
+
* @returns {Promise<string|null>} The file path where the screenshot was saved, or null if failed
|
|
3693
|
+
*/
|
|
3694
|
+
async _saveAutoScreenshot(actionName, phase, callerInfo, description = "") {
|
|
3695
|
+
if (!this.autoScreenshots || !this.connected) {
|
|
3696
|
+
return null;
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
try {
|
|
3700
|
+
// Increment sequence for unique ordering
|
|
3701
|
+
this._screenshotSequence++;
|
|
3702
|
+
const seq = String(this._screenshotSequence).padStart(3, "0");
|
|
3703
|
+
|
|
3704
|
+
// Extract line number info
|
|
3705
|
+
const lineInfo = callerInfo.line ? `L${callerInfo.line}` : "L???";
|
|
3706
|
+
|
|
3707
|
+
// Sanitize description for filename (remove special chars, limit length)
|
|
3708
|
+
const sanitizedDesc = description
|
|
3709
|
+
.replace(/[^a-zA-Z0-9\s-]/g, "")
|
|
3710
|
+
.replace(/\s+/g, "-")
|
|
3711
|
+
.substring(0, 30)
|
|
3712
|
+
.toLowerCase();
|
|
3713
|
+
|
|
3714
|
+
// Build filename: 001-click-before-L42-submit-button.png
|
|
3715
|
+
const descPart = sanitizedDesc ? `-${sanitizedDesc}` : "";
|
|
3716
|
+
const filename = `${seq}-${actionName}-${phase}-${lineInfo}${descPart}.png`;
|
|
3717
|
+
|
|
3718
|
+
const base64Data = await this.system.captureScreenBase64(1, false, false);
|
|
3719
|
+
|
|
3720
|
+
// Save to .testdriver/screenshots/<test-file-name> directory
|
|
3721
|
+
let screenshotsDir = path.join(process.cwd(), ".testdriver", "screenshots");
|
|
3722
|
+
if (this.testFile) {
|
|
3723
|
+
const testFileName = path.basename(
|
|
3724
|
+
this.testFile,
|
|
3725
|
+
path.extname(this.testFile),
|
|
3726
|
+
);
|
|
3727
|
+
screenshotsDir = path.join(screenshotsDir, testFileName);
|
|
3728
|
+
}
|
|
3729
|
+
if (!fs.existsSync(screenshotsDir)) {
|
|
3730
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
const filePath = path.join(screenshotsDir, filename);
|
|
3734
|
+
|
|
3735
|
+
// Remove data:image/png;base64, prefix if present
|
|
3736
|
+
const cleanBase64 = base64Data.replace(/^data:image\/\w+;base64,/, "");
|
|
3737
|
+
const buffer = Buffer.from(cleanBase64, "base64");
|
|
3738
|
+
|
|
3739
|
+
fs.writeFileSync(filePath, buffer);
|
|
3740
|
+
|
|
3741
|
+
// Debug log in verbose mode
|
|
3742
|
+
const debugMode = process.env.VERBOSE || process.env.TD_DEBUG;
|
|
3743
|
+
if (debugMode) {
|
|
3744
|
+
this.emitter.emit("log:debug", `📸 Auto-screenshot: ${filename}`);
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3747
|
+
return filePath;
|
|
3748
|
+
} catch (error) {
|
|
3749
|
+
// Don't fail the command if screenshot fails
|
|
3750
|
+
const debugMode = process.env.VERBOSE || process.env.TD_DEBUG;
|
|
3751
|
+
if (debugMode) {
|
|
3752
|
+
this.emitter.emit("log:debug", `Failed to save auto-screenshot: ${error.message}`);
|
|
3753
|
+
}
|
|
3754
|
+
return null;
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
/**
|
|
3759
|
+
* Ensure the SDK is connected before running commands
|
|
3760
|
+
* @private
|
|
3761
|
+
*/
|
|
3762
|
+
_ensureConnected() {
|
|
3763
|
+
if (!this.connected) {
|
|
3764
|
+
throw new Error("SDK is not connected. Call connect() first.");
|
|
3765
|
+
}
|
|
3766
|
+
}
|
|
3767
|
+
|
|
3768
|
+
/**
|
|
3769
|
+
* Get the current sandbox instance details
|
|
3770
|
+
* @returns {Object|null} Sandbox instance
|
|
3771
|
+
*/
|
|
3772
|
+
getInstance() {
|
|
3773
|
+
return this.instance;
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
/**
|
|
3777
|
+
* Enable or disable logging output
|
|
3778
|
+
* @param {boolean} enabled - Whether to enable logging
|
|
3779
|
+
*/
|
|
3780
|
+
setLogging(enabled) {
|
|
3781
|
+
this.loggingEnabled = enabled;
|
|
3782
|
+
if (enabled && !this._loggingSetup) {
|
|
3783
|
+
this._setupLogging();
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
/**
|
|
3788
|
+
* Get the event emitter for custom event handling
|
|
3789
|
+
* @returns {EventEmitter2} Event emitter
|
|
3790
|
+
*/
|
|
3791
|
+
getEmitter() {
|
|
3792
|
+
return this.emitter;
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
/**
|
|
3796
|
+
* Set test context for enhanced logging (integrates with Vitest)
|
|
3797
|
+
* @param {Object} context - Test context with file, test name, start time
|
|
3798
|
+
* @param {string} [context.file] - Current test file name
|
|
3799
|
+
* @param {string} [context.test] - Current test name
|
|
3800
|
+
* @param {number} [context.startTime] - Test start timestamp
|
|
3801
|
+
*/
|
|
3802
|
+
setTestContext(context) {
|
|
3803
|
+
formatter.setTestContext(context);
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
/**
|
|
3807
|
+
* Set up logging for the SDK
|
|
3808
|
+
* @private
|
|
3809
|
+
*/
|
|
3810
|
+
/**
|
|
3811
|
+
* Log environment info (version, API URL, git commit) after connect.
|
|
3812
|
+
* Fires asynchronously so it never blocks the test.
|
|
3813
|
+
* Suppressed when the API reports the "stable" channel.
|
|
3814
|
+
* @private
|
|
3815
|
+
*/
|
|
3816
|
+
_logEnvironmentInfo() {
|
|
3817
|
+
const apiRoot = this.config?.TD_API_ROOT || 'unknown';
|
|
3818
|
+
const apiKey = this.config?.TD_API_KEY || '';
|
|
3819
|
+
const maskedKey = apiKey.length > 4 ? '***' + apiKey.slice(-4) : '(not set)';
|
|
3820
|
+
const env = process.env.TD_ENV || 'unknown';
|
|
3821
|
+
const os = this.agent?.options?.os || process.env.TD_OS || 'linux';
|
|
3822
|
+
const sdkVersion = require('./package.json').version;
|
|
3823
|
+
|
|
3824
|
+
// Always print local config immediately
|
|
3825
|
+
const localLines = [
|
|
3826
|
+
'',
|
|
3827
|
+
` ┌─ TestDriver SDK v${sdkVersion}`,
|
|
3828
|
+
` │ Environment: ${env}`,
|
|
3829
|
+
` │ API: ${apiRoot}`,
|
|
3830
|
+
` │ Key: ${maskedKey}`,
|
|
3831
|
+
` │ OS: ${os}`,
|
|
3832
|
+
` └─`,
|
|
3833
|
+
'',
|
|
3834
|
+
];
|
|
3835
|
+
console.log(localLines.join('\n'));
|
|
3836
|
+
|
|
3837
|
+
// Fetch API version info asynchronously (non-blocking, best-effort)
|
|
3838
|
+
const http = apiRoot.startsWith('https') ? require('https') : require('http');
|
|
3839
|
+
const url = apiRoot + '/api/entrance/version';
|
|
3840
|
+
const req = http.get(url, { timeout: 5000 }, (res) => {
|
|
3841
|
+
let data = '';
|
|
3842
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
3843
|
+
res.on('end', () => {
|
|
3844
|
+
try {
|
|
3845
|
+
const info = JSON.parse(data);
|
|
3846
|
+
const commit = info.commit || 'unknown';
|
|
3847
|
+
const shortCommit = commit.substring(0, 7);
|
|
3848
|
+
const commitUrl = commit !== 'unknown'
|
|
3849
|
+
? `https://github.com/testdriverai/mono/commit/${commit}`
|
|
3850
|
+
: null;
|
|
3851
|
+
const lines = [
|
|
3852
|
+
` ┌─ API Server`,
|
|
3853
|
+
` │ Channel: ${info.channel || 'unknown'} v${info.version || '?'}`,
|
|
3854
|
+
commitUrl
|
|
3855
|
+
? ` │ Commit: ${shortCommit} → ${commitUrl}`
|
|
3856
|
+
: ` │ Commit: ${shortCommit}`,
|
|
3857
|
+
` └─`,
|
|
3858
|
+
'',
|
|
3859
|
+
];
|
|
3860
|
+
console.log(lines.join('\n'));
|
|
3861
|
+
} catch (_) { /* ignore parse errors */ }
|
|
3862
|
+
});
|
|
3863
|
+
});
|
|
3864
|
+
req.on('error', () => { /* ignore network errors */ });
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3867
|
+
_setupLogging() {
|
|
3868
|
+
// Track the last fatal error message to throw on exit
|
|
3869
|
+
let lastFatalError = null;
|
|
3870
|
+
const debugMode =
|
|
3871
|
+
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
3872
|
+
|
|
3873
|
+
// Set up markdown logger
|
|
3874
|
+
createMarkdownLogger(this.emitter);
|
|
3875
|
+
|
|
3876
|
+
// Set up basic event logging
|
|
3877
|
+
// Note: We only console.log here - the console spy in vitest/hooks.mjs
|
|
3878
|
+
// handles forwarding to the local log buffer.
|
|
3879
|
+
this.emitter.on("log:**", (message) => {
|
|
3880
|
+
const event = this.emitter.event;
|
|
3881
|
+
|
|
3882
|
+
if (event.includes("markdown")) {
|
|
3883
|
+
return;
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
if (event === events.log.debug && !debugMode) return;
|
|
3887
|
+
if (this.loggingEnabled && message) {
|
|
3888
|
+
const prefixedMessage = this.testContext
|
|
3889
|
+
? `[${this.testContext}] ${message}`
|
|
3890
|
+
: message;
|
|
3891
|
+
console.log(prefixedMessage);
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3894
|
+
// Buffer structured SDK log for later upload
|
|
3895
|
+
if (message) {
|
|
3896
|
+
const level = event === events.log.warn ? "warn"
|
|
3897
|
+
: event === events.log.debug ? "debug"
|
|
3898
|
+
: "info";
|
|
3899
|
+
this._logBuffer.push({
|
|
3900
|
+
time: Date.now(),
|
|
3901
|
+
line: String(message),
|
|
3902
|
+
level,
|
|
3903
|
+
source: "sdk",
|
|
3904
|
+
event,
|
|
3905
|
+
logFile: "sdk",
|
|
3906
|
+
});
|
|
3907
|
+
}
|
|
3908
|
+
});
|
|
3909
|
+
|
|
3910
|
+
this.emitter.on("error:**", (data) => {
|
|
3911
|
+
if (this.loggingEnabled) {
|
|
3912
|
+
const event = this.emitter.event;
|
|
3913
|
+
console.error(event, ":", data);
|
|
3914
|
+
|
|
3915
|
+
// Capture fatal errors
|
|
3916
|
+
if (event === events.error.fatal) {
|
|
3917
|
+
lastFatalError = data;
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3921
|
+
// Buffer error events for later upload
|
|
3922
|
+
this._logBuffer.push({
|
|
3923
|
+
time: Date.now(),
|
|
3924
|
+
line: `${this.emitter.event}: ${data}`,
|
|
3925
|
+
level: "error",
|
|
3926
|
+
source: "sdk",
|
|
3927
|
+
event: this.emitter.event,
|
|
3928
|
+
logFile: "sdk",
|
|
3929
|
+
});
|
|
3930
|
+
});
|
|
3931
|
+
|
|
3932
|
+
this.emitter.on("status", (message) => {
|
|
3933
|
+
if (this.loggingEnabled) {
|
|
3934
|
+
console.log(`- ${message}`);
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3937
|
+
// Buffer status events
|
|
3938
|
+
if (message) {
|
|
3939
|
+
this._logBuffer.push({
|
|
3940
|
+
time: Date.now(),
|
|
3941
|
+
line: `- ${message}`,
|
|
3942
|
+
level: "info",
|
|
3943
|
+
source: "sdk",
|
|
3944
|
+
event: "status",
|
|
3945
|
+
logFile: "sdk",
|
|
3946
|
+
});
|
|
3947
|
+
}
|
|
3948
|
+
});
|
|
3949
|
+
|
|
3950
|
+
// Handle exit events - throw error with meaningful message instead of calling process.exit
|
|
3951
|
+
// This allows test frameworks like Vitest to properly catch and display the error
|
|
3952
|
+
this.emitter.on(events.exit, (exitCode) => {
|
|
3953
|
+
if (exitCode !== 0) {
|
|
3954
|
+
// Create an error with the fatal error message if available
|
|
3955
|
+
const errorMessage = lastFatalError || "TestDriver fatal error";
|
|
3956
|
+
const error = new Error(errorMessage);
|
|
3957
|
+
error.name = "TestDriverFatalError";
|
|
3958
|
+
error.exitCode = exitCode;
|
|
3959
|
+
throw error;
|
|
3960
|
+
}
|
|
3961
|
+
});
|
|
3962
|
+
|
|
3963
|
+
// Handle show window events for sandbox visualization
|
|
3964
|
+
this.emitter.on("show-window", async (url) => {
|
|
3965
|
+
if (this.loggingEnabled) {
|
|
3966
|
+
console.log("");
|
|
3967
|
+
console.log("🔗 Live test execution:");
|
|
3968
|
+
console.log(url);
|
|
3969
|
+
if (!this.config.CI) {
|
|
3970
|
+
await this._openBrowser(url);
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
});
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3976
|
+
/**
|
|
3977
|
+
* Open URL in default browser
|
|
3978
|
+
* @private
|
|
3979
|
+
* @param {string} url - URL to open
|
|
3980
|
+
*/
|
|
3981
|
+
async _openBrowser(url) {
|
|
3982
|
+
try {
|
|
3983
|
+
// Use dynamic import for the 'open' package (ES module)
|
|
3984
|
+
const { default: open } = await import("open");
|
|
3985
|
+
|
|
3986
|
+
// Open the browser
|
|
3987
|
+
await open(url, {
|
|
3988
|
+
wait: false,
|
|
3989
|
+
});
|
|
3990
|
+
} catch (error) {
|
|
3991
|
+
const { events } = require("./agent/events.js");
|
|
3992
|
+
this.emitter.emit(
|
|
3993
|
+
events.log.log,
|
|
3994
|
+
`Failed to open browser automatically: ${error.message}`,
|
|
3995
|
+
);
|
|
3996
|
+
this.emitter.emit(events.log.log, `Please manually open: ${url}`);
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
/**
|
|
4001
|
+
* Initialize debugger server
|
|
4002
|
+
* @private
|
|
4003
|
+
*/
|
|
4004
|
+
async _initializeDebugger() {
|
|
4005
|
+
// Debugger UI is now hosted on the web app (console.testdriver.ai/debugger/)
|
|
4006
|
+
// No local debugger server needed — the agent builds the URL at render time.
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
// ====================================
|
|
4010
|
+
// Test Recording Methods
|
|
4011
|
+
// ====================================
|
|
4012
|
+
|
|
4013
|
+
/**
|
|
4014
|
+
* Create a new test run to track test execution
|
|
4015
|
+
*
|
|
4016
|
+
* @param {Object} options - Test run configuration
|
|
4017
|
+
* @param {string} options.runId - Unique identifier for this test run
|
|
4018
|
+
* @param {string} options.suiteName - Name of the test suite
|
|
4019
|
+
* @param {string} [options.platform] - Platform (windows/mac/linux)
|
|
4020
|
+
* @param {string} [options.sandboxId] - Sandbox ID (auto-detected from session if not provided)
|
|
4021
|
+
* @param {Object} [options.ci] - CI/CD metadata
|
|
4022
|
+
* @param {Object} [options.git] - Git metadata
|
|
4023
|
+
* @param {Object} [options.env] - Environment metadata
|
|
4024
|
+
* @returns {Promise<Object>} Created test run
|
|
4025
|
+
*
|
|
4026
|
+
* @example
|
|
4027
|
+
* const testRun = await client.createTestRun({
|
|
4028
|
+
* runId: 'unique-run-id',
|
|
4029
|
+
* suiteName: 'My Test Suite',
|
|
4030
|
+
* platform: 'windows',
|
|
4031
|
+
* git: {
|
|
4032
|
+
* repo: 'myorg/myrepo',
|
|
4033
|
+
* branch: 'main',
|
|
4034
|
+
* commit: 'abc123'
|
|
4035
|
+
* }
|
|
4036
|
+
* });
|
|
4037
|
+
*/
|
|
4038
|
+
async createTestRun(options) {
|
|
4039
|
+
this._ensureConnected();
|
|
4040
|
+
|
|
4041
|
+
const { createSDK } = require("./agent/lib/sdk.js");
|
|
4042
|
+
const sdk = createSDK(
|
|
4043
|
+
this.emitter,
|
|
4044
|
+
this.config,
|
|
4045
|
+
this.agent.sessionInstance,
|
|
4046
|
+
);
|
|
4047
|
+
await sdk.auth();
|
|
4048
|
+
|
|
4049
|
+
const platform = options.platform || this.config.TD_PLATFORM || "linux";
|
|
4050
|
+
|
|
4051
|
+
// Auto-detect sandbox ID from the active sandbox if not provided
|
|
4052
|
+
// For E2B (Linux), the instance has sandboxId; for AWS (Windows), it has instanceId
|
|
4053
|
+
const sandboxId =
|
|
4054
|
+
options.sandboxId ||
|
|
4055
|
+
this.instance?.sandboxId ||
|
|
4056
|
+
this.instance?.instanceId ||
|
|
4057
|
+
this.agent?.sandboxId ||
|
|
4058
|
+
null;
|
|
4059
|
+
|
|
4060
|
+
// Get or create session ID using the agent's newSession method
|
|
4061
|
+
let sessionId = this.agent?.sessionInstance?.get() || null;
|
|
4062
|
+
|
|
4063
|
+
const data = {
|
|
4064
|
+
runId: options.runId,
|
|
4065
|
+
suiteName: options.suiteName,
|
|
4066
|
+
platform,
|
|
4067
|
+
sandboxId: sandboxId,
|
|
4068
|
+
sessionId: sessionId,
|
|
4069
|
+
// CI/CD
|
|
4070
|
+
ciProvider: options.ci?.provider,
|
|
4071
|
+
ciRunId: options.ci?.runId,
|
|
4072
|
+
ciJobId: options.ci?.jobId,
|
|
4073
|
+
ciUrl: options.ci?.url,
|
|
4074
|
+
// Git
|
|
4075
|
+
repo: options.git?.repo,
|
|
4076
|
+
branch: options.git?.branch,
|
|
4077
|
+
commit: options.git?.commit,
|
|
4078
|
+
commitMessage: options.git?.commitMessage,
|
|
4079
|
+
author: options.git?.author,
|
|
4080
|
+
// Environment
|
|
4081
|
+
nodeVersion: options.env?.nodeVersion || process.version,
|
|
4082
|
+
testDriverVersion:
|
|
4083
|
+
options.env?.testDriverVersion || require("./package.json").version,
|
|
4084
|
+
vitestVersion: options.env?.vitestVersion,
|
|
4085
|
+
environment: options.env?.additional,
|
|
4086
|
+
};
|
|
4087
|
+
|
|
4088
|
+
const result = await sdk.req("/api/v1/testdriver/test-run-create", data);
|
|
4089
|
+
return result.data;
|
|
4090
|
+
}
|
|
4091
|
+
|
|
4092
|
+
/**
|
|
4093
|
+
* Complete a test run and update final statistics
|
|
4094
|
+
*
|
|
4095
|
+
* @param {Object} options - Test run completion data
|
|
4096
|
+
* @param {string} options.runId - Test run ID
|
|
4097
|
+
* @param {string} options.status - Final status (passed/failed/cancelled)
|
|
4098
|
+
* @param {number} [options.totalTests] - Total number of tests
|
|
4099
|
+
* @param {number} [options.passedTests] - Number of passed tests
|
|
4100
|
+
* @param {number} [options.failedTests] - Number of failed tests
|
|
4101
|
+
* @param {number} [options.skippedTests] - Number of skipped tests
|
|
4102
|
+
* @returns {Promise<Object>} Updated test run
|
|
4103
|
+
*
|
|
4104
|
+
* @example
|
|
4105
|
+
* await client.completeTestRun({
|
|
4106
|
+
* runId: 'unique-run-id',
|
|
4107
|
+
* status: 'passed',
|
|
4108
|
+
* totalTests: 10,
|
|
4109
|
+
* passedTests: 10,
|
|
4110
|
+
* failedTests: 0
|
|
4111
|
+
* });
|
|
4112
|
+
*/
|
|
4113
|
+
async completeTestRun(options) {
|
|
4114
|
+
this._ensureConnected();
|
|
4115
|
+
|
|
4116
|
+
const { createSDK } = require("./agent/lib/sdk.js");
|
|
4117
|
+
const sdk = createSDK(
|
|
4118
|
+
this.emitter,
|
|
4119
|
+
this.config,
|
|
4120
|
+
this.agent.sessionInstance,
|
|
4121
|
+
);
|
|
4122
|
+
await sdk.auth();
|
|
4123
|
+
|
|
4124
|
+
const result = await sdk.req(
|
|
4125
|
+
"/api/v1/testdriver/test-run-complete",
|
|
4126
|
+
options,
|
|
4127
|
+
);
|
|
4128
|
+
return result.data;
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
/**
|
|
4132
|
+
* Record a test case result
|
|
4133
|
+
*
|
|
4134
|
+
* @param {Object} options - Test case data
|
|
4135
|
+
* @param {string} options.runId - Test run ID
|
|
4136
|
+
* @param {string} options.testName - Name of the test
|
|
4137
|
+
* @param {string} options.testFile - Path to test file
|
|
4138
|
+
* @param {string} options.status - Test status (passed/failed/skipped/pending)
|
|
4139
|
+
* @param {string} [options.suiteName] - Test suite/describe block name
|
|
4140
|
+
* @param {number} [options.duration] - Test duration in ms
|
|
4141
|
+
* @param {string} [options.errorMessage] - Error message if failed
|
|
4142
|
+
* @param {string} [options.errorStack] - Error stack trace if failed
|
|
4143
|
+
* @param {string} [options.replayUrl] - Dashcam replay URL
|
|
4144
|
+
* @param {number} [options.replayStartTime] - Start time in replay
|
|
4145
|
+
* @param {number} [options.replayEndTime] - End time in replay
|
|
4146
|
+
* @returns {Promise<Object>} Created/updated test case
|
|
4147
|
+
*
|
|
4148
|
+
* @example
|
|
4149
|
+
* await client.recordTestCase({
|
|
4150
|
+
* runId: 'unique-run-id',
|
|
4151
|
+
* testName: 'should login successfully',
|
|
4152
|
+
* testFile: 'tests/login.test.js',
|
|
4153
|
+
* status: 'passed',
|
|
4154
|
+
* duration: 1500,
|
|
4155
|
+
* replayUrl: 'https://app.dashcam.io/replay/abc123'
|
|
4156
|
+
* });
|
|
4157
|
+
*/
|
|
4158
|
+
async recordTestCase(options) {
|
|
4159
|
+
this._ensureConnected();
|
|
4160
|
+
|
|
4161
|
+
const { createSDK } = require("./agent/lib/sdk.js");
|
|
4162
|
+
const sdk = createSDK(
|
|
4163
|
+
this.emitter,
|
|
4164
|
+
this.config,
|
|
4165
|
+
this.agent.sessionInstance,
|
|
4166
|
+
);
|
|
4167
|
+
await sdk.auth();
|
|
4168
|
+
|
|
4169
|
+
const result = await sdk.req(
|
|
4170
|
+
"/api/v1/testdriver/test-case-create",
|
|
4171
|
+
options,
|
|
4172
|
+
);
|
|
4173
|
+
return result.data;
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
// ====================================
|
|
4177
|
+
// AI Methods (Exploratory Loop)
|
|
4178
|
+
// ====================================
|
|
4179
|
+
|
|
4180
|
+
/**
|
|
4181
|
+
* Execute a natural language task using AI
|
|
4182
|
+
* This is the SDK equivalent of the CLI's exploratory loop
|
|
4183
|
+
*
|
|
4184
|
+
* @param {string} task - Natural language description of what to do
|
|
4185
|
+
* @param {Object} [options] - Execution options
|
|
4186
|
+
* @param {number} [options.tries=7] - Maximum number of check/retry attempts before giving up
|
|
4187
|
+
* @returns {Promise<ActResult>} Result object with success status and details
|
|
4188
|
+
* @throws {AIError} When the task fails after all tries are exhausted
|
|
4189
|
+
*
|
|
4190
|
+
* @typedef {Object} ActResult
|
|
4191
|
+
* @property {boolean} success - Whether the task completed successfully
|
|
4192
|
+
* @property {string} task - The original task that was executed
|
|
4193
|
+
* @property {number} tries - Number of check attempts made
|
|
4194
|
+
* @property {number} maxTries - Maximum tries that were allowed
|
|
4195
|
+
* @property {number} duration - Total execution time in milliseconds
|
|
4196
|
+
* @property {string} [response] - AI's final response if available
|
|
4197
|
+
*
|
|
4198
|
+
* @example
|
|
4199
|
+
* // Simple execution
|
|
4200
|
+
* const result = await client.act('Click the submit button');
|
|
4201
|
+
* console.log(result.success); // true
|
|
4202
|
+
*
|
|
4203
|
+
* @example
|
|
4204
|
+
* // With custom retry limit
|
|
4205
|
+
* const result = await client.act('Fill out the contact form', { tries: 10 });
|
|
4206
|
+
* console.log(`Completed in ${result.tries} tries`);
|
|
4207
|
+
*
|
|
4208
|
+
* @example
|
|
4209
|
+
* // Handle failures
|
|
4210
|
+
* try {
|
|
4211
|
+
* await client.act('Complete the checkout process', { tries: 3 });
|
|
4212
|
+
* } catch (error) {
|
|
4213
|
+
* console.log(`Failed after ${error.tries} tries: ${error.message}`);
|
|
4214
|
+
* }
|
|
4215
|
+
*/
|
|
4216
|
+
async act(task, options = {}) {
|
|
4217
|
+
this._ensureConnected();
|
|
4218
|
+
|
|
4219
|
+
const { tries = 7 } = options;
|
|
4220
|
+
|
|
4221
|
+
this.analytics.track("sdk.act", { task, tries });
|
|
4222
|
+
|
|
4223
|
+
const { events } = require("./agent/events.js");
|
|
4224
|
+
const startTime = Date.now();
|
|
4225
|
+
|
|
4226
|
+
// Store original checkLimit and set custom one if provided
|
|
4227
|
+
const originalCheckLimit = this.agent.checkLimit;
|
|
4228
|
+
this.agent.checkLimit = tries;
|
|
4229
|
+
|
|
4230
|
+
// Reset check count for this act() call
|
|
4231
|
+
const originalCheckCount = this.agent.checkCount;
|
|
4232
|
+
this.agent.checkCount = 0;
|
|
4233
|
+
|
|
4234
|
+
// Enable soft assert mode so check-phase assertions don't throw
|
|
4235
|
+
const originalSoftAssertMode = this.agent.softAssertMode;
|
|
4236
|
+
this.agent.softAssertMode = true;
|
|
4237
|
+
|
|
4238
|
+
// Emit scoped start marker for ai()
|
|
4239
|
+
this.emitter.emit(events.log.log, formatter.formatAIStart(task));
|
|
4240
|
+
|
|
4241
|
+
try {
|
|
4242
|
+
// Use the agent's exploratoryLoop method directly
|
|
4243
|
+
const response = await this.agent.exploratoryLoop(
|
|
4244
|
+
task,
|
|
4245
|
+
false,
|
|
4246
|
+
true,
|
|
4247
|
+
false,
|
|
4248
|
+
);
|
|
4249
|
+
|
|
4250
|
+
const duration = Date.now() - startTime;
|
|
4251
|
+
const triesUsed = this.agent.checkCount;
|
|
4252
|
+
|
|
4253
|
+
this.emitter.emit(
|
|
4254
|
+
events.log.log,
|
|
4255
|
+
formatter.formatAIComplete(duration, true),
|
|
4256
|
+
);
|
|
4257
|
+
|
|
4258
|
+
// Restore original state
|
|
4259
|
+
this.agent.checkLimit = originalCheckLimit;
|
|
4260
|
+
this.agent.checkCount = originalCheckCount;
|
|
4261
|
+
this.agent.softAssertMode = originalSoftAssertMode;
|
|
4262
|
+
|
|
4263
|
+
return {
|
|
4264
|
+
success: true,
|
|
4265
|
+
task,
|
|
4266
|
+
tries: triesUsed,
|
|
4267
|
+
maxTries: tries,
|
|
4268
|
+
duration,
|
|
4269
|
+
response: response || undefined,
|
|
4270
|
+
};
|
|
4271
|
+
} catch (error) {
|
|
4272
|
+
const duration = Date.now() - startTime;
|
|
4273
|
+
const triesUsed = this.agent.checkCount;
|
|
4274
|
+
|
|
4275
|
+
this.emitter.emit(
|
|
4276
|
+
events.log.log,
|
|
4277
|
+
formatter.formatAIComplete(duration, false, error.message),
|
|
4278
|
+
);
|
|
4279
|
+
|
|
4280
|
+
// Restore original state
|
|
4281
|
+
this.agent.checkLimit = originalCheckLimit;
|
|
4282
|
+
this.agent.checkCount = originalCheckCount;
|
|
4283
|
+
this.agent.softAssertMode = originalSoftAssertMode;
|
|
4284
|
+
|
|
4285
|
+
// Create an enhanced error with additional context using AIError class
|
|
4286
|
+
throw new AIError(`AI failed: ${error.message}`, {
|
|
4287
|
+
task,
|
|
4288
|
+
tries: triesUsed,
|
|
4289
|
+
maxTries: tries,
|
|
4290
|
+
duration,
|
|
4291
|
+
cause: error,
|
|
4292
|
+
});
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
/**
|
|
4297
|
+
* @deprecated Use act() instead
|
|
4298
|
+
* Execute a natural language task using AI
|
|
4299
|
+
*
|
|
4300
|
+
* @param {string} task - Natural language description of what to do
|
|
4301
|
+
* @param {Object} [options] - Execution options
|
|
4302
|
+
* @param {number} [options.tries=7] - Maximum number of check/retry attempts
|
|
4303
|
+
* @returns {Promise<ActResult>} Result object with success status and details
|
|
4304
|
+
*/
|
|
4305
|
+
async ai(task, options) {
|
|
4306
|
+
return await this.act(task, options);
|
|
4307
|
+
}
|
|
4308
|
+
|
|
4309
|
+
/**
|
|
4310
|
+
* Get buffered logs as a JSONL string for upload.
|
|
4311
|
+
* Each line is a JSON object with { time, line, level, source, event }.
|
|
4312
|
+
* @returns {string} JSONL-formatted log data
|
|
4313
|
+
*/
|
|
4314
|
+
getLogs() {
|
|
4315
|
+
if (this._logBuffer.length === 0) return "";
|
|
4316
|
+
const startTime = this._logBuffer[0].time;
|
|
4317
|
+
return this._logBuffer
|
|
4318
|
+
.map((entry) => JSON.stringify({ ...entry, time: entry.time - startTime }))
|
|
4319
|
+
.join("\n");
|
|
4320
|
+
}
|
|
4321
|
+
|
|
4322
|
+
/**
|
|
4323
|
+
* Clear the internal log buffer.
|
|
4324
|
+
*/
|
|
4325
|
+
clearLogs() {
|
|
4326
|
+
this._logBuffer = [];
|
|
4327
|
+
}
|
|
4328
|
+
}
|
|
4329
|
+
|
|
4330
|
+
// Expose SDK version as a static property for use by vitest hooks/plugins
|
|
4331
|
+
TestDriverSDK.version = require("./package.json").version;
|
|
4332
|
+
|
|
4333
|
+
module.exports = TestDriverSDK;
|
|
4334
|
+
module.exports.Element = Element;
|
|
4335
|
+
module.exports.ElementNotFoundError = ElementNotFoundError;
|
|
4336
|
+
module.exports.AIError = AIError;
|