@testdriverai/agent 7.8.0-test.38
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/agent/index.js
ADDED
|
@@ -0,0 +1,2450 @@
|
|
|
1
|
+
// disable depreciation warnings
|
|
2
|
+
process.removeAllListeners("warning");
|
|
3
|
+
|
|
4
|
+
// package.json is included to get the version number
|
|
5
|
+
const packageJson = require("../package.json");
|
|
6
|
+
|
|
7
|
+
// nodejs modules
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
|
|
11
|
+
// third party modules
|
|
12
|
+
const path = require("path");
|
|
13
|
+
const yaml = require("js-yaml");
|
|
14
|
+
const sanitizeFilename = require("sanitize-filename");
|
|
15
|
+
const { EventEmitter2 } = require("eventemitter2");
|
|
16
|
+
const diff = require("diff");
|
|
17
|
+
|
|
18
|
+
// global utilities
|
|
19
|
+
const generator = require("./lib/generator.js");
|
|
20
|
+
const theme = require("./lib/theme.js");
|
|
21
|
+
const SourceMapper = require("./lib/source-mapper.js");
|
|
22
|
+
|
|
23
|
+
// agent modules
|
|
24
|
+
const { createParser } = require("./lib/parser.js");
|
|
25
|
+
const { createSystem } = require("./lib/system.js");
|
|
26
|
+
const { createCommander } = require("./lib/commander.js");
|
|
27
|
+
const { createCommands } = require("./lib/commands.js");
|
|
28
|
+
const { createSandbox } = require("./lib/sandbox.js");
|
|
29
|
+
const { createCommandDefinitions } = require("./interface.js");
|
|
30
|
+
const { createSDK } = require("./lib/sdk.js");
|
|
31
|
+
const { createConfig } = require("./lib/config.js");
|
|
32
|
+
const { createAnalytics } = require("./lib/analytics.js");
|
|
33
|
+
const { createSession } = require("./lib/session.js");
|
|
34
|
+
const { createOutputs } = require("./lib/outputs.js");
|
|
35
|
+
|
|
36
|
+
const isValidVersion = require("./lib/valid-version.js");
|
|
37
|
+
const { events, createEmitter } = require("./events.js");
|
|
38
|
+
const logger = require("./lib/logger.js");
|
|
39
|
+
|
|
40
|
+
class TestDriverAgent extends EventEmitter2 {
|
|
41
|
+
constructor(environment = {}, cliArgs = {}) {
|
|
42
|
+
super({
|
|
43
|
+
wildcard: true,
|
|
44
|
+
delimiter: ":",
|
|
45
|
+
newListener: false,
|
|
46
|
+
removeListener: false,
|
|
47
|
+
maxListeners: 20,
|
|
48
|
+
verboseMemoryLeak: false,
|
|
49
|
+
ignoreErrors: false,
|
|
50
|
+
}); // Create the agent's own emitter for internal events
|
|
51
|
+
this.emitter = createEmitter();
|
|
52
|
+
|
|
53
|
+
// Create config instance for this agent using provided environment
|
|
54
|
+
this.config = createConfig(environment);
|
|
55
|
+
|
|
56
|
+
// Store CLI arguments passed to this agent
|
|
57
|
+
this.cliArgs = cliArgs;
|
|
58
|
+
|
|
59
|
+
// Derive properties from cliArgs
|
|
60
|
+
const flags = cliArgs.options || {};
|
|
61
|
+
const firstArg = cliArgs.args && cliArgs.args[0];
|
|
62
|
+
|
|
63
|
+
// All commands (run, edit, generate) use the same pattern:
|
|
64
|
+
// first argument is the main file to work with
|
|
65
|
+
this.thisFile = firstArg || this.config.TD_DEFAULT_TEST_FILE;
|
|
66
|
+
|
|
67
|
+
this.resultFile = flags.resultFile || null;
|
|
68
|
+
this.newSandbox = flags.newSandbox || false;
|
|
69
|
+
this.healMode = flags.healMode || flags.heal || false;
|
|
70
|
+
this.sandboxId = flags["sandbox-id"] || null;
|
|
71
|
+
this.sandboxAmi = flags["sandbox-ami"] || null;
|
|
72
|
+
this.sandboxInstance = flags["sandbox-instance"] || null;
|
|
73
|
+
this.sandboxOs = flags.os || "linux";
|
|
74
|
+
this.ip = flags.ip || null;
|
|
75
|
+
this.workingDir = flags.workingDir || process.cwd();
|
|
76
|
+
|
|
77
|
+
// Resolve thisFile to absolute path with proper extension
|
|
78
|
+
if (this.thisFile) {
|
|
79
|
+
if (this.thisFile === ".") {
|
|
80
|
+
this.thisFile = path.join(this.workingDir, "testdriver.yaml");
|
|
81
|
+
} else {
|
|
82
|
+
this.thisFile = path.join(this.workingDir, this.thisFile);
|
|
83
|
+
if (
|
|
84
|
+
!this.thisFile.endsWith(".yaml") &&
|
|
85
|
+
!this.thisFile.endsWith(".yml")
|
|
86
|
+
) {
|
|
87
|
+
this.thisFile += ".yaml";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Create parser instance with this agent's emitter
|
|
93
|
+
this.parser = createParser(this.emitter);
|
|
94
|
+
|
|
95
|
+
// Create session instance for this agent
|
|
96
|
+
this.session = createSession();
|
|
97
|
+
|
|
98
|
+
// Create outputs instance for this agent
|
|
99
|
+
this.outputs = createOutputs();
|
|
100
|
+
|
|
101
|
+
// Create SDK instance with this agent's emitter, config, session, and abort signal
|
|
102
|
+
this.sdk = createSDK(this.emitter, this.config, this.session);
|
|
103
|
+
|
|
104
|
+
// Create analytics instance with this agent's emitter, config, and session
|
|
105
|
+
this.analytics = createAnalytics(this.emitter, this.config, this.session);
|
|
106
|
+
|
|
107
|
+
// Create sandbox instance with this agent's emitter, analytics, and session
|
|
108
|
+
this.sandbox = createSandbox(this.emitter, this.analytics, this.session);
|
|
109
|
+
|
|
110
|
+
// Attach Sentry log listeners to capture CLI logs as breadcrumbs
|
|
111
|
+
const sentry = require("../lib/sentry");
|
|
112
|
+
sentry.attachLogListeners(this.emitter);
|
|
113
|
+
|
|
114
|
+
// Set the OS for the sandbox to use
|
|
115
|
+
this.sandbox.os = this.sandboxOs;
|
|
116
|
+
|
|
117
|
+
// Create system instance with emitter, sandbox and config
|
|
118
|
+
this.system = createSystem(this.emitter, this.sandbox, this.config);
|
|
119
|
+
|
|
120
|
+
// Create commands instance with this agent's emitter and system
|
|
121
|
+
const commandsResult = createCommands(
|
|
122
|
+
this.emitter,
|
|
123
|
+
this.system,
|
|
124
|
+
this.sandbox,
|
|
125
|
+
this.config,
|
|
126
|
+
this.session,
|
|
127
|
+
() => this.sourceMapper.currentFilePath || this.thisFile,
|
|
128
|
+
this.cliArgs.options.redrawThreshold,
|
|
129
|
+
null, // getDashcamElapsedTime - will be set by SDK when dashcam is available
|
|
130
|
+
() => this.softAssertMode, // getter for soft assert mode (used by act())
|
|
131
|
+
);
|
|
132
|
+
this.commands = commandsResult.commands;
|
|
133
|
+
this.redraw = commandsResult.redraw;
|
|
134
|
+
|
|
135
|
+
// Create commander instance with this agent's emitter and commands
|
|
136
|
+
this.commander = createCommander(
|
|
137
|
+
this.emitter,
|
|
138
|
+
this.commands,
|
|
139
|
+
this.analytics,
|
|
140
|
+
this.config,
|
|
141
|
+
this.outputs,
|
|
142
|
+
this.session,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// these are "in-memory" globals
|
|
146
|
+
// they represent the current state of the agent
|
|
147
|
+
this.lastPrompt = ""; // the last prompt to be input
|
|
148
|
+
this.executionHistory = []; // a history of commands run in the current session
|
|
149
|
+
this.errorCounts = {}; // counts of different errors encountered in this session
|
|
150
|
+
this.errorLimit = 3; // the max number of times an error can be encountered before exiting
|
|
151
|
+
this.checkCount = 0; // the number of times the AI has checked the task
|
|
152
|
+
this.checkLimit = 7; // the max number of times the AI can check the task before exiting
|
|
153
|
+
this.lastScreenshot = null; // the last screenshot taken by the agent
|
|
154
|
+
this.readlineInterface = null; // the readline interface for interactive mode
|
|
155
|
+
this.tasks = []; // list of prompts that the user has given us
|
|
156
|
+
this.hasRunPostrun = false; // whether the postrun lifecycle has been run. prevents infinite loops
|
|
157
|
+
|
|
158
|
+
this.lastCommand = new Date().getTime();
|
|
159
|
+
this.csv = [["command,time"]];
|
|
160
|
+
|
|
161
|
+
// Source mapping for YAML files
|
|
162
|
+
this.sourceMapper = new SourceMapper();
|
|
163
|
+
|
|
164
|
+
// temporary file for command history
|
|
165
|
+
this.commandHistoryFile = path.join(os.homedir(), ".testdriver_history");
|
|
166
|
+
|
|
167
|
+
// Flag to indicate if the agent should stop execution
|
|
168
|
+
this.stopped = false;
|
|
169
|
+
|
|
170
|
+
// Flag to suppress assertion throws (used by act() to make check-phase assertions non-fatal)
|
|
171
|
+
this.softAssertMode = false;
|
|
172
|
+
|
|
173
|
+
this.emitter.emit(events.log.log, JSON.stringify(environment));
|
|
174
|
+
this.emitter.emit(events.log.log, JSON.stringify(cliArgs));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Stop method to immediately halt execution
|
|
178
|
+
stop() {
|
|
179
|
+
this.stopped = true;
|
|
180
|
+
this.emitter.emit(
|
|
181
|
+
events.log.narration,
|
|
182
|
+
theme.dim("stopping execution..."),
|
|
183
|
+
true,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// single function to handle all program exits
|
|
188
|
+
// allows us to save the current state, run lifecycle hooks, and track analytics
|
|
189
|
+
async exit(failed = true, shouldSave = false, shouldRunPostrun = false) {
|
|
190
|
+
const { formatter } = require("../sdk-log-formatter.js");
|
|
191
|
+
this.emitter.emit(
|
|
192
|
+
events.log.narration,
|
|
193
|
+
formatter.getPrefix("disconnect") +
|
|
194
|
+
" " +
|
|
195
|
+
theme.yellow.bold("Exiting") +
|
|
196
|
+
theme.dim("..."),
|
|
197
|
+
true,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Clean up redraw interval
|
|
201
|
+
if (this.redraw && this.redraw.cleanup) {
|
|
202
|
+
this.redraw.cleanup();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Close sandbox connection to release the connection slot
|
|
206
|
+
if (this.sandbox) {
|
|
207
|
+
try {
|
|
208
|
+
this.sandbox.close();
|
|
209
|
+
} catch (err) {
|
|
210
|
+
// Ignore sandbox close errors during exit
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
shouldRunPostrun =
|
|
215
|
+
!this.hasRunPostrun &&
|
|
216
|
+
(shouldRunPostrun || this.cliArgs?.command == "run");
|
|
217
|
+
|
|
218
|
+
if (shouldSave) {
|
|
219
|
+
await this.save();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
this.analytics.track("exit", { failed });
|
|
223
|
+
|
|
224
|
+
if (shouldRunPostrun) {
|
|
225
|
+
this.hasRunPostrun = true;
|
|
226
|
+
await this.runLifecycle("postrun");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Emit exit event with exit code and close readline interface
|
|
230
|
+
this.readlineInterface?.close();
|
|
231
|
+
this.emitter.emit(events.exit, failed ? 1 : 0);
|
|
232
|
+
|
|
233
|
+
// we purposly never resolve this promise so the process will hang
|
|
234
|
+
return new Promise(() => {
|
|
235
|
+
// The process exit should be handled by the base/entry point listening to the exit event
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// fatal errors always exit the program
|
|
240
|
+
// this ensure we log the error, summarize it, and exit cleanly
|
|
241
|
+
async dieOnFatal(error, skipPostrun = false) {
|
|
242
|
+
// Show error with source context if available
|
|
243
|
+
const errorContext = this.sourceMapper.getErrorWithSourceContext(error);
|
|
244
|
+
if (errorContext) {
|
|
245
|
+
this.emitter.emit(events.error.fatal, errorContext);
|
|
246
|
+
} else {
|
|
247
|
+
this.emitter.emit(events.error.fatal, error);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (skipPostrun) {
|
|
251
|
+
return await this.exit(true);
|
|
252
|
+
} else {
|
|
253
|
+
try {
|
|
254
|
+
await this.summarize(error.message);
|
|
255
|
+
} catch (summarizeError) {
|
|
256
|
+
// If summarization fails, log it but don't let it prevent postrun from running
|
|
257
|
+
this.emitter.emit(
|
|
258
|
+
events.log.warn,
|
|
259
|
+
theme.yellow(`Failed to summarize: ${summarizeError.message}`),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
// Always run postrun lifecycle script, even for fatal errors
|
|
263
|
+
return await this.exit(true, false, true);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// creates a new "thread" in which the AI is given an error
|
|
268
|
+
// and responds. notice `actOnMarkdown` which will continue
|
|
269
|
+
// the thread until there are no more codeblocks to execute
|
|
270
|
+
async haveAIResolveError(
|
|
271
|
+
error,
|
|
272
|
+
markdown,
|
|
273
|
+
depth = 0,
|
|
274
|
+
undo = true,
|
|
275
|
+
shouldSave,
|
|
276
|
+
) {
|
|
277
|
+
// healMode must be required to attempt to recover from errors
|
|
278
|
+
// otherwise we go directly to fatal
|
|
279
|
+
if (!this.healMode) {
|
|
280
|
+
this.emitter.emit(
|
|
281
|
+
events.error.general,
|
|
282
|
+
theme.red("Error detected, but recovery mode is not enabled."),
|
|
283
|
+
);
|
|
284
|
+
this.emitter.emit(
|
|
285
|
+
events.log.log,
|
|
286
|
+
"To attempt automatic recovery, re-run with the --heal flag.",
|
|
287
|
+
);
|
|
288
|
+
return await this.dieOnFatal(error);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (error.fatal) {
|
|
292
|
+
return await this.dieOnFatal(error);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Get error message
|
|
296
|
+
let eMessage = error.message ? error.message : error;
|
|
297
|
+
|
|
298
|
+
// Truncate error message if too long to prevent 400 errors from API
|
|
299
|
+
// Keep first 5000 characters as a reasonable limit for API payloads
|
|
300
|
+
const MAX_ERROR_LENGTH = 5000;
|
|
301
|
+
if (typeof eMessage === "string" && eMessage.length > MAX_ERROR_LENGTH) {
|
|
302
|
+
eMessage =
|
|
303
|
+
eMessage.substring(0, MAX_ERROR_LENGTH) +
|
|
304
|
+
"\n\n[Error message truncated - message was too long]";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// we sanitize the error message to use it as a key in the errorCounts object
|
|
308
|
+
let safeKey = JSON.stringify(error.message ? error.message : error);
|
|
309
|
+
this.errorCounts[safeKey] = this.errorCounts[safeKey]
|
|
310
|
+
? this.errorCounts[safeKey] + 1
|
|
311
|
+
: 1;
|
|
312
|
+
|
|
313
|
+
this.emitter.emit(
|
|
314
|
+
events.log.warn,
|
|
315
|
+
theme.red("Error detected. Attempting to recover (via --heal)..."),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// Show error with source context if available
|
|
319
|
+
const errorContext = this.sourceMapper.getErrorWithSourceContext(error);
|
|
320
|
+
if (errorContext) {
|
|
321
|
+
this.emitter.emit(events.log.warn, errorContext);
|
|
322
|
+
} else {
|
|
323
|
+
this.emitter.emit(events.log.markdown.static, eMessage);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
this.emitter.emit(events.log.debug, error);
|
|
327
|
+
this.emitter.emit(events.log.debug, error.stack);
|
|
328
|
+
|
|
329
|
+
// if we get the same error 3 times in `run` mode, we exit
|
|
330
|
+
if (this.errorCounts[safeKey] > this.errorLimit - 1) {
|
|
331
|
+
this.emitter.emit(
|
|
332
|
+
events.log.log,
|
|
333
|
+
theme.red("Error loop detected. Exiting."),
|
|
334
|
+
);
|
|
335
|
+
this.emitter.emit(events.log.log, this.getErrorWithPosition(error));
|
|
336
|
+
await this.summarize(eMessage);
|
|
337
|
+
return await this.exit(true);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// remove this step from the execution history
|
|
341
|
+
if (undo) {
|
|
342
|
+
await this.popFromHistory();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ask the AI what to do
|
|
346
|
+
let image;
|
|
347
|
+
if (error.attachScreenshot) {
|
|
348
|
+
image = await this.system.captureScreenBase64();
|
|
349
|
+
} else {
|
|
350
|
+
image = null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
this.emitter.emit(events.log.narration, theme.dim("thinking..."), true);
|
|
354
|
+
|
|
355
|
+
const streamId = `error-${Date.now()}`;
|
|
356
|
+
this.emitter.emit(events.log.markdown.start, streamId);
|
|
357
|
+
|
|
358
|
+
// Truncate markdown if too long to prevent 400 errors
|
|
359
|
+
const MAX_MARKDOWN_LENGTH = 10000;
|
|
360
|
+
let truncatedMarkdown = markdown;
|
|
361
|
+
if (typeof markdown === "string" && markdown.length > MAX_MARKDOWN_LENGTH) {
|
|
362
|
+
truncatedMarkdown =
|
|
363
|
+
markdown.substring(0, MAX_MARKDOWN_LENGTH) +
|
|
364
|
+
"\n\n[Markdown truncated - content was too long]";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let response;
|
|
368
|
+
try {
|
|
369
|
+
response = await this.sdk.req(
|
|
370
|
+
"error",
|
|
371
|
+
{
|
|
372
|
+
description: eMessage,
|
|
373
|
+
markdown: truncatedMarkdown,
|
|
374
|
+
image,
|
|
375
|
+
},
|
|
376
|
+
(chunk) => {
|
|
377
|
+
if (chunk.type === "data" && chunk.data) {
|
|
378
|
+
this.emitter.emit(events.log.markdown.chunk, streamId, chunk.data);
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
} catch (apiError) {
|
|
383
|
+
// If the error API call itself fails, prevent infinite loop
|
|
384
|
+
// by not retrying and instead treating as fatal
|
|
385
|
+
this.emitter.emit(
|
|
386
|
+
events.log.error,
|
|
387
|
+
theme.red(`Failed to get AI error resolution: ${apiError.message}`),
|
|
388
|
+
);
|
|
389
|
+
this.emitter.emit(events.log.log, "Original error: " + eMessage);
|
|
390
|
+
return await this.dieOnFatal(error);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
this.emitter.emit(events.log.markdown.end, streamId);
|
|
394
|
+
|
|
395
|
+
// if the response worked, we try to execute the codeblocks in the response,
|
|
396
|
+
// which begins the recursive process of executing codeblocks
|
|
397
|
+
if (response?.data) {
|
|
398
|
+
return await this.actOnMarkdown(
|
|
399
|
+
response.data,
|
|
400
|
+
depth,
|
|
401
|
+
true,
|
|
402
|
+
false,
|
|
403
|
+
shouldSave,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// this is run after all possible codeblocks have been executed, but only at depth 0, which is the top level
|
|
409
|
+
// this checks that the task is "really done" using a screenshot of the desktop state
|
|
410
|
+
// it's likely that the task will not be complete and the AI will respond with more codeblocks to execute
|
|
411
|
+
async check() {
|
|
412
|
+
// Check if execution has been stopped
|
|
413
|
+
if (this.stopped) {
|
|
414
|
+
this.emitter.emit(
|
|
415
|
+
events.log.narration,
|
|
416
|
+
theme.dim("execution stopped"),
|
|
417
|
+
true,
|
|
418
|
+
);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
this.checkCount++;
|
|
423
|
+
|
|
424
|
+
if (this.checkCount >= this.checkLimit) {
|
|
425
|
+
this.emitter.emit(
|
|
426
|
+
events.log.narration,
|
|
427
|
+
theme.red("Exploratory loop detected. Exiting."),
|
|
428
|
+
);
|
|
429
|
+
await this.summarize("Check loop detected.");
|
|
430
|
+
return await this.exit(true);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
this.emitter.emit(events.log.narration, theme.dim("checking..."));
|
|
434
|
+
|
|
435
|
+
// check asks the ai if the task is complete
|
|
436
|
+
// Parallelize system calls for better performance
|
|
437
|
+
const [thisScreenshot, mousePosition, activeWindow] = await Promise.all([
|
|
438
|
+
this.system.captureScreenBase64(1, false, true),
|
|
439
|
+
this.system.getMousePosition(),
|
|
440
|
+
this.system.activeWin(),
|
|
441
|
+
]);
|
|
442
|
+
let images = [this.lastScreenshot, thisScreenshot];
|
|
443
|
+
|
|
444
|
+
let response = await this.sdk.req("check", {
|
|
445
|
+
tasks: this.tasks,
|
|
446
|
+
images,
|
|
447
|
+
mousePosition,
|
|
448
|
+
activeWindow,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Use log.log (not markdown.static) so output goes through console spy to sandbox
|
|
452
|
+
this.emitter.emit(events.log.log, response.data);
|
|
453
|
+
|
|
454
|
+
this.lastScreenshot = thisScreenshot;
|
|
455
|
+
|
|
456
|
+
return response.data;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// command is transformed from a single yml entry generated by the AI into a JSON object
|
|
460
|
+
// it is mapped via `commander` to the `commands` module so the yaml
|
|
461
|
+
// parameters can be mapped to actual functions
|
|
462
|
+
async runCommand(command, depth, shouldSave, pushToHistory) {
|
|
463
|
+
let yml = await yaml.dump(command);
|
|
464
|
+
const commandName = command.command;
|
|
465
|
+
const startTime = Date.now();
|
|
466
|
+
|
|
467
|
+
// Get current source position
|
|
468
|
+
const sourcePosition = this.sourceMapper.getCurrentSourcePosition();
|
|
469
|
+
|
|
470
|
+
// Emit command start event with source mapping
|
|
471
|
+
this.emitter.emit(events.command.start, {
|
|
472
|
+
command: commandName,
|
|
473
|
+
depth,
|
|
474
|
+
data: command,
|
|
475
|
+
timestamp: startTime,
|
|
476
|
+
sourcePosition: sourcePosition,
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// Log current execution position for debugging
|
|
480
|
+
if (this.sourceMapper.currentFileSourceMap) {
|
|
481
|
+
this.emitter.emit(events.log.log, "");
|
|
482
|
+
this.emitter.emit(
|
|
483
|
+
events.log.log,
|
|
484
|
+
theme.dim(`${this.sourceMapper.getCurrentPositionDescription()}`),
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
let response;
|
|
490
|
+
|
|
491
|
+
// "run" and "if" commands are special meta commands
|
|
492
|
+
// that change the flow of execution
|
|
493
|
+
if (command.command == "run") {
|
|
494
|
+
response = await this.embed(command.file, depth, pushToHistory);
|
|
495
|
+
} else if (command.command == "if") {
|
|
496
|
+
response = await this.iffy(
|
|
497
|
+
command.condition,
|
|
498
|
+
command.then,
|
|
499
|
+
command.else,
|
|
500
|
+
depth,
|
|
501
|
+
);
|
|
502
|
+
} else {
|
|
503
|
+
response = await this.commander.run(command, depth);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const endTime = Date.now();
|
|
507
|
+
const duration = endTime - startTime;
|
|
508
|
+
|
|
509
|
+
// Emit command success event with source mapping
|
|
510
|
+
this.emitter.emit(events.command.success, {
|
|
511
|
+
command: commandName,
|
|
512
|
+
depth,
|
|
513
|
+
data: command,
|
|
514
|
+
duration,
|
|
515
|
+
response,
|
|
516
|
+
timestamp: endTime,
|
|
517
|
+
sourcePosition: sourcePosition,
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// if the result of a command contains more commands, we perform the process again
|
|
521
|
+
if (response && typeof response === "string") {
|
|
522
|
+
return await this.actOnMarkdown(response, depth, false, false, false);
|
|
523
|
+
}
|
|
524
|
+
} catch (error) {
|
|
525
|
+
const endTime = Date.now();
|
|
526
|
+
const duration = endTime - startTime;
|
|
527
|
+
|
|
528
|
+
// Emit command error event with source mapping
|
|
529
|
+
this.emitter.emit(events.command.error, {
|
|
530
|
+
command: commandName,
|
|
531
|
+
depth,
|
|
532
|
+
data: command,
|
|
533
|
+
error: error.message,
|
|
534
|
+
duration,
|
|
535
|
+
timestamp: endTime,
|
|
536
|
+
sourcePosition: sourcePosition,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
return await this.haveAIResolveError(
|
|
540
|
+
error,
|
|
541
|
+
yaml.dump({ commands: [yml] }),
|
|
542
|
+
depth,
|
|
543
|
+
true,
|
|
544
|
+
shouldSave,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async executeCommands(
|
|
550
|
+
commands,
|
|
551
|
+
depth,
|
|
552
|
+
pushToHistory = false,
|
|
553
|
+
dry = false,
|
|
554
|
+
shouldSave = false,
|
|
555
|
+
) {
|
|
556
|
+
// Check if execution has been stopped
|
|
557
|
+
if (this.stopped) {
|
|
558
|
+
this.emitter.emit(
|
|
559
|
+
events.log.narration,
|
|
560
|
+
theme.dim("execution stopped"),
|
|
561
|
+
true,
|
|
562
|
+
);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (commands?.length) {
|
|
567
|
+
for (const command of commands) {
|
|
568
|
+
// Check if execution has been stopped before each command
|
|
569
|
+
if (this.stopped) {
|
|
570
|
+
this.emitter.emit(
|
|
571
|
+
events.log.narration,
|
|
572
|
+
theme.dim("execution stopped"),
|
|
573
|
+
true,
|
|
574
|
+
);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Update current command tracking
|
|
579
|
+
const commandIndex = commands.indexOf(command);
|
|
580
|
+
this.sourceMapper.setCurrentCommand(commandIndex);
|
|
581
|
+
|
|
582
|
+
if (pushToHistory) {
|
|
583
|
+
this.executionHistory[
|
|
584
|
+
this.executionHistory.length - 1
|
|
585
|
+
]?.commands.push(command);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (shouldSave) {
|
|
589
|
+
await this.save({ silent: true });
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (!dry) {
|
|
593
|
+
await this.runCommand(command, depth, shouldSave);
|
|
594
|
+
}
|
|
595
|
+
let timeToComplete = (new Date().getTime() - this.lastCommand) / 1000;
|
|
596
|
+
// this.emitter.emit(events.log.log, timeToComplete, 'seconds')
|
|
597
|
+
|
|
598
|
+
this.csv.push([command.command, timeToComplete]);
|
|
599
|
+
this.lastCommand = new Date().getTime();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// codeblocks are ```yml ... ``` blocks found in ai responses
|
|
605
|
+
// this is similar to "function calling" in other ai frameworks
|
|
606
|
+
// here we parse the codeblocks and execute the commands within them
|
|
607
|
+
async executeCodeBlocks(
|
|
608
|
+
codeblocks,
|
|
609
|
+
depth,
|
|
610
|
+
pushToHistory = false,
|
|
611
|
+
dry = false,
|
|
612
|
+
shouldSave = false,
|
|
613
|
+
) {
|
|
614
|
+
// Check if execution has been stopped
|
|
615
|
+
if (this.stopped) {
|
|
616
|
+
this.emitter.emit(
|
|
617
|
+
events.log.narration,
|
|
618
|
+
theme.dim("execution stopped"),
|
|
619
|
+
true,
|
|
620
|
+
);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
depth = depth + 1;
|
|
625
|
+
|
|
626
|
+
for (const codeblock of codeblocks) {
|
|
627
|
+
// Check if execution has been stopped before each codeblock
|
|
628
|
+
if (this.stopped) {
|
|
629
|
+
this.emitter.emit(
|
|
630
|
+
events.log.narration,
|
|
631
|
+
theme.dim("execution stopped"),
|
|
632
|
+
true,
|
|
633
|
+
);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
let commands;
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
commands = await this.parser.getCommands(codeblock);
|
|
641
|
+
} catch (e) {
|
|
642
|
+
// For parser errors
|
|
643
|
+
return await this.haveAIResolveError(
|
|
644
|
+
e,
|
|
645
|
+
yaml.dump(this.parser.getYAMLFromCodeBlock(codeblock)),
|
|
646
|
+
depth,
|
|
647
|
+
shouldSave,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
await this.executeCommands(
|
|
652
|
+
commands,
|
|
653
|
+
depth,
|
|
654
|
+
pushToHistory,
|
|
655
|
+
dry,
|
|
656
|
+
shouldSave,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// this is the main function that interacts with the ai, runs commands, and checks the results
|
|
662
|
+
// notice that depth is 0 here. when this function resolves, the task is considered complete
|
|
663
|
+
// notice the call to `check()` which validates the prompt is complete
|
|
664
|
+
async aiExecute(
|
|
665
|
+
message,
|
|
666
|
+
validateAndLoop = false,
|
|
667
|
+
dry = false,
|
|
668
|
+
shouldSave = false,
|
|
669
|
+
isLoopContinuation = false,
|
|
670
|
+
) {
|
|
671
|
+
// Check if execution has been stopped
|
|
672
|
+
if (this.stopped) {
|
|
673
|
+
this.emitter.emit(
|
|
674
|
+
events.log.narration,
|
|
675
|
+
theme.dim("execution stopped"),
|
|
676
|
+
true,
|
|
677
|
+
);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Only create new execution history entry if this is not a loop continuation
|
|
682
|
+
if (!isLoopContinuation) {
|
|
683
|
+
this.executionHistory.push({ prompt: this.lastPrompt, commands: [] });
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (shouldSave) {
|
|
687
|
+
await this.save({ silent: true });
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
this.emitter.emit(events.log.debug, "kicking off exploratory loop");
|
|
691
|
+
|
|
692
|
+
// kick everything off
|
|
693
|
+
await this.actOnMarkdown(message, 0, true, dry, shouldSave);
|
|
694
|
+
|
|
695
|
+
// this calls the "check" function to validate the task is complete"
|
|
696
|
+
// the ai determines if it's complete or not
|
|
697
|
+
// if it is incomplete, the ai will likely return more codeblocks to execute
|
|
698
|
+
if (validateAndLoop) {
|
|
699
|
+
this.emitter.emit(
|
|
700
|
+
events.log.debug,
|
|
701
|
+
"exploratory loop resolved, check your work",
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
let response = await this.check();
|
|
705
|
+
|
|
706
|
+
let checkCodeblocks = [];
|
|
707
|
+
try {
|
|
708
|
+
checkCodeblocks = await this.parser.findCodeBlocks(response);
|
|
709
|
+
} catch (error) {
|
|
710
|
+
return await this.haveAIResolveError(error, response, 0, true, true);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
this.emitter.emit(
|
|
714
|
+
events.log.debug,
|
|
715
|
+
`found ${checkCodeblocks.length} codeblocks`,
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
if (checkCodeblocks.length > 0) {
|
|
719
|
+
this.emitter.emit(
|
|
720
|
+
events.log.debug,
|
|
721
|
+
"check thinks more needs to be done",
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
return await this.aiExecute(
|
|
725
|
+
response,
|
|
726
|
+
validateAndLoop,
|
|
727
|
+
dry,
|
|
728
|
+
shouldSave,
|
|
729
|
+
true,
|
|
730
|
+
);
|
|
731
|
+
} else {
|
|
732
|
+
this.emitter.emit(events.log.debug, "seems complete, returning");
|
|
733
|
+
|
|
734
|
+
this.emitter.emit(events.log.log, theme.green("success!"));
|
|
735
|
+
|
|
736
|
+
return response;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// reads a yaml file and interprets the variables found within it
|
|
742
|
+
async loadYML(file) {
|
|
743
|
+
const startTime = Date.now();
|
|
744
|
+
|
|
745
|
+
// Emit file load start event
|
|
746
|
+
this.emitter.emit(events.file.start, {
|
|
747
|
+
operation: "load",
|
|
748
|
+
filePath: file,
|
|
749
|
+
timestamp: startTime,
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
let yml;
|
|
753
|
+
|
|
754
|
+
//wrap this in try/catch so if the file doesn't exist output an error message to the user
|
|
755
|
+
try {
|
|
756
|
+
yml = fs.readFileSync(file, "utf-8");
|
|
757
|
+
|
|
758
|
+
// Emit file load success event
|
|
759
|
+
this.emitter.emit(events.file.load, {
|
|
760
|
+
filePath: file,
|
|
761
|
+
size: yml.length,
|
|
762
|
+
timestamp: Date.now(),
|
|
763
|
+
});
|
|
764
|
+
} catch (e) {
|
|
765
|
+
// Emit file error event
|
|
766
|
+
this.emitter.emit(events.file.error, {
|
|
767
|
+
operation: "load",
|
|
768
|
+
filePath: file,
|
|
769
|
+
error: e.message,
|
|
770
|
+
timestamp: Date.now(),
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
this.emitter.emit(events.error.fatal, `File not found: ${file}`);
|
|
774
|
+
|
|
775
|
+
await this.summarize("File not found");
|
|
776
|
+
await this.exit(true);
|
|
777
|
+
}
|
|
778
|
+
if (!yml) {
|
|
779
|
+
return {};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
yml = await this.parser.validateYAML(yml);
|
|
783
|
+
|
|
784
|
+
// Inject environment variables into any ${VAR} strings
|
|
785
|
+
yml = this.parser.interpolate(yml, {
|
|
786
|
+
TD_THIS_FILE: file,
|
|
787
|
+
...this.config._environment,
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// Show Unreplaced Variables
|
|
791
|
+
let unreplacedVars = this.parser.collectUnreplacedVariables(yml);
|
|
792
|
+
|
|
793
|
+
// Remove all variables that start with OUTPUT- these are special
|
|
794
|
+
unreplacedVars = unreplacedVars.filter((v) => {
|
|
795
|
+
return !v.startsWith("OUTPUT.");
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
if (unreplacedVars.length > 0) {
|
|
799
|
+
this.emitter.emit(
|
|
800
|
+
events.log.warn,
|
|
801
|
+
theme.yellow(
|
|
802
|
+
`Unreplaced variables in YAML: ${unreplacedVars.join(", ")}`,
|
|
803
|
+
),
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
let ymlObj = null;
|
|
808
|
+
let sourceMap = null;
|
|
809
|
+
try {
|
|
810
|
+
// Parse YAML with source mapping
|
|
811
|
+
const parseResult = this.sourceMapper.parseYamlWithSourceMap(yml, file);
|
|
812
|
+
ymlObj = parseResult.yamlObj;
|
|
813
|
+
sourceMap = parseResult.sourceMap;
|
|
814
|
+
|
|
815
|
+
const endTime = Date.now();
|
|
816
|
+
|
|
817
|
+
// Emit file load completion event with source mapping
|
|
818
|
+
this.emitter.emit(events.file.stop, {
|
|
819
|
+
operation: "load",
|
|
820
|
+
filePath: file,
|
|
821
|
+
duration: endTime - startTime,
|
|
822
|
+
success: true,
|
|
823
|
+
sourceMap: sourceMap,
|
|
824
|
+
timestamp: endTime,
|
|
825
|
+
});
|
|
826
|
+
} catch (e) {
|
|
827
|
+
const endTime = Date.now();
|
|
828
|
+
|
|
829
|
+
// Emit file error event
|
|
830
|
+
this.emitter.emit(events.file.error, {
|
|
831
|
+
operation: "parse",
|
|
832
|
+
filePath: file,
|
|
833
|
+
error: e.message,
|
|
834
|
+
duration: endTime - startTime,
|
|
835
|
+
timestamp: endTime,
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
this.emitter.emit(events.error.fatal, e.message);
|
|
839
|
+
|
|
840
|
+
await this.summarize("Invalid YAML");
|
|
841
|
+
await this.exit(true);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return ymlObj;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// this is a rarely used command that likely doesn't need to exist
|
|
848
|
+
// it's used to call /assert in interactive mode
|
|
849
|
+
// @todo remove assert() command from agent.js
|
|
850
|
+
async assert(expect) {
|
|
851
|
+
this.analytics.track("assert");
|
|
852
|
+
|
|
853
|
+
let task = expect;
|
|
854
|
+
if (!task) {
|
|
855
|
+
// set task to last value of tasks
|
|
856
|
+
let task = this.tasks[this.tasks.length - 1];
|
|
857
|
+
|
|
858
|
+
// throw error if no task
|
|
859
|
+
if (!task) {
|
|
860
|
+
throw new Error("No task to assert");
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
this.emitter.emit(events.log.narration, theme.dim("thinking..."), true);
|
|
865
|
+
|
|
866
|
+
let response = `\`\`\`yaml
|
|
867
|
+
commands:
|
|
868
|
+
- command: assert
|
|
869
|
+
expect: ${expect}
|
|
870
|
+
\`\`\``;
|
|
871
|
+
|
|
872
|
+
await this.aiExecute(response);
|
|
873
|
+
|
|
874
|
+
await this.save({ silent: true });
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// this function responds to the result of `promptUser()` which is the user input
|
|
878
|
+
// it kicks off the exploratory loop, which is the main function that interacts with the AI
|
|
879
|
+
async exploratoryLoop(
|
|
880
|
+
currentTask,
|
|
881
|
+
dry = false,
|
|
882
|
+
validateAndLoop = false,
|
|
883
|
+
shouldSave = true,
|
|
884
|
+
) {
|
|
885
|
+
// Check if execution has been stopped
|
|
886
|
+
if (this.stopped) {
|
|
887
|
+
this.emitter.emit(
|
|
888
|
+
events.log.narration,
|
|
889
|
+
theme.dim("execution stopped"),
|
|
890
|
+
true,
|
|
891
|
+
);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
this.lastPrompt = currentTask;
|
|
896
|
+
this.checkCount = 0;
|
|
897
|
+
|
|
898
|
+
this.emitter.emit(events.log.debug, "exploratoryLoop called");
|
|
899
|
+
|
|
900
|
+
this.tasks.push(currentTask);
|
|
901
|
+
|
|
902
|
+
this.emitter.emit(events.log.narration, theme.dim("thinking..."), true);
|
|
903
|
+
|
|
904
|
+
// Parallelize system calls for better performance
|
|
905
|
+
const [screenshot, mousePosition, activeWindow] = await Promise.all([
|
|
906
|
+
this.system.captureScreenBase64(),
|
|
907
|
+
this.system.getMousePosition(),
|
|
908
|
+
this.system.activeWin(),
|
|
909
|
+
]);
|
|
910
|
+
this.lastScreenshot = screenshot;
|
|
911
|
+
|
|
912
|
+
let message = await this.sdk.req("input", {
|
|
913
|
+
input: currentTask,
|
|
914
|
+
mousePosition,
|
|
915
|
+
activeWindow,
|
|
916
|
+
image: this.lastScreenshot,
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
this.emitter.emit(events.log.log, message.data);
|
|
920
|
+
|
|
921
|
+
if (message && message.data) {
|
|
922
|
+
await this.aiExecute(message.data, validateAndLoop, dry, shouldSave);
|
|
923
|
+
this.emitter.emit(
|
|
924
|
+
events.log.debug,
|
|
925
|
+
"showing prompt from exploratoryLoop response check",
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// generate asks the AI to come up with ideas for test files
|
|
933
|
+
// based on the current state of the system (primarily the current screenshot)
|
|
934
|
+
// it will generate files that contain only "prompts"
|
|
935
|
+
// @todo revit the generate command
|
|
936
|
+
async generate(count = 1, prompt = null) {
|
|
937
|
+
this.emitter.emit(
|
|
938
|
+
events.log.debug,
|
|
939
|
+
`generate called with count: ${count}, prompt: ${prompt}`,
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
await this.runLifecycle("prerun");
|
|
943
|
+
|
|
944
|
+
this.emitter.emit(events.log.narration, theme.dim("thinking..."), true);
|
|
945
|
+
|
|
946
|
+
const streamId = `generate-${Date.now()}`;
|
|
947
|
+
this.emitter.emit(events.log.markdown.start, streamId);
|
|
948
|
+
|
|
949
|
+
// Parallelize system calls for better performance
|
|
950
|
+
const [image, mouse, activeWindow] = await Promise.all([
|
|
951
|
+
this.system.captureScreenBase64(),
|
|
952
|
+
this.system.getMousePosition(),
|
|
953
|
+
this.system.activeWin(),
|
|
954
|
+
]);
|
|
955
|
+
|
|
956
|
+
let message = await this.sdk.req(
|
|
957
|
+
"generate",
|
|
958
|
+
{
|
|
959
|
+
prompt: prompt || "make sure to do a spellcheck",
|
|
960
|
+
image,
|
|
961
|
+
mousePosition: mouse,
|
|
962
|
+
activeWindow: activeWindow,
|
|
963
|
+
count,
|
|
964
|
+
stream: false,
|
|
965
|
+
},
|
|
966
|
+
(chunk) => {
|
|
967
|
+
if (chunk.type === "data") {
|
|
968
|
+
this.emitter.emit(events.log.markdown.chunk, streamId, chunk.data);
|
|
969
|
+
}
|
|
970
|
+
},
|
|
971
|
+
);
|
|
972
|
+
|
|
973
|
+
this.emitter.emit(events.log.markdown.end, streamId);
|
|
974
|
+
|
|
975
|
+
let testPrompts = await this.parser.findGenerativePrompts(message.data);
|
|
976
|
+
|
|
977
|
+
// for each testPrompt
|
|
978
|
+
for (const testPrompt of testPrompts) {
|
|
979
|
+
// with the contents of the testPrompt
|
|
980
|
+
let fileName =
|
|
981
|
+
sanitizeFilename(testPrompt.name)
|
|
982
|
+
.trim()
|
|
983
|
+
.replace(/ /g, "-")
|
|
984
|
+
.replace(/['"`]/g, "")
|
|
985
|
+
.replace(/[^a-zA-Z0-9-]/g, "") // remove any non-alphanumeric chars except hyphens
|
|
986
|
+
.toLowerCase() + ".yaml";
|
|
987
|
+
|
|
988
|
+
let path1 = path.join(
|
|
989
|
+
this.workingDir,
|
|
990
|
+
"testdriver",
|
|
991
|
+
"generate",
|
|
992
|
+
fileName,
|
|
993
|
+
);
|
|
994
|
+
// create generate directory if it doesn't exist
|
|
995
|
+
const generateDir = path.join(this.workingDir, "testdriver", "generate");
|
|
996
|
+
if (!fs.existsSync(generateDir)) {
|
|
997
|
+
fs.mkdirSync(generateDir);
|
|
998
|
+
logger.log("Created generate directory:", generateDir);
|
|
999
|
+
} else {
|
|
1000
|
+
logger.log("Generate directory already exists:", generateDir);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
let list = testPrompt.steps;
|
|
1004
|
+
|
|
1005
|
+
let contents = yaml.dump({
|
|
1006
|
+
version: packageJson.version,
|
|
1007
|
+
steps: list,
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
this.emitter.emit(events.log.debug, `writing file ${path1} ${contents}`);
|
|
1011
|
+
|
|
1012
|
+
fs.writeFileSync(path1, contents);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
await this.runLifecycle("postrun");
|
|
1016
|
+
|
|
1017
|
+
this.exit(false);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// this is the functinoality for "undo"
|
|
1021
|
+
async popFromHistory(fullStep) {
|
|
1022
|
+
this.emitter.emit(events.log.narration, theme.dim("undoing..."), true);
|
|
1023
|
+
|
|
1024
|
+
if (this.executionHistory.length) {
|
|
1025
|
+
if (fullStep) {
|
|
1026
|
+
this.executionHistory.pop();
|
|
1027
|
+
} else {
|
|
1028
|
+
this.executionHistory[this.executionHistory.length - 1].commands.pop();
|
|
1029
|
+
}
|
|
1030
|
+
if (
|
|
1031
|
+
!this.executionHistory[this.executionHistory.length - 1].commands.length
|
|
1032
|
+
) {
|
|
1033
|
+
this.executionHistory.pop();
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
async undo() {
|
|
1039
|
+
this.analytics.track("undo");
|
|
1040
|
+
|
|
1041
|
+
this.popFromHistory();
|
|
1042
|
+
await this.save();
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// this allows the user to input "flattened yaml"
|
|
1046
|
+
// like "command='focus-application' name='Google Chrome'"
|
|
1047
|
+
async manualInput(commandString) {
|
|
1048
|
+
this.analytics.track("manual input");
|
|
1049
|
+
|
|
1050
|
+
let yml = await generator.manualToYml(commandString);
|
|
1051
|
+
|
|
1052
|
+
let message = `\`\`\`yaml
|
|
1053
|
+
${yml}
|
|
1054
|
+
\`\`\``;
|
|
1055
|
+
|
|
1056
|
+
await this.aiExecute(message, false);
|
|
1057
|
+
|
|
1058
|
+
await this.save({ silent: true });
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// this function is responsible for starting the recursive process of executing codeblocks
|
|
1062
|
+
async actOnMarkdown(
|
|
1063
|
+
content,
|
|
1064
|
+
depth,
|
|
1065
|
+
pushToHistory = false,
|
|
1066
|
+
dry = false,
|
|
1067
|
+
shouldSave = false,
|
|
1068
|
+
) {
|
|
1069
|
+
let codeblocks = [];
|
|
1070
|
+
try {
|
|
1071
|
+
codeblocks = await this.parser.findCodeBlocks(content);
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
pushToHistory = false;
|
|
1074
|
+
return await this.haveAIResolveError(
|
|
1075
|
+
error,
|
|
1076
|
+
content,
|
|
1077
|
+
depth,
|
|
1078
|
+
false,
|
|
1079
|
+
shouldSave,
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (codeblocks.length) {
|
|
1084
|
+
let executions = await this.executeCodeBlocks(
|
|
1085
|
+
codeblocks,
|
|
1086
|
+
depth,
|
|
1087
|
+
pushToHistory,
|
|
1088
|
+
dry,
|
|
1089
|
+
shouldSave,
|
|
1090
|
+
);
|
|
1091
|
+
return executions;
|
|
1092
|
+
} else {
|
|
1093
|
+
return true;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// this function is responsible for summarizing the test script that has already executed
|
|
1098
|
+
// it is what is saved to the `/tmp/testdriver-summary.md` file and output to the action as a summary
|
|
1099
|
+
async summarize(error = null) {
|
|
1100
|
+
this.analytics.track("summarize");
|
|
1101
|
+
|
|
1102
|
+
this.emitter.emit(
|
|
1103
|
+
events.log.narration,
|
|
1104
|
+
theme.dim("reviewing test..."),
|
|
1105
|
+
true,
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
// let text = prompts.summarize(tasks, error);
|
|
1109
|
+
let image = await this.system.captureScreenBase64();
|
|
1110
|
+
|
|
1111
|
+
this.emitter.emit(events.log.narration, theme.dim("summarizing..."), true);
|
|
1112
|
+
|
|
1113
|
+
const streamId = `summarize-${Date.now()}`;
|
|
1114
|
+
this.emitter.emit(events.log.markdown.start, streamId);
|
|
1115
|
+
|
|
1116
|
+
let reply = await this.sdk.req(
|
|
1117
|
+
"summarize",
|
|
1118
|
+
{
|
|
1119
|
+
image,
|
|
1120
|
+
error: error?.toString(),
|
|
1121
|
+
tasks: this.tasks,
|
|
1122
|
+
},
|
|
1123
|
+
(chunk) => {
|
|
1124
|
+
if (chunk.type === "data") {
|
|
1125
|
+
this.emitter.emit(events.log.markdown.chunk, streamId, chunk.data);
|
|
1126
|
+
}
|
|
1127
|
+
},
|
|
1128
|
+
);
|
|
1129
|
+
|
|
1130
|
+
this.emitter.emit(events.log.markdown.end, streamId);
|
|
1131
|
+
|
|
1132
|
+
// Only write summary to file if --summary option was provided
|
|
1133
|
+
if (this.resultFile) {
|
|
1134
|
+
// Ensure the output directory exists
|
|
1135
|
+
const outputDir = path.dirname(this.resultFile);
|
|
1136
|
+
if (!fs.existsSync(outputDir)) {
|
|
1137
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
fs.writeFileSync(this.resultFile, reply.data);
|
|
1141
|
+
this.emitter.emit(
|
|
1142
|
+
events.log.log,
|
|
1143
|
+
theme.dim(`Summary written to: ${this.resultFile}`),
|
|
1144
|
+
);
|
|
1145
|
+
} else {
|
|
1146
|
+
const tmpFile = path.join(os.tmpdir(), "testdriver-summary.md");
|
|
1147
|
+
fs.writeFileSync(tmpFile, reply?.data);
|
|
1148
|
+
this.emitter.emit(
|
|
1149
|
+
events.log.log,
|
|
1150
|
+
theme.dim(`Summary written to: ${tmpFile}`),
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// this function is responsible for saving the regression test script to a file
|
|
1156
|
+
async save({ filepath = this.thisFile, silent = false } = {}) {
|
|
1157
|
+
const startTime = Date.now();
|
|
1158
|
+
|
|
1159
|
+
// Emit file save start event
|
|
1160
|
+
this.emitter.emit(events.file.start, {
|
|
1161
|
+
operation: "save",
|
|
1162
|
+
filePath: filepath,
|
|
1163
|
+
timestamp: startTime,
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
this.analytics.track("save", { silent });
|
|
1167
|
+
|
|
1168
|
+
if (!this.executionHistory.length) {
|
|
1169
|
+
// Emit file save completion event for empty history
|
|
1170
|
+
this.emitter.emit(events.file.stop, {
|
|
1171
|
+
operation: "save",
|
|
1172
|
+
filePath: filepath,
|
|
1173
|
+
duration: Date.now() - startTime,
|
|
1174
|
+
success: true,
|
|
1175
|
+
reason: "empty_history",
|
|
1176
|
+
timestamp: Date.now(),
|
|
1177
|
+
});
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Read existing file content for diff comparison
|
|
1182
|
+
let existingContent = "";
|
|
1183
|
+
let fileExists = false;
|
|
1184
|
+
try {
|
|
1185
|
+
if (fs.existsSync(filepath)) {
|
|
1186
|
+
existingContent = fs.readFileSync(filepath, "utf8");
|
|
1187
|
+
fileExists = true;
|
|
1188
|
+
}
|
|
1189
|
+
} catch {
|
|
1190
|
+
// File doesn't exist or can't be read, treat as empty
|
|
1191
|
+
existingContent = "";
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// write reply to /tmp/testdriver-summary.md
|
|
1195
|
+
let regression = await generator.dumpToYML(
|
|
1196
|
+
this.executionHistory,
|
|
1197
|
+
this.session,
|
|
1198
|
+
);
|
|
1199
|
+
|
|
1200
|
+
// Create diff if file exists and content has changed
|
|
1201
|
+
let diffResult = null;
|
|
1202
|
+
|
|
1203
|
+
if (fileExists && existingContent !== regression) {
|
|
1204
|
+
const patches = diff.structuredPatch(
|
|
1205
|
+
filepath,
|
|
1206
|
+
filepath,
|
|
1207
|
+
existingContent,
|
|
1208
|
+
regression,
|
|
1209
|
+
`${new Date().toISOString()} (before)`,
|
|
1210
|
+
`${new Date().toISOString()} (after)`,
|
|
1211
|
+
);
|
|
1212
|
+
|
|
1213
|
+
// Create source map-like information for VS Code
|
|
1214
|
+
const diffLines = diff.diffLines(existingContent, regression);
|
|
1215
|
+
const sourceMaps = [];
|
|
1216
|
+
let oldLineNumber = 1;
|
|
1217
|
+
let newLineNumber = 1;
|
|
1218
|
+
|
|
1219
|
+
diffLines.forEach((part) => {
|
|
1220
|
+
const lineCount = part.value.split("\n").length - 1;
|
|
1221
|
+
if (part.added) {
|
|
1222
|
+
sourceMaps.push({
|
|
1223
|
+
type: "addition",
|
|
1224
|
+
oldStart: oldLineNumber,
|
|
1225
|
+
oldEnd: oldLineNumber,
|
|
1226
|
+
newStart: newLineNumber,
|
|
1227
|
+
newEnd: newLineNumber + lineCount,
|
|
1228
|
+
content: part.value,
|
|
1229
|
+
lines: lineCount,
|
|
1230
|
+
});
|
|
1231
|
+
newLineNumber += lineCount;
|
|
1232
|
+
} else if (part.removed) {
|
|
1233
|
+
sourceMaps.push({
|
|
1234
|
+
type: "deletion",
|
|
1235
|
+
oldStart: oldLineNumber,
|
|
1236
|
+
oldEnd: oldLineNumber + lineCount,
|
|
1237
|
+
newStart: newLineNumber,
|
|
1238
|
+
newEnd: newLineNumber,
|
|
1239
|
+
content: part.value,
|
|
1240
|
+
lines: lineCount,
|
|
1241
|
+
});
|
|
1242
|
+
oldLineNumber += lineCount;
|
|
1243
|
+
} else {
|
|
1244
|
+
// unchanged
|
|
1245
|
+
sourceMaps.push({
|
|
1246
|
+
type: "unchanged",
|
|
1247
|
+
oldStart: oldLineNumber,
|
|
1248
|
+
oldEnd: oldLineNumber + lineCount,
|
|
1249
|
+
newStart: newLineNumber,
|
|
1250
|
+
newEnd: newLineNumber + lineCount,
|
|
1251
|
+
content: part.value,
|
|
1252
|
+
lines: lineCount,
|
|
1253
|
+
});
|
|
1254
|
+
oldLineNumber += lineCount;
|
|
1255
|
+
newLineNumber += lineCount;
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
diffResult = {
|
|
1260
|
+
patches,
|
|
1261
|
+
sourceMaps,
|
|
1262
|
+
summary: {
|
|
1263
|
+
additions: diffLines.filter((part) => part.added).length,
|
|
1264
|
+
deletions: diffLines.filter((part) => part.removed).length,
|
|
1265
|
+
modifications: diffLines.filter(
|
|
1266
|
+
(part) => !part.added && !part.removed,
|
|
1267
|
+
).length,
|
|
1268
|
+
},
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
try {
|
|
1273
|
+
fs.writeFileSync(filepath, regression);
|
|
1274
|
+
|
|
1275
|
+
const endTime = Date.now();
|
|
1276
|
+
|
|
1277
|
+
// Emit file save success event
|
|
1278
|
+
this.emitter.emit(events.file.save, {
|
|
1279
|
+
filePath: filepath,
|
|
1280
|
+
size: regression.length,
|
|
1281
|
+
timestamp: endTime,
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
// Emit diff event if there were changes
|
|
1285
|
+
if (diffResult) {
|
|
1286
|
+
this.emitter.emit(events.file.diff, {
|
|
1287
|
+
filePath: filepath,
|
|
1288
|
+
diff: diffResult,
|
|
1289
|
+
timestamp: endTime,
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Emit file save completion event
|
|
1294
|
+
this.emitter.emit(events.file.stop, {
|
|
1295
|
+
operation: "save",
|
|
1296
|
+
filePath: filepath,
|
|
1297
|
+
duration: endTime - startTime,
|
|
1298
|
+
success: true,
|
|
1299
|
+
timestamp: endTime,
|
|
1300
|
+
});
|
|
1301
|
+
} catch (e) {
|
|
1302
|
+
const endTime = Date.now();
|
|
1303
|
+
|
|
1304
|
+
// Emit file save error event
|
|
1305
|
+
this.emitter.emit(events.file.error, {
|
|
1306
|
+
operation: "save",
|
|
1307
|
+
filePath: filepath,
|
|
1308
|
+
error: e.message,
|
|
1309
|
+
duration: endTime - startTime,
|
|
1310
|
+
timestamp: endTime,
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
this.emitter.emit(events.error.fatal, e.message);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
if (!silent) {
|
|
1317
|
+
this.emitter.emit(
|
|
1318
|
+
events.log.markdown.static,
|
|
1319
|
+
`Current test script:
|
|
1320
|
+
|
|
1321
|
+
\`\`\`yaml
|
|
1322
|
+
${regression}
|
|
1323
|
+
\`\`\``,
|
|
1324
|
+
);
|
|
1325
|
+
|
|
1326
|
+
if (!silent) {
|
|
1327
|
+
this.emitter.emit(events.log.log, theme.dim(`saved as ${filepath}`));
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
async runRawYML(yml) {
|
|
1335
|
+
const tmp = require("tmp");
|
|
1336
|
+
let tmpobj = tmp.fileSync();
|
|
1337
|
+
|
|
1338
|
+
let decoded = decodeURIComponent(yml);
|
|
1339
|
+
|
|
1340
|
+
// parse the yaml
|
|
1341
|
+
let ymlObj = null;
|
|
1342
|
+
try {
|
|
1343
|
+
ymlObj = await yaml.load(decoded);
|
|
1344
|
+
} catch (e) {
|
|
1345
|
+
this.emitter.emit(events.error.fatal, e);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// add the root key steps: with array of commands:
|
|
1349
|
+
if (ymlObj && !ymlObj.steps) {
|
|
1350
|
+
ymlObj = {
|
|
1351
|
+
version: packageJson.version,
|
|
1352
|
+
steps: [ymlObj],
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// write the yaml to a file
|
|
1357
|
+
fs.writeFileSync(tmpobj.name, yaml.dump(ymlObj));
|
|
1358
|
+
|
|
1359
|
+
// and run it with run()
|
|
1360
|
+
|
|
1361
|
+
await this.runLifecycle("prerun");
|
|
1362
|
+
await this.run(tmpobj.name, false, false);
|
|
1363
|
+
await this.runLifecycle("postrun");
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// this will load a regression test from a file location
|
|
1367
|
+
// it parses the markdown file and executes the codeblocks exactly as if they were
|
|
1368
|
+
// generated by the AI in a single prompt
|
|
1369
|
+
async run(file = this.thisFile, shouldSave = false, shouldExit = true) {
|
|
1370
|
+
const fileStartTime = Date.now();
|
|
1371
|
+
|
|
1372
|
+
// Emit file start event (for individual file execution within a test)
|
|
1373
|
+
this.emitter.emit(events.file.start, {
|
|
1374
|
+
operation: "run",
|
|
1375
|
+
filePath: file,
|
|
1376
|
+
timestamp: fileStartTime,
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
this.emitter.emit(events.log.narration, theme.cyan(`running ${file}...`));
|
|
1380
|
+
|
|
1381
|
+
let ymlObj = await this.loadYML(file);
|
|
1382
|
+
|
|
1383
|
+
// Store source mapping for current file
|
|
1384
|
+
const parseResult = this.sourceMapper.parseYamlWithSourceMap(
|
|
1385
|
+
fs.readFileSync(file, "utf-8"),
|
|
1386
|
+
file,
|
|
1387
|
+
);
|
|
1388
|
+
this.sourceMapper.setCurrentContext(file, parseResult.sourceMap, -1, -1);
|
|
1389
|
+
|
|
1390
|
+
if (ymlObj.version) {
|
|
1391
|
+
let valid = isValidVersion(ymlObj.version);
|
|
1392
|
+
if (!valid) {
|
|
1393
|
+
this.emitter.emit(
|
|
1394
|
+
events.log.warn,
|
|
1395
|
+
theme.yellow(`Version mismatch detected!`),
|
|
1396
|
+
);
|
|
1397
|
+
this.emitter.emit(
|
|
1398
|
+
events.log.warn,
|
|
1399
|
+
theme.yellow(`Running a test created with v${ymlObj.version}.`),
|
|
1400
|
+
);
|
|
1401
|
+
this.emitter.emit(
|
|
1402
|
+
events.log.warn,
|
|
1403
|
+
theme.yellow(
|
|
1404
|
+
`The local testdriverai version is v${packageJson.version}.`,
|
|
1405
|
+
),
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
this.executionHistory = [];
|
|
1411
|
+
|
|
1412
|
+
if (!ymlObj.steps || !ymlObj.steps.length) {
|
|
1413
|
+
this.emitter.emit(
|
|
1414
|
+
events.log.log,
|
|
1415
|
+
theme.red("No steps found in the YAML file"),
|
|
1416
|
+
);
|
|
1417
|
+
await this.exit(true, shouldSave, true);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
try {
|
|
1421
|
+
for (const step of ymlObj.steps) {
|
|
1422
|
+
const stepIndex = ymlObj.steps.indexOf(step);
|
|
1423
|
+
const stepStartTime = Date.now();
|
|
1424
|
+
|
|
1425
|
+
// Update current step tracking
|
|
1426
|
+
this.sourceMapper.setCurrentStep(stepIndex);
|
|
1427
|
+
|
|
1428
|
+
// Get source position for current step
|
|
1429
|
+
const sourcePosition = this.sourceMapper.getCurrentSourcePosition();
|
|
1430
|
+
|
|
1431
|
+
// Emit step start event with source mapping
|
|
1432
|
+
this.emitter.emit(events.step.start, {
|
|
1433
|
+
stepIndex,
|
|
1434
|
+
prompt: step.prompt,
|
|
1435
|
+
commandCount: step.commands ? step.commands.length : 0,
|
|
1436
|
+
timestamp: stepStartTime,
|
|
1437
|
+
sourcePosition: sourcePosition,
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
this.emitter.emit(events.log.log, ``, null);
|
|
1441
|
+
this.emitter.emit(
|
|
1442
|
+
events.log.log,
|
|
1443
|
+
theme.yellow(`> ${step.prompt || "no prompt"}`),
|
|
1444
|
+
null,
|
|
1445
|
+
);
|
|
1446
|
+
|
|
1447
|
+
try {
|
|
1448
|
+
if (!step.commands && !step.prompt) {
|
|
1449
|
+
this.emitter.emit(
|
|
1450
|
+
events.log.log,
|
|
1451
|
+
theme.red("No commands or prompt found"),
|
|
1452
|
+
);
|
|
1453
|
+
|
|
1454
|
+
this.emitter.emit(events.step.error, {
|
|
1455
|
+
stepIndex,
|
|
1456
|
+
prompt: step.prompt,
|
|
1457
|
+
error: "No commands or prompt found",
|
|
1458
|
+
timestamp: Date.now(),
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
await this.exit(true, shouldSave, true);
|
|
1462
|
+
} else if (!step.commands) {
|
|
1463
|
+
this.emitter.emit(
|
|
1464
|
+
events.log.log,
|
|
1465
|
+
theme.yellow("No commands found, running exploratory"),
|
|
1466
|
+
);
|
|
1467
|
+
await this.exploratoryLoop(step.prompt, false, true, shouldSave);
|
|
1468
|
+
} else {
|
|
1469
|
+
await this.executeCommands(
|
|
1470
|
+
step.commands,
|
|
1471
|
+
0,
|
|
1472
|
+
true,
|
|
1473
|
+
false,
|
|
1474
|
+
shouldSave,
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const stepEndTime = Date.now();
|
|
1479
|
+
const stepDuration = stepEndTime - stepStartTime;
|
|
1480
|
+
|
|
1481
|
+
// Emit step success event with source mapping
|
|
1482
|
+
this.emitter.emit(events.step.success, {
|
|
1483
|
+
stepIndex,
|
|
1484
|
+
prompt: step.prompt,
|
|
1485
|
+
commandCount: step.commands ? step.commands.length : 0,
|
|
1486
|
+
duration: stepDuration,
|
|
1487
|
+
timestamp: stepEndTime,
|
|
1488
|
+
sourcePosition: sourcePosition,
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
if (shouldSave) {
|
|
1492
|
+
await this.save({ silent: true });
|
|
1493
|
+
}
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
const stepEndTime = Date.now();
|
|
1496
|
+
const stepDuration = stepEndTime - stepStartTime;
|
|
1497
|
+
|
|
1498
|
+
// Emit step error event with source mapping
|
|
1499
|
+
this.emitter.emit(events.step.error, {
|
|
1500
|
+
stepIndex,
|
|
1501
|
+
prompt: step.prompt,
|
|
1502
|
+
error: error.message,
|
|
1503
|
+
duration: stepDuration,
|
|
1504
|
+
timestamp: stepEndTime,
|
|
1505
|
+
sourcePosition: sourcePosition,
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
throw error; // Re-throw to maintain existing error handling
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
const testEndTime = Date.now();
|
|
1513
|
+
const fileDuration = testEndTime - fileStartTime;
|
|
1514
|
+
|
|
1515
|
+
// Emit file success event
|
|
1516
|
+
this.emitter.emit(events.file.stop, {
|
|
1517
|
+
operation: "run",
|
|
1518
|
+
filePath: file,
|
|
1519
|
+
duration: fileDuration,
|
|
1520
|
+
success: true,
|
|
1521
|
+
timestamp: testEndTime,
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
if (shouldSave) {
|
|
1525
|
+
await this.save({ filepath: file, silent: false });
|
|
1526
|
+
}
|
|
1527
|
+
if (shouldExit) {
|
|
1528
|
+
await this.summarize();
|
|
1529
|
+
await this.exit(false, shouldSave, true);
|
|
1530
|
+
}
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
const testEndTime = Date.now();
|
|
1533
|
+
const fileDuration = testEndTime - fileStartTime;
|
|
1534
|
+
|
|
1535
|
+
// Emit file error event
|
|
1536
|
+
this.emitter.emit(events.file.error, {
|
|
1537
|
+
operation: "run",
|
|
1538
|
+
filePath: file,
|
|
1539
|
+
error: error.message,
|
|
1540
|
+
duration: fileDuration,
|
|
1541
|
+
timestamp: testEndTime,
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
// Re-throw the error to maintain existing error handling
|
|
1545
|
+
throw error;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
async iffy(condition, then, otherwise, depth) {
|
|
1550
|
+
this.analytics.track("if", { condition });
|
|
1551
|
+
|
|
1552
|
+
this.emitter.emit(
|
|
1553
|
+
events.log.log,
|
|
1554
|
+
generator.jsonToManual({ command: "if", condition }),
|
|
1555
|
+
);
|
|
1556
|
+
|
|
1557
|
+
try {
|
|
1558
|
+
await this.commands.assert(condition, false);
|
|
1559
|
+
return await this.executeCommands(then, ++depth);
|
|
1560
|
+
// eslint-disable-next-line no-unused-vars
|
|
1561
|
+
} catch (error) {
|
|
1562
|
+
return await this.executeCommands(otherwise, ++depth);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
async embed(file, depth, pushToHistory) {
|
|
1567
|
+
let inputFile = JSON.parse(JSON.stringify(file));
|
|
1568
|
+
|
|
1569
|
+
this.analytics.track("embed", { file });
|
|
1570
|
+
|
|
1571
|
+
this.emitter.emit(
|
|
1572
|
+
events.log.log,
|
|
1573
|
+
generator.jsonToManual({ command: "run", file }),
|
|
1574
|
+
);
|
|
1575
|
+
|
|
1576
|
+
depth = depth + 1;
|
|
1577
|
+
|
|
1578
|
+
this.emitter.emit(events.log.log, `${inputFile} (start)`);
|
|
1579
|
+
|
|
1580
|
+
// Use the new helper method to resolve file paths relative to testdriver directory
|
|
1581
|
+
const currentFilePath = this.sourceMapper.currentFilePath || this.thisFile;
|
|
1582
|
+
|
|
1583
|
+
// if the file is not an absolute path, resolve it using the new helper
|
|
1584
|
+
if (!path.isAbsolute(file)) {
|
|
1585
|
+
file = this.resolveTestDriverRelativePath(currentFilePath, file);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// check if the file exists
|
|
1589
|
+
if (!fs.existsSync(file)) {
|
|
1590
|
+
throw `Embedded file not found: ${file}`;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
let ymlObj = await this.loadYML(file);
|
|
1594
|
+
|
|
1595
|
+
// Store current source mapping state
|
|
1596
|
+
const previousContext = this.sourceMapper.saveContext();
|
|
1597
|
+
|
|
1598
|
+
// Set up source mapping for embedded file
|
|
1599
|
+
const parseResult = this.sourceMapper.parseYamlWithSourceMap(
|
|
1600
|
+
fs.readFileSync(file, "utf-8"),
|
|
1601
|
+
file,
|
|
1602
|
+
);
|
|
1603
|
+
this.sourceMapper.setCurrentContext(file, parseResult.sourceMap, -1, -1);
|
|
1604
|
+
|
|
1605
|
+
try {
|
|
1606
|
+
for (const step of ymlObj.steps) {
|
|
1607
|
+
const stepIndex = ymlObj.steps.indexOf(step);
|
|
1608
|
+
this.sourceMapper.setCurrentStep(stepIndex);
|
|
1609
|
+
|
|
1610
|
+
if (!step.commands && !step.prompt) {
|
|
1611
|
+
this.emitter.emit(
|
|
1612
|
+
events.log.log,
|
|
1613
|
+
theme.red("No commands or prompt found"),
|
|
1614
|
+
);
|
|
1615
|
+
await this.exit(true);
|
|
1616
|
+
} else if (!step.commands) {
|
|
1617
|
+
this.emitter.emit(
|
|
1618
|
+
events.log.log,
|
|
1619
|
+
theme.yellow("No commands found, running exploratory"),
|
|
1620
|
+
);
|
|
1621
|
+
await this.exploratoryLoop(step.prompt, false, true, false);
|
|
1622
|
+
} else {
|
|
1623
|
+
await this.executeCommands(step.commands, depth, pushToHistory);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
} finally {
|
|
1627
|
+
// Restore previous source mapping state
|
|
1628
|
+
this.sourceMapper.restoreContext(previousContext);
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
this.emitter.emit(events.log.log, `${inputFile} (end)`);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
async buildEnv(options = {}) {
|
|
1635
|
+
// If instance already exists, do not build environment again
|
|
1636
|
+
if (this.instance) {
|
|
1637
|
+
this.emitter.emit(
|
|
1638
|
+
events.log.narration,
|
|
1639
|
+
theme.dim("sandbox instance already exists, skipping launch."),
|
|
1640
|
+
);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
let { headless = false, heal, new: createNew = false } = options;
|
|
1645
|
+
|
|
1646
|
+
// Prioritize this.newSandbox flag if it's set
|
|
1647
|
+
if (this.newSandbox) {
|
|
1648
|
+
createNew = true;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// If CI environment variable is true, always create a new sandbox
|
|
1652
|
+
if (this.config.CI) {
|
|
1653
|
+
createNew = true;
|
|
1654
|
+
this.emitter.emit(
|
|
1655
|
+
events.log.log,
|
|
1656
|
+
theme.dim("CI environment detected, will create a new sandbox"),
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
if (heal) this.healMode = heal;
|
|
1661
|
+
|
|
1662
|
+
// If createNew flag is set, clear sandboxId to prevent reconnection attempts
|
|
1663
|
+
if (createNew) {
|
|
1664
|
+
this.sandboxId = null;
|
|
1665
|
+
if (!this.config.CI && !this.newSandbox) {
|
|
1666
|
+
this.emitter.emit(events.log.log, theme.dim("Creating a new sandbox"));
|
|
1667
|
+
} else if (this.newSandbox) {
|
|
1668
|
+
this.emitter.emit(events.log.log, theme.dim("Creating a new sandbox"));
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// Create session first so session ID is available for Sentry tracing in WebSocket connection
|
|
1673
|
+
await this.newSession();
|
|
1674
|
+
|
|
1675
|
+
// order is important!
|
|
1676
|
+
await this.connectToSandboxService();
|
|
1677
|
+
|
|
1678
|
+
// Set sandbox ID for reconnection (only if not creating new and recent ID exists)
|
|
1679
|
+
if (this.ip) {
|
|
1680
|
+
let instance = await this.sandbox.send({
|
|
1681
|
+
type: "direct",
|
|
1682
|
+
resolution: this.config.TD_RESOLUTION,
|
|
1683
|
+
ci: this.config.CI,
|
|
1684
|
+
ip: this.ip,
|
|
1685
|
+
instanceId: this.instanceId || undefined,
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
// Store connection params for reconnection
|
|
1689
|
+
// For direct IP connections, store as a direct type so reconnection
|
|
1690
|
+
// sends a 'direct' message instead of 'connect' with an IP as sandboxId
|
|
1691
|
+
this.sandbox.setConnectionParams({
|
|
1692
|
+
type: 'direct',
|
|
1693
|
+
ip: this.ip,
|
|
1694
|
+
sandboxId: instance?.instance?.instanceId || instance?.instance?.sandboxId || null,
|
|
1695
|
+
persist: true,
|
|
1696
|
+
keepAlive: this.keepAlive,
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
// Mark instance socket as connected so console logs are forwarded
|
|
1700
|
+
this.sandbox.instanceSocketConnected = true;
|
|
1701
|
+
this.emitter.emit(events.sandbox.connected);
|
|
1702
|
+
|
|
1703
|
+
this.instance = instance.instance;
|
|
1704
|
+
await this.renderSandbox(this.instance, headless);
|
|
1705
|
+
await this.runLifecycle("provision");
|
|
1706
|
+
|
|
1707
|
+
return;
|
|
1708
|
+
} else if (!createNew && this.sandboxId && !this.config.CI) {
|
|
1709
|
+
// Only attempt to connect to existing sandbox if not in CI mode and not creating new
|
|
1710
|
+
// Attempt to connect to known instance
|
|
1711
|
+
this.emitter.emit(
|
|
1712
|
+
events.log.narration,
|
|
1713
|
+
theme.dim(`connecting to sandbox ${this.sandboxId}...`),
|
|
1714
|
+
);
|
|
1715
|
+
|
|
1716
|
+
try {
|
|
1717
|
+
let instance = await this.connectToSandboxDirect(
|
|
1718
|
+
this.sandboxId,
|
|
1719
|
+
true, // always persist by default
|
|
1720
|
+
this.keepAlive, // pass keepAlive TTL
|
|
1721
|
+
);
|
|
1722
|
+
|
|
1723
|
+
this.instance = instance;
|
|
1724
|
+
|
|
1725
|
+
await this.renderSandbox(instance, headless);
|
|
1726
|
+
return;
|
|
1727
|
+
} catch (error) {
|
|
1728
|
+
// If connection fails, fall through to creating a new sandbox
|
|
1729
|
+
this.emitter.emit(
|
|
1730
|
+
events.log.narration,
|
|
1731
|
+
theme.dim(`failed to connect to recent sandbox, creating new one...`),
|
|
1732
|
+
);
|
|
1733
|
+
console.error("Failed to reconnect to sandbox:", error);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// Create new sandbox (either because createNew is true, or no existing sandbox to connect to)
|
|
1738
|
+
if (!this.instance) {
|
|
1739
|
+
const { formatter } = require("../sdk-log-formatter.js");
|
|
1740
|
+
this.emitter.emit(
|
|
1741
|
+
events.log.narration,
|
|
1742
|
+
formatter.getPrefix("connect") +
|
|
1743
|
+
" " +
|
|
1744
|
+
theme.green.bold("Creating") +
|
|
1745
|
+
" " +
|
|
1746
|
+
theme.cyan(`new sandbox...`),
|
|
1747
|
+
);
|
|
1748
|
+
let newSandbox = await this.createNewSandbox();
|
|
1749
|
+
|
|
1750
|
+
// Extract the sandbox ID from the newly created sandbox
|
|
1751
|
+
this.sandboxId =
|
|
1752
|
+
newSandbox?.sandbox?.sandboxId || newSandbox?.sandbox?.instanceId;
|
|
1753
|
+
|
|
1754
|
+
// E2B sandboxes return a url directly from create — no separate
|
|
1755
|
+
// connect step needed (the API proxies commands via Ably).
|
|
1756
|
+
if (newSandbox?.sandbox?.url) {
|
|
1757
|
+
this.sandbox.setConnectionParams({
|
|
1758
|
+
sandboxId: this.sandboxId,
|
|
1759
|
+
persist: true,
|
|
1760
|
+
keepAlive: this.keepAlive,
|
|
1761
|
+
});
|
|
1762
|
+
this.emitter.emit(events.sandbox.connected);
|
|
1763
|
+
this.instance = newSandbox.sandbox;
|
|
1764
|
+
await this.renderSandbox(this.instance, headless);
|
|
1765
|
+
await this.runLifecycle("provision");
|
|
1766
|
+
} else {
|
|
1767
|
+
let instance = await this.connectToSandboxDirect(
|
|
1768
|
+
this.sandboxId,
|
|
1769
|
+
true, // always persist by default
|
|
1770
|
+
this.keepAlive, // pass keepAlive TTL
|
|
1771
|
+
);
|
|
1772
|
+
this.instance = instance;
|
|
1773
|
+
await this.renderSandbox(instance, headless);
|
|
1774
|
+
await this.runLifecycle("provision");
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
async start() {
|
|
1780
|
+
try {
|
|
1781
|
+
this.emitter.emit(
|
|
1782
|
+
events.log.log,
|
|
1783
|
+
theme.green(`Howdy! I'm TestDriver v${packageJson.version}`),
|
|
1784
|
+
);
|
|
1785
|
+
|
|
1786
|
+
// Emit test start event for the entire test execution
|
|
1787
|
+
this.emitter.emit(events.test.start, {
|
|
1788
|
+
filePath: this.thisFile,
|
|
1789
|
+
timestamp: Date.now(),
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
// Debugger UI is hosted on the web app (console.testdriver.ai/debugger/)
|
|
1793
|
+
// No local debugger server needed
|
|
1794
|
+
this.emitter.emit(events.log.log, `This is beta software!`);
|
|
1795
|
+
this.emitter.emit(events.log.log, ``);
|
|
1796
|
+
this.emitter.emit(
|
|
1797
|
+
events.log.log,
|
|
1798
|
+
theme.yellow(`Join our Discord for help`),
|
|
1799
|
+
);
|
|
1800
|
+
this.emitter.emit(
|
|
1801
|
+
events.log.log,
|
|
1802
|
+
`https://discord.com/invite/cWDFW8DzPm`,
|
|
1803
|
+
);
|
|
1804
|
+
this.emitter.emit(events.log.log, ``);
|
|
1805
|
+
|
|
1806
|
+
// make testdriver directory if it doesn't exist
|
|
1807
|
+
let testdriverFolder = path.join(this.workingDir);
|
|
1808
|
+
if (!fs.existsSync(testdriverFolder)) {
|
|
1809
|
+
fs.mkdirSync(testdriverFolder);
|
|
1810
|
+
// log
|
|
1811
|
+
this.emitter.emit(
|
|
1812
|
+
events.log.log,
|
|
1813
|
+
theme.dim(`Created testdriver directory: ${testdriverFolder}`),
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
// if the directory for thisFile doesn't exist, create it
|
|
1818
|
+
if (
|
|
1819
|
+
this.cliArgs.command !== "sandbox" &&
|
|
1820
|
+
this.cliArgs.command !== "generate"
|
|
1821
|
+
) {
|
|
1822
|
+
const dir = path.dirname(this.thisFile);
|
|
1823
|
+
if (!fs.existsSync(dir)) {
|
|
1824
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1825
|
+
this.emitter.emit(
|
|
1826
|
+
events.log.log,
|
|
1827
|
+
theme.dim(`Created directory ${dir}`),
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// if thisFile doesn't exist, create it
|
|
1832
|
+
// thisFile def to testdriver/testdriver.yaml, during init, it just creates an empty file
|
|
1833
|
+
if (!fs.existsSync(this.thisFile)) {
|
|
1834
|
+
fs.writeFileSync(this.thisFile, "");
|
|
1835
|
+
this.emitter.emit(
|
|
1836
|
+
events.log.log,
|
|
1837
|
+
theme.dim(`Created ${this.thisFile}`),
|
|
1838
|
+
);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
if (this.config.TD_API_KEY) {
|
|
1843
|
+
await this.sdk.auth();
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
if (
|
|
1847
|
+
this.cliArgs.command !== "sandbox" &&
|
|
1848
|
+
this.cliArgs.command !== "generate"
|
|
1849
|
+
) {
|
|
1850
|
+
this.emitter.emit(
|
|
1851
|
+
events.log.log,
|
|
1852
|
+
theme.dim(`Working on ${this.thisFile}`),
|
|
1853
|
+
);
|
|
1854
|
+
|
|
1855
|
+
this.loadYML(this.thisFile);
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
this.analytics.track("command", {
|
|
1859
|
+
command: this.cliArgs.command,
|
|
1860
|
+
file: this.thisFile,
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
// Dynamically handle all available commands (except edit which is handled by CLI)
|
|
1864
|
+
const availableCommands = Object.keys(this.getCommandDefinitions());
|
|
1865
|
+
if (
|
|
1866
|
+
availableCommands.includes(this.cliArgs.command) &&
|
|
1867
|
+
this.cliArgs.command !== "edit"
|
|
1868
|
+
) {
|
|
1869
|
+
await this.executeUnifiedCommand(
|
|
1870
|
+
this.cliArgs.command,
|
|
1871
|
+
this.cliArgs.args,
|
|
1872
|
+
this.cliArgs.options,
|
|
1873
|
+
this.cliArgs.options._optionValues,
|
|
1874
|
+
);
|
|
1875
|
+
} else if (this.cliArgs.command !== "edit") {
|
|
1876
|
+
this.emitter.emit(
|
|
1877
|
+
events.error.fatal,
|
|
1878
|
+
`Unknown command: ${this.cliArgs.command}`,
|
|
1879
|
+
);
|
|
1880
|
+
await this.exit(true);
|
|
1881
|
+
}
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
this.emitter.emit(events.error.fatal, error.message || error);
|
|
1884
|
+
await this.exit(true);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
async renderSandbox(instance, headless = false) {
|
|
1889
|
+
if (!headless) {
|
|
1890
|
+
let url;
|
|
1891
|
+
|
|
1892
|
+
// If the instance already has a URL (from reconnection), use it
|
|
1893
|
+
if (instance.url) {
|
|
1894
|
+
url = instance.url;
|
|
1895
|
+
} else if (instance.ip || instance.publicIp) {
|
|
1896
|
+
// Otherwise construct it from IP and port
|
|
1897
|
+
url =
|
|
1898
|
+
"http://" +
|
|
1899
|
+
(instance.ip || instance.publicIp) +
|
|
1900
|
+
":" +
|
|
1901
|
+
(instance.vncPort || "5800") +
|
|
1902
|
+
"/vnc_lite.html?token=V3b8wG9";
|
|
1903
|
+
} else {
|
|
1904
|
+
// If we don't have URL or IP, we can't render
|
|
1905
|
+
logger.warn("renderSandbox: Missing URL and IP in instance", instance);
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
let data = {
|
|
1910
|
+
resolution: this.config.TD_RESOLUTION,
|
|
1911
|
+
url: url,
|
|
1912
|
+
token: "V3b8wG9",
|
|
1913
|
+
testFile: this.testFile || null,
|
|
1914
|
+
os: this.sandboxOs || "linux",
|
|
1915
|
+
};
|
|
1916
|
+
|
|
1917
|
+
// Base64 encode the data (the debugger expects base64, not URL encoding)
|
|
1918
|
+
const encodedData = Buffer.from(JSON.stringify(data)).toString("base64");
|
|
1919
|
+
|
|
1920
|
+
// Build debugger URL — hosted on S3 (v7-vnc bucket)
|
|
1921
|
+
const debuggerBase = process.env.TD_DEBUGGER_BASE_URL || "http://v7-vnc.s3.us-east-2.amazonaws.com";
|
|
1922
|
+
// URL-encode the base64 data to handle +, /, = characters safely
|
|
1923
|
+
const urlToOpen = `${debuggerBase}/index.html?data=${encodeURIComponent(encodedData)}`;
|
|
1924
|
+
|
|
1925
|
+
// Check preview mode from CLI options (SDK passes it directly)
|
|
1926
|
+
const previewMode = (this.cliArgs.options && this.cliArgs.options.preview) || this.config.TD_PREVIEW || "browser";
|
|
1927
|
+
|
|
1928
|
+
if (previewMode === "ide") {
|
|
1929
|
+
// Send session to VS Code extension via HTTP
|
|
1930
|
+
this.writeIdeSessionFile(urlToOpen, data);
|
|
1931
|
+
} else if (previewMode !== "none") {
|
|
1932
|
+
// Open in browser (default behavior)
|
|
1933
|
+
this.emitter.emit(events.showWindow, urlToOpen);
|
|
1934
|
+
}
|
|
1935
|
+
// If preview is "none", don't open anything
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// Get the console (web app) URL for the given API root
|
|
1940
|
+
_getConsoleUrl(apiRoot) {
|
|
1941
|
+
// Allow explicit override via env (e.g. VITE_DOMAIN from .env)
|
|
1942
|
+
if (process.env.VITE_DOMAIN) return process.env.VITE_DOMAIN;
|
|
1943
|
+
|
|
1944
|
+
const environments = require("../lib/environments.json");
|
|
1945
|
+
const mapping = {
|
|
1946
|
+
"https://v6.testdriver.ai": environments.stable.consoleUrl,
|
|
1947
|
+
};
|
|
1948
|
+
for (const env of Object.values(environments)) {
|
|
1949
|
+
mapping[env.apiRoot] = env.consoleUrl;
|
|
1950
|
+
}
|
|
1951
|
+
if (mapping[apiRoot]) return mapping[apiRoot];
|
|
1952
|
+
// Local dev: API on localhost:1337 -> Web on localhost:3001
|
|
1953
|
+
if (apiRoot.includes("localhost:1337") || apiRoot.includes("127.0.0.1:1337")) {
|
|
1954
|
+
return "http://localhost:3001";
|
|
1955
|
+
}
|
|
1956
|
+
return environments.stable.consoleUrl;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// Write session file for IDE preview (VSCode extension watches for these)
|
|
1960
|
+
writeIdeSessionFile(debuggerUrl, data) {
|
|
1961
|
+
const fs = require("fs");
|
|
1962
|
+
const path = require("path");
|
|
1963
|
+
|
|
1964
|
+
const sessionId = `${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
1965
|
+
const previewsDir = path.join(process.cwd(), ".testdriver", ".previews");
|
|
1966
|
+
|
|
1967
|
+
// Create the previews directory if it doesn't exist
|
|
1968
|
+
if (!fs.existsSync(previewsDir)) {
|
|
1969
|
+
fs.mkdirSync(previewsDir, { recursive: true });
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
const sessionData = {
|
|
1973
|
+
sessionId,
|
|
1974
|
+
debuggerUrl,
|
|
1975
|
+
resolution: Array.isArray(data.resolution) ? data.resolution : (data.resolution ? data.resolution.split("x").map(Number) : [1920, 1080]),
|
|
1976
|
+
testFile: data.testFile || this.testFile || null,
|
|
1977
|
+
os: data.os || this.sandboxOs || "linux",
|
|
1978
|
+
timestamp: Date.now(),
|
|
1979
|
+
};
|
|
1980
|
+
|
|
1981
|
+
const filePath = path.join(previewsDir, `${sessionId}.json`);
|
|
1982
|
+
fs.writeFileSync(filePath, JSON.stringify(sessionData, null, 2));
|
|
1983
|
+
|
|
1984
|
+
logger.log(`IDE preview session written to ${filePath}`);
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// Find the VS Code instance that contains the test file
|
|
1988
|
+
findTargetIdeInstance(testFilePath) {
|
|
1989
|
+
const fs = require("fs");
|
|
1990
|
+
const os = require("os");
|
|
1991
|
+
const path = require("path");
|
|
1992
|
+
|
|
1993
|
+
const instancesDir = path.join(os.homedir(), ".testdriver", "ide-instances");
|
|
1994
|
+
|
|
1995
|
+
if (!fs.existsSync(instancesDir)) {
|
|
1996
|
+
return null;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
const files = fs.readdirSync(instancesDir);
|
|
2000
|
+
const normalizedTestPath = testFilePath ? path.normalize(testFilePath) : null;
|
|
2001
|
+
|
|
2002
|
+
let matchingInstance = null;
|
|
2003
|
+
let longestMatchLength = 0;
|
|
2004
|
+
|
|
2005
|
+
for (const file of files) {
|
|
2006
|
+
if (!file.endsWith('.json')) continue;
|
|
2007
|
+
|
|
2008
|
+
try {
|
|
2009
|
+
const registrationPath = path.join(instancesDir, file);
|
|
2010
|
+
const registration = JSON.parse(fs.readFileSync(registrationPath, 'utf-8'));
|
|
2011
|
+
|
|
2012
|
+
// Check if this instance is still alive (registration within last 60 seconds or process exists)
|
|
2013
|
+
const isRecent = Date.now() - registration.timestamp < 60000;
|
|
2014
|
+
|
|
2015
|
+
// Skip stale registrations
|
|
2016
|
+
if (!isRecent) {
|
|
2017
|
+
// Try to clean up stale file
|
|
2018
|
+
try { fs.unlinkSync(registrationPath); } catch {}
|
|
2019
|
+
continue;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// If we have a test file path, find the best matching workspace
|
|
2023
|
+
if (normalizedTestPath && registration.workspacePaths) {
|
|
2024
|
+
for (const workspacePath of registration.workspacePaths) {
|
|
2025
|
+
const normalizedWorkspace = path.normalize(workspacePath);
|
|
2026
|
+
if (normalizedTestPath.startsWith(normalizedWorkspace + path.sep) ||
|
|
2027
|
+
normalizedTestPath === normalizedWorkspace) {
|
|
2028
|
+
// Prefer longest match (most specific workspace)
|
|
2029
|
+
if (normalizedWorkspace.length > longestMatchLength) {
|
|
2030
|
+
longestMatchLength = normalizedWorkspace.length;
|
|
2031
|
+
matchingInstance = registration;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
} else if (!matchingInstance) {
|
|
2036
|
+
// If no test file path, just use the first available instance
|
|
2037
|
+
matchingInstance = registration;
|
|
2038
|
+
}
|
|
2039
|
+
} catch (error) {
|
|
2040
|
+
// Ignore malformed registration files
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
return matchingInstance;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// Send session notification to VS Code extension via HTTP
|
|
2048
|
+
sendIdeSessionNotification(debuggerUrl, data) {
|
|
2049
|
+
const http = require("http");
|
|
2050
|
+
const path = require("path");
|
|
2051
|
+
|
|
2052
|
+
const testFilePath = data.testFile || this.thisFile;
|
|
2053
|
+
const targetInstance = this.findTargetIdeInstance(testFilePath);
|
|
2054
|
+
|
|
2055
|
+
if (!targetInstance) {
|
|
2056
|
+
logger.warn("No VS Code instance found for IDE preview. Make sure VS Code with TestDriver extension is open.");
|
|
2057
|
+
// Fall back to browser
|
|
2058
|
+
this.emitter.emit(events.showWindow, debuggerUrl);
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// Generate a unique session ID
|
|
2063
|
+
const testFileName = (testFilePath || "test")
|
|
2064
|
+
.split(path.sep).pop()
|
|
2065
|
+
.replace(/\.[^/.]+$/, "");
|
|
2066
|
+
const sessionId = `${testFileName}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
2067
|
+
|
|
2068
|
+
const sessionData = {
|
|
2069
|
+
sessionId: sessionId,
|
|
2070
|
+
debuggerUrl: debuggerUrl,
|
|
2071
|
+
resolution: data.resolution || this.config.TD_RESOLUTION,
|
|
2072
|
+
testFile: testFilePath,
|
|
2073
|
+
os: data.os || this.sandboxOs || "linux",
|
|
2074
|
+
timestamp: Date.now(),
|
|
2075
|
+
};
|
|
2076
|
+
|
|
2077
|
+
const postData = JSON.stringify(sessionData);
|
|
2078
|
+
|
|
2079
|
+
const options = {
|
|
2080
|
+
hostname: '127.0.0.1',
|
|
2081
|
+
port: targetInstance.port,
|
|
2082
|
+
path: '/session',
|
|
2083
|
+
method: 'POST',
|
|
2084
|
+
headers: {
|
|
2085
|
+
'Content-Type': 'application/json',
|
|
2086
|
+
'Content-Length': Buffer.byteLength(postData)
|
|
2087
|
+
},
|
|
2088
|
+
timeout: 5000
|
|
2089
|
+
};
|
|
2090
|
+
|
|
2091
|
+
const req = http.request(options, (res) => {
|
|
2092
|
+
if (res.statusCode === 200) {
|
|
2093
|
+
logger.log(`IDE session notification sent to port ${targetInstance.port}`);
|
|
2094
|
+
} else {
|
|
2095
|
+
logger.warn(`IDE session notification failed with status ${res.statusCode}`);
|
|
2096
|
+
// Fall back to browser on failure
|
|
2097
|
+
this.emitter.emit(events.showWindow, debuggerUrl);
|
|
2098
|
+
}
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
req.on('error', (error) => {
|
|
2102
|
+
logger.warn(`Failed to send IDE session notification: ${error.message}`);
|
|
2103
|
+
// Fall back to browser on error
|
|
2104
|
+
this.emitter.emit(events.showWindow, debuggerUrl);
|
|
2105
|
+
});
|
|
2106
|
+
|
|
2107
|
+
req.on('timeout', () => {
|
|
2108
|
+
req.destroy();
|
|
2109
|
+
logger.warn('IDE session notification timed out');
|
|
2110
|
+
// Fall back to browser on timeout
|
|
2111
|
+
this.emitter.emit(events.showWindow, debuggerUrl);
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
req.write(postData);
|
|
2115
|
+
req.end();
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
async connectToSandboxService() {
|
|
2119
|
+
this.emitter.emit(
|
|
2120
|
+
events.log.narration,
|
|
2121
|
+
theme.dim(`establishing connection...`),
|
|
2122
|
+
);
|
|
2123
|
+
let ableToBoot = await this.sandbox.boot(this.config.TD_API_ROOT);
|
|
2124
|
+
|
|
2125
|
+
if (!ableToBoot) {
|
|
2126
|
+
return await this.dieOnFatal(
|
|
2127
|
+
`Unable to connect to TestDriver sandbox service at ${this.config.TD_API_ROOT}.
|
|
2128
|
+
Please check your network connection, TD_API_KEY, or the service status.`,
|
|
2129
|
+
true,
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
const { formatter } = require("../sdk-log-formatter.js");
|
|
2134
|
+
this.emitter.emit(
|
|
2135
|
+
events.log.narration,
|
|
2136
|
+
formatter.getPrefix("connect") +
|
|
2137
|
+
" " +
|
|
2138
|
+
theme.green.bold("Authenticating") +
|
|
2139
|
+
theme.dim("..."),
|
|
2140
|
+
);
|
|
2141
|
+
let ableToAuth = await this.sandbox.auth(this.config.TD_API_KEY);
|
|
2142
|
+
|
|
2143
|
+
if (!ableToAuth) {
|
|
2144
|
+
return await this.dieOnFatal(
|
|
2145
|
+
`Unable to authorize with TestDriver sandbox service at ${this.config.TD_API_ROOT}.
|
|
2146
|
+
Please check your network connection, TD_API_KEY, or the service status.`,
|
|
2147
|
+
true,
|
|
2148
|
+
);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
async connectToSandboxDirect(sandboxId, persist = false, keepAlive = null) {
|
|
2153
|
+
const { formatter } = require("../sdk-log-formatter.js");
|
|
2154
|
+
this.emitter.emit(
|
|
2155
|
+
events.log.narration,
|
|
2156
|
+
formatter.getPrefix("connect") +
|
|
2157
|
+
" " +
|
|
2158
|
+
theme.green.bold("Connecting") +
|
|
2159
|
+
" " +
|
|
2160
|
+
theme.cyan(`to sandbox...`),
|
|
2161
|
+
);
|
|
2162
|
+
let reply = await this.sandbox.connect(sandboxId, persist, keepAlive);
|
|
2163
|
+
|
|
2164
|
+
// reply includes { success, url, sandbox: {...} }
|
|
2165
|
+
// For renderSandbox, we need the sandbox object with url merged in
|
|
2166
|
+
const sandbox = reply.sandbox || {};
|
|
2167
|
+
|
|
2168
|
+
// If reply has a URL at top level, merge it into the sandbox object
|
|
2169
|
+
if (reply.url && !sandbox.url) {
|
|
2170
|
+
sandbox.url = reply.url;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
return sandbox;
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
async createNewSandbox() {
|
|
2177
|
+
const sandboxConfig = {
|
|
2178
|
+
type: "create",
|
|
2179
|
+
resolution: this.config.TD_RESOLUTION,
|
|
2180
|
+
ci: this.config.CI,
|
|
2181
|
+
os: this.sandboxOs || "linux",
|
|
2182
|
+
};
|
|
2183
|
+
|
|
2184
|
+
// Add AMI and instance type if specified
|
|
2185
|
+
if (this.sandboxAmi) {
|
|
2186
|
+
sandboxConfig.ami = this.sandboxAmi;
|
|
2187
|
+
}
|
|
2188
|
+
if (this.sandboxInstance) {
|
|
2189
|
+
sandboxConfig.instanceType = this.sandboxInstance;
|
|
2190
|
+
}
|
|
2191
|
+
// Add keepAlive TTL if specified
|
|
2192
|
+
if (this.keepAlive !== undefined && this.keepAlive !== null) {
|
|
2193
|
+
sandboxConfig.keepAlive = this.keepAlive;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
const { formatter } = require("../sdk-log-formatter.js");
|
|
2197
|
+
const retryDelay = 15000; // 15 seconds between retries
|
|
2198
|
+
|
|
2199
|
+
while (true) {
|
|
2200
|
+
let response = await this.sandbox.send(sandboxConfig, 60000 * 8);
|
|
2201
|
+
|
|
2202
|
+
// Check if queued (all slots in use)
|
|
2203
|
+
if (response.type === "create.queued") {
|
|
2204
|
+
this.emitter.emit(
|
|
2205
|
+
events.log.narration,
|
|
2206
|
+
formatter.getPrefix("queue") +
|
|
2207
|
+
" " +
|
|
2208
|
+
theme.yellow.bold("Waiting") +
|
|
2209
|
+
" " +
|
|
2210
|
+
theme.dim(response.message),
|
|
2211
|
+
);
|
|
2212
|
+
|
|
2213
|
+
// Wait then retry
|
|
2214
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
2215
|
+
continue;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// Success - got a sandbox
|
|
2219
|
+
return response;
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
async newSession() {
|
|
2224
|
+
// should be start of new session
|
|
2225
|
+
// If sandbox is connected, get system info; otherwise pass empty objects
|
|
2226
|
+
const isSandboxConnected = this.sandbox.apiSocketConnected;
|
|
2227
|
+
|
|
2228
|
+
const sessionRes = await this.sdk.req("session/start", {
|
|
2229
|
+
systemInformationOsInfo: isSandboxConnected
|
|
2230
|
+
? await this.system.getSystemInformationOsInfo()
|
|
2231
|
+
: {},
|
|
2232
|
+
mousePosition: isSandboxConnected
|
|
2233
|
+
? await this.system.getMousePosition()
|
|
2234
|
+
: {},
|
|
2235
|
+
activeWindow: isSandboxConnected ? await this.system.activeWin() : {},
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
if (!sessionRes) {
|
|
2239
|
+
throw new Error(
|
|
2240
|
+
"Unable to start TestDriver session. Check your network connection or restart the CLI.",
|
|
2241
|
+
);
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
this.session.set(sessionRes.data.id);
|
|
2245
|
+
|
|
2246
|
+
// Set Sentry session trace context for distributed tracing
|
|
2247
|
+
// This links CLI errors/logs to the same trace as API calls
|
|
2248
|
+
try {
|
|
2249
|
+
const sentry = require("../lib/sentry");
|
|
2250
|
+
sentry.setSessionTraceContext(sessionRes.data.id);
|
|
2251
|
+
} catch (e) {
|
|
2252
|
+
// Sentry module may not be available, ignore
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
// Helper method to find testdriver directory by traversing up from a file path
|
|
2257
|
+
findTestDriverDirectory(filePath) {
|
|
2258
|
+
// Start from the directory containing the file, or use workingDir as fallback
|
|
2259
|
+
let currentDir = filePath
|
|
2260
|
+
? path.dirname(path.resolve(filePath))
|
|
2261
|
+
: this.workingDir;
|
|
2262
|
+
|
|
2263
|
+
while (currentDir !== path.dirname(currentDir)) {
|
|
2264
|
+
// Continue until we reach the root
|
|
2265
|
+
const testdriverPath = path.join(currentDir, "testdriver");
|
|
2266
|
+
if (
|
|
2267
|
+
fs.existsSync(testdriverPath) &&
|
|
2268
|
+
fs.statSync(testdriverPath).isDirectory()
|
|
2269
|
+
) {
|
|
2270
|
+
return testdriverPath;
|
|
2271
|
+
}
|
|
2272
|
+
currentDir = path.dirname(currentDir);
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// Fallback to workingDir/testdriver if not found
|
|
2276
|
+
return path.join(this.workingDir, "testdriver");
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// Helper method to resolve file paths relative to the testdriver directory
|
|
2280
|
+
// This handles both snippets and other relative files that should be resolved
|
|
2281
|
+
// relative to the nearest testdriver directory
|
|
2282
|
+
resolveTestDriverRelativePath(filePath, relativePath) {
|
|
2283
|
+
// If it's already an absolute path, return as-is
|
|
2284
|
+
if (path.isAbsolute(relativePath)) {
|
|
2285
|
+
return relativePath;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// Check if this looks like a snippet or lifecycle reference
|
|
2289
|
+
if (
|
|
2290
|
+
relativePath.startsWith("snippets/") ||
|
|
2291
|
+
relativePath.startsWith("lifecycle/")
|
|
2292
|
+
) {
|
|
2293
|
+
// First, check if there's a local directory in the same directory as the current file
|
|
2294
|
+
if (filePath) {
|
|
2295
|
+
const currentFileDir = path.dirname(path.resolve(filePath));
|
|
2296
|
+
const localPath = path.join(currentFileDir, relativePath);
|
|
2297
|
+
|
|
2298
|
+
if (fs.existsSync(localPath)) {
|
|
2299
|
+
return localPath;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
// If no local file found, fall back to the testdriver directory
|
|
2304
|
+
const testdriverDir = this.findTestDriverDirectory(filePath);
|
|
2305
|
+
return path.join(testdriverDir, relativePath);
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
// For other relative paths, resolve relative to the current file's directory
|
|
2309
|
+
if (filePath) {
|
|
2310
|
+
return path.resolve(path.dirname(filePath), relativePath);
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// Fallback to workingDir
|
|
2314
|
+
return path.resolve(this.workingDir, relativePath);
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
async runLifecycle(lifecycleName) {
|
|
2318
|
+
// Use the current file path from sourceMapper to find the lifecycle directory
|
|
2319
|
+
// If sourceMapper doesn't have a current file, use thisFile which should be the file being run
|
|
2320
|
+
let currentFilePath = this.sourceMapper.currentFilePath || this.thisFile;
|
|
2321
|
+
|
|
2322
|
+
// If we still don't have a currentFilePath, fall back to the default testdriver directory
|
|
2323
|
+
if (!currentFilePath) {
|
|
2324
|
+
currentFilePath = path.join(
|
|
2325
|
+
this.workingDir,
|
|
2326
|
+
"testdriver",
|
|
2327
|
+
"testdriver.yaml",
|
|
2328
|
+
);
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// Ensure we have an absolute path
|
|
2332
|
+
if (currentFilePath && !path.isAbsolute(currentFilePath)) {
|
|
2333
|
+
currentFilePath = path.resolve(this.workingDir, currentFilePath);
|
|
2334
|
+
}
|
|
2335
|
+
let lifecycleFile = null;
|
|
2336
|
+
|
|
2337
|
+
// First, check if there's a local lifecycle directory in the same directory as the current file
|
|
2338
|
+
if (currentFilePath) {
|
|
2339
|
+
const currentFileDir = path.dirname(currentFilePath);
|
|
2340
|
+
const localLifecycleDir = path.join(currentFileDir, "lifecycle");
|
|
2341
|
+
const localLifecycleFile = path.join(
|
|
2342
|
+
localLifecycleDir,
|
|
2343
|
+
`${lifecycleName}.yaml`,
|
|
2344
|
+
);
|
|
2345
|
+
// If there's a local lifecycle directory, only look there (don't fall back to global)
|
|
2346
|
+
if (
|
|
2347
|
+
fs.existsSync(localLifecycleDir) &&
|
|
2348
|
+
fs.statSync(localLifecycleDir).isDirectory()
|
|
2349
|
+
) {
|
|
2350
|
+
if (fs.existsSync(localLifecycleFile)) {
|
|
2351
|
+
lifecycleFile = localLifecycleFile;
|
|
2352
|
+
}
|
|
2353
|
+
// Stop here - don't fall back to global if local lifecycle directory exists
|
|
2354
|
+
} else {
|
|
2355
|
+
// Only fall back to global if there's no local lifecycle directory
|
|
2356
|
+
const testdriverDir = this.findTestDriverDirectory(currentFilePath);
|
|
2357
|
+
const globalLifecycleFile = path.join(
|
|
2358
|
+
testdriverDir,
|
|
2359
|
+
"lifecycle",
|
|
2360
|
+
`${lifecycleName}.yaml`,
|
|
2361
|
+
);
|
|
2362
|
+
if (fs.existsSync(globalLifecycleFile)) {
|
|
2363
|
+
lifecycleFile = globalLifecycleFile;
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
this.emitter.emit(events.log.log, lifecycleFile);
|
|
2369
|
+
|
|
2370
|
+
if (lifecycleFile) {
|
|
2371
|
+
// Store current source mapping state before running lifecycle file
|
|
2372
|
+
const previousContext = this.sourceMapper.saveContext();
|
|
2373
|
+
|
|
2374
|
+
try {
|
|
2375
|
+
await this.run(lifecycleFile, false, false);
|
|
2376
|
+
} finally {
|
|
2377
|
+
// Restore previous source mapping state after lifecycle file execution
|
|
2378
|
+
this.sourceMapper.restoreContext(previousContext);
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
} // Unified command definitions that work for both CLI and interactive modes
|
|
2382
|
+
getCommandDefinitions() {
|
|
2383
|
+
return createCommandDefinitions(this);
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
// Execute a unified command
|
|
2387
|
+
async executeUnifiedCommand(commandName, args = {}, options = {}) {
|
|
2388
|
+
const commands = this.getCommandDefinitions();
|
|
2389
|
+
const command = commands[commandName];
|
|
2390
|
+
|
|
2391
|
+
if (!command) {
|
|
2392
|
+
throw new Error(`Unknown command: ${commandName}`);
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// Convert args array to object if needed
|
|
2396
|
+
const argsObj = {};
|
|
2397
|
+
if (Array.isArray(args)) {
|
|
2398
|
+
// Get argument definitions from the command
|
|
2399
|
+
const argDefs = command.args ? Object.values(command.args) : [];
|
|
2400
|
+
const argNames = command.args ? Object.keys(command.args) : [];
|
|
2401
|
+
|
|
2402
|
+
// Handle both positional args (/run myfile) and named args (/run file=myfile)
|
|
2403
|
+
args.forEach((arg, index) => {
|
|
2404
|
+
if (typeof arg === "string" && arg.includes("=")) {
|
|
2405
|
+
// Named argument: file=myfile or path=myfile
|
|
2406
|
+
const [key, value] = arg.split("=", 2);
|
|
2407
|
+
// Support both 'file' and 'path' for the run command
|
|
2408
|
+
if (commandName === "run" && key === "path") {
|
|
2409
|
+
argsObj["file"] = value;
|
|
2410
|
+
} else {
|
|
2411
|
+
argsObj[key] = value;
|
|
2412
|
+
}
|
|
2413
|
+
} else {
|
|
2414
|
+
// Positional argument: myfile
|
|
2415
|
+
const argName = argNames[index];
|
|
2416
|
+
if (argName) {
|
|
2417
|
+
const argDef = argDefs[index];
|
|
2418
|
+
if (argDef && argDef.variadic) {
|
|
2419
|
+
argsObj[argName] = args.slice(index);
|
|
2420
|
+
} else {
|
|
2421
|
+
argsObj[argName] = arg;
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
});
|
|
2426
|
+
|
|
2427
|
+
// Apply defaults for any missing arguments
|
|
2428
|
+
argNames.forEach((argName, index) => {
|
|
2429
|
+
const argDef = argDefs[index];
|
|
2430
|
+
if (argsObj[argName] === undefined && argDef && argDef.default) {
|
|
2431
|
+
argsObj[argName] = argDef.default;
|
|
2432
|
+
}
|
|
2433
|
+
});
|
|
2434
|
+
} else {
|
|
2435
|
+
Object.assign(argsObj, args);
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
// Move environment setup and special handling here
|
|
2439
|
+
if (["edit", "run", "generate"].includes(commandName)) {
|
|
2440
|
+
await this.buildEnv(options);
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
if (commandName === "run") {
|
|
2444
|
+
this.errorLimit = 100;
|
|
2445
|
+
}
|
|
2446
|
+
await command.handler(argsObj, options);
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
module.exports = TestDriverAgent;
|