@testdriverai/agent 7.8.0-canary.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +7 -0
- package/.env.example +4 -0
- package/.prettierignore +4 -0
- package/.prettierrc +1 -0
- package/CHANGELOG.md +953 -0
- package/README.md +81 -0
- package/agent/events.js +135 -0
- package/agent/index.js +2450 -0
- package/agent/interface.js +35 -0
- package/agent/lib/analytics.js +22 -0
- package/agent/lib/censorship.js +75 -0
- package/agent/lib/commander.js +246 -0
- package/agent/lib/commands.js +1684 -0
- package/agent/lib/config.js +60 -0
- package/agent/lib/generator.js +91 -0
- package/agent/lib/http.js +144 -0
- package/agent/lib/logger.js +56 -0
- package/agent/lib/outputs.js +29 -0
- package/agent/lib/parser.js +209 -0
- package/agent/lib/redraw.js +386 -0
- package/agent/lib/resources/cursor-2.png +0 -0
- package/agent/lib/sandbox.js +1104 -0
- package/agent/lib/sdk.js +633 -0
- package/agent/lib/session.js +25 -0
- package/agent/lib/source-mapper.js +342 -0
- package/agent/lib/subimage/index.js +77 -0
- package/agent/lib/subimage/opencv.js +69 -0
- package/agent/lib/system.js +204 -0
- package/agent/lib/theme.js +14 -0
- package/agent/lib/valid-version.js +21 -0
- package/agent/lib/validation.js +169 -0
- package/ai/.claude-plugin/plugin.json +9 -0
- package/ai/agents/testdriver.md +638 -0
- package/ai/skills/testdriver-ai/SKILL.md +204 -0
- package/ai/skills/testdriver-assert/SKILL.md +315 -0
- package/ai/skills/testdriver-aws-setup/SKILL.md +448 -0
- package/ai/skills/testdriver-cache/SKILL.md +221 -0
- package/ai/skills/testdriver-caching/SKILL.md +124 -0
- package/ai/skills/testdriver-captcha/SKILL.md +158 -0
- package/ai/skills/testdriver-ci-cd/SKILL.md +602 -0
- package/ai/skills/testdriver-click/SKILL.md +286 -0
- package/ai/skills/testdriver-client/SKILL.md +477 -0
- package/ai/skills/testdriver-cloud/SKILL.md +119 -0
- package/ai/skills/testdriver-customizing-devices/SKILL.md +319 -0
- package/ai/skills/testdriver-dashcam/SKILL.md +418 -0
- package/ai/skills/testdriver-debugging-with-screenshots/SKILL.md +401 -0
- package/ai/skills/testdriver-device-config/SKILL.md +317 -0
- package/ai/skills/testdriver-double-click/SKILL.md +102 -0
- package/ai/skills/testdriver-elements/SKILL.md +605 -0
- package/ai/skills/testdriver-enterprise/SKILL.md +114 -0
- package/ai/skills/testdriver-errors/SKILL.md +246 -0
- package/ai/skills/testdriver-events/SKILL.md +356 -0
- package/ai/skills/testdriver-examples/SKILL.md +7 -0
- package/ai/skills/testdriver-exec/SKILL.md +317 -0
- package/ai/skills/testdriver-find/SKILL.md +829 -0
- package/ai/skills/testdriver-focus-application/SKILL.md +293 -0
- package/ai/skills/testdriver-generating-tests/SKILL.md +36 -0
- package/ai/skills/testdriver-hover/SKILL.md +278 -0
- package/ai/skills/testdriver-locating-elements/SKILL.md +71 -0
- package/ai/skills/testdriver-making-assertions/SKILL.md +32 -0
- package/ai/skills/testdriver-mcp/SKILL.md +7 -0
- package/ai/skills/testdriver-mcp-workflow/SKILL.md +410 -0
- package/ai/skills/testdriver-mouse-down/SKILL.md +161 -0
- package/ai/skills/testdriver-mouse-up/SKILL.md +164 -0
- package/ai/skills/testdriver-parse/SKILL.md +236 -0
- package/ai/skills/testdriver-performing-actions/SKILL.md +54 -0
- package/ai/skills/testdriver-press-keys/SKILL.md +348 -0
- package/ai/skills/testdriver-provision/SKILL.md +331 -0
- package/ai/skills/testdriver-quickstart/SKILL.md +144 -0
- package/ai/skills/testdriver-redraw/SKILL.md +214 -0
- package/ai/skills/testdriver-reusable-code/SKILL.md +249 -0
- package/ai/skills/testdriver-right-click/SKILL.md +123 -0
- package/ai/skills/testdriver-running-tests/SKILL.md +185 -0
- package/ai/skills/testdriver-screenshot/SKILL.md +248 -0
- package/ai/skills/testdriver-screenshots/SKILL.md +184 -0
- package/ai/skills/testdriver-scroll/SKILL.md +335 -0
- package/ai/skills/testdriver-secrets/SKILL.md +115 -0
- package/ai/skills/testdriver-self-hosted/SKILL.md +65 -0
- package/ai/skills/testdriver-test-writer/SKILL.md +448 -0
- package/ai/skills/testdriver-testdriver/SKILL.md +628 -0
- package/ai/skills/testdriver-testdriver-mechanic/SKILL.md +165 -0
- package/ai/skills/testdriver-type/SKILL.md +357 -0
- package/ai/skills/testdriver-variables/SKILL.md +111 -0
- package/ai/skills/testdriver-wait/SKILL.md +50 -0
- package/ai/skills/testdriver-waiting-for-elements/SKILL.md +90 -0
- package/ai/skills/testdriver-what-is-testdriver/SKILL.md +54 -0
- package/bin/testdriverai.js +22 -0
- package/debugger/bg.png +0 -0
- package/debugger/icon.png +0 -0
- package/debugger/index.html +469 -0
- package/debugger/td.png +0 -0
- package/debugger/tray-buffered.png +0 -0
- package/debugger/tray.png +0 -0
- package/docs/GITHUB_COMMENTS.md +330 -0
- package/docs/GITHUB_COMMENTS_ANNOUNCEMENT.md +167 -0
- package/docs/QUICK-START-GITHUB-COMMENTS.md +84 -0
- package/docs/TEST-GITHUB-COMMENTS.md +129 -0
- package/docs/_data/examples-manifest.json +177 -0
- package/docs/_data/examples-manifest.schema.json +41 -0
- package/docs/_scripts/extract-example-urls.js +165 -0
- package/docs/_scripts/generate-examples.js +560 -0
- package/docs/_scripts/generate-skills.js +154 -0
- package/docs/_scripts/link-replacer.js +164 -0
- package/docs/_scripts/upload-docs-to-openai.js +284 -0
- package/docs/changelog.mdx +161 -0
- package/docs/claude-mcp-plugin.mdx +160 -0
- package/docs/docs.json +442 -0
- package/docs/github-integration-setup.md +266 -0
- package/docs/guide/best-practices-polling.mdx +174 -0
- package/docs/images/content/account/newprojectsettings.png +0 -0
- package/docs/images/content/account/projectpage.png +0 -0
- package/docs/images/content/account/projectreplays.png +0 -0
- package/docs/images/content/account/team-manage.png +0 -0
- package/docs/images/content/account/teampage.png +0 -0
- package/docs/images/content/extension/cursor.svg +1 -0
- package/docs/images/content/extension/vscode.svg +57 -0
- package/docs/images/content/extension/windsurf.svg +3 -0
- package/docs/images/content/parse/output.png +0 -0
- package/docs/images/content/self-hosted/launchtemplateid.png +0 -0
- package/docs/images/content/side-by-side.png +0 -0
- package/docs/images/content/vscode/ide-full.png +0 -0
- package/docs/images/content/vscode/running.png +0 -0
- package/docs/images/content/vscode/v7-chat.png +0 -0
- package/docs/images/content/vscode/v7-choose-agent.png +0 -0
- package/docs/images/content/vscode/v7-full.png +0 -0
- package/docs/images/content/vscode/v7-onboarding.png +0 -0
- package/docs/images/content/vscode/vscode-2-assert.png +0 -0
- package/docs/images/content/vscode/vscode-agent-preview.png +0 -0
- package/docs/images/content/vscode/vscode-copilot-ask.png +0 -0
- package/docs/images/content/vscode/vscode-file-creation.png +0 -0
- package/docs/images/content/vscode/vscode-install.png +0 -0
- package/docs/images/content/vscode/vscode-overview.png +0 -0
- package/docs/images/content/vscode/vscode-setup-walkthrough.png +0 -0
- package/docs/images/content/vscode/vscode-stopchat.png +0 -0
- package/docs/images/content/vscode/vscode-stoptest.png +0 -0
- package/docs/images/content/vscode/vscode-tdservice.png +0 -0
- package/docs/images/content/vscode/vscode-test-output.png +0 -0
- package/docs/images/content/vscode/vscode-testhistory.png +0 -0
- package/docs/images/content/vscode/vscode-testpane-runtests.png +0 -0
- package/docs/images/content/vscode/vscode-testpane.png +0 -0
- package/docs/images/template/dark.png +0 -0
- package/docs/images/template/icon.png +0 -0
- package/docs/images/template/light.png +0 -0
- package/docs/snippets/calendar-link.mdx +4 -0
- package/docs/snippets/gitignore-warning.mdx +7 -0
- package/docs/snippets/lifecycle-warning.mdx +6 -0
- package/docs/snippets/test-prereqs.mdx +12 -0
- package/docs/snippets/tests/assert-replay.mdx +7 -0
- package/docs/snippets/tests/assert-yaml.mdx +8 -0
- package/docs/snippets/tests/exec-js-replay.mdx +7 -0
- package/docs/snippets/tests/exec-js-yaml.mdx +32 -0
- package/docs/snippets/tests/exec-shell-replay.mdx +7 -0
- package/docs/snippets/tests/exec-shell-yaml.mdx +15 -0
- package/docs/snippets/tests/hover-image-replay.mdx +7 -0
- package/docs/snippets/tests/hover-image-yaml.mdx +17 -0
- package/docs/snippets/tests/hover-text-replay.mdx +7 -0
- package/docs/snippets/tests/hover-text-with-description-replay.mdx +7 -0
- package/docs/snippets/tests/hover-text-with-description-yaml.mdx +24 -0
- package/docs/snippets/tests/hover-text-yaml.mdx +14 -0
- package/docs/snippets/tests/match-image-replay.mdx +7 -0
- package/docs/snippets/tests/match-image-yaml.mdx +17 -0
- package/docs/snippets/tests/press-keys-replay.mdx +7 -0
- package/docs/snippets/tests/press-keys-yaml.mdx +36 -0
- package/docs/snippets/tests/remember-replay.mdx +7 -0
- package/docs/snippets/tests/remember-yaml.mdx +28 -0
- package/docs/snippets/tests/scroll-replay.mdx +7 -0
- package/docs/snippets/tests/scroll-until-image-replay.mdx +7 -0
- package/docs/snippets/tests/scroll-until-image-yaml.mdx +14 -0
- package/docs/snippets/tests/scroll-until-text-replay.mdx +7 -0
- package/docs/snippets/tests/scroll-until-text-yaml.mdx +17 -0
- package/docs/snippets/tests/scroll-yaml.mdx +30 -0
- package/docs/snippets/tests/type-repeated-replay.mdx +7 -0
- package/docs/snippets/tests/type-repeated-yaml.mdx +22 -0
- package/docs/snippets/tests/type-replay.mdx +7 -0
- package/docs/snippets/tests/type-yaml.mdx +28 -0
- package/docs/snippets/tests/wait-for-image-replay.mdx +7 -0
- package/docs/snippets/tests/wait-for-image-yaml.mdx +18 -0
- package/docs/snippets/tests/wait-for-text-replay.mdx +7 -0
- package/docs/snippets/tests/wait-for-text-yaml.mdx +18 -0
- package/docs/snippets/tests/wait-replay.mdx +7 -0
- package/docs/snippets/tests/wait-yaml.mdx +13 -0
- package/docs/styles.css +65 -0
- package/docs/v6/account/dashboard.mdx +16 -0
- package/docs/v6/account/enterprise.mdx +110 -0
- package/docs/v6/account/pricing.mdx +33 -0
- package/docs/v6/account/projects.mdx +33 -0
- package/docs/v6/account/team.mdx +35 -0
- package/docs/v6/action/ami.mdx +109 -0
- package/docs/v6/action/performance.mdx +105 -0
- package/docs/v6/action/secrets.mdx +93 -0
- package/docs/v6/apps/chrome-extensions.mdx +48 -0
- package/docs/v6/apps/desktop-apps.mdx +93 -0
- package/docs/v6/apps/mobile-apps.mdx +26 -0
- package/docs/v6/apps/static-websites.mdx +54 -0
- package/docs/v6/apps/tauri-apps.mdx +361 -0
- package/docs/v6/bugs/jira.mdx +232 -0
- package/docs/v6/cli/overview.mdx +66 -0
- package/docs/v6/commands/assert.mdx +45 -0
- package/docs/v6/commands/exec.mdx +276 -0
- package/docs/v6/commands/focus-application.mdx +44 -0
- package/docs/v6/commands/hover-image.mdx +69 -0
- package/docs/v6/commands/hover-text.mdx +47 -0
- package/docs/v6/commands/if.mdx +53 -0
- package/docs/v6/commands/match-image.mdx +67 -0
- package/docs/v6/commands/press-keys.mdx +87 -0
- package/docs/v6/commands/remember.mdx +49 -0
- package/docs/v6/commands/run.mdx +44 -0
- package/docs/v6/commands/scroll-until-image.mdx +66 -0
- package/docs/v6/commands/scroll-until-text.mdx +60 -0
- package/docs/v6/commands/scroll.mdx +69 -0
- package/docs/v6/commands/type.mdx +45 -0
- package/docs/v6/commands/wait-for-image.mdx +54 -0
- package/docs/v6/commands/wait-for-text.mdx +48 -0
- package/docs/v6/commands/wait.mdx +45 -0
- package/docs/v6/exporting/junit.mdx +218 -0
- package/docs/v6/exporting/playwright.mdx +197 -0
- package/docs/v6/features/auto-healing.mdx +144 -0
- package/docs/v6/features/generation.mdx +116 -0
- package/docs/v6/features/parallel-testing.mdx +151 -0
- package/docs/v6/features/reusable-snippets.mdx +131 -0
- package/docs/v6/features/selectorless.mdx +80 -0
- package/docs/v6/features/visual-assertions.mdx +139 -0
- package/docs/v6/getting-started/ci.mdx +146 -0
- package/docs/v6/getting-started/cli.mdx +91 -0
- package/docs/v6/getting-started/editing.mdx +100 -0
- package/docs/v6/getting-started/playwright.mdx +342 -0
- package/docs/v6/getting-started/running.mdx +48 -0
- package/docs/v6/getting-started/self-hosting.mdx +408 -0
- package/docs/v6/getting-started/vscode.mdx +88 -0
- package/docs/v6/guide/assertions.mdx +189 -0
- package/docs/v6/guide/authentication.mdx +136 -0
- package/docs/v6/guide/code.mdx +65 -0
- package/docs/v6/guide/dashcam.mdx +118 -0
- package/docs/v6/guide/environment-variables.mdx +26 -0
- package/docs/v6/guide/lifecycle.mdx +242 -0
- package/docs/v6/guide/locating.mdx +141 -0
- package/docs/v6/guide/protips.mdx +43 -0
- package/docs/v6/guide/variables.mdx +143 -0
- package/docs/v6/guide/waiting.mdx +130 -0
- package/docs/v6/importing/csv.mdx +196 -0
- package/docs/v6/importing/gherkin.mdx +143 -0
- package/docs/v6/importing/jira.mdx +164 -0
- package/docs/v6/importing/testrail.mdx +162 -0
- package/docs/v6/integrations/electron.mdx +146 -0
- package/docs/v6/integrations/netlify.mdx +100 -0
- package/docs/v6/integrations/vercel.mdx +125 -0
- package/docs/v6/interactive/explore.mdx +99 -0
- package/docs/v6/interactive/run.mdx +52 -0
- package/docs/v6/interactive/save.mdx +63 -0
- package/docs/v6/overview/comparison.mdx +101 -0
- package/docs/v6/overview/faq.mdx +162 -0
- package/docs/v6/overview/performance.mdx +52 -0
- package/docs/v6/overview/quickstart.mdx +137 -0
- package/docs/v6/overview/what-is-testdriver.mdx +85 -0
- package/docs/v6/scenarios/ai-chatbot.mdx +28 -0
- package/docs/v6/scenarios/cookie-banner.mdx +32 -0
- package/docs/v6/scenarios/file-upload.mdx +33 -0
- package/docs/v6/scenarios/form-filling.mdx +32 -0
- package/docs/v6/scenarios/log-in.mdx +75 -0
- package/docs/v6/scenarios/pdf-generation.mdx +25 -0
- package/docs/v6/scenarios/spell-check.mdx +22 -0
- package/docs/v6/security/action.mdx +84 -0
- package/docs/v6/security/agent.mdx +73 -0
- package/docs/v6/security/platform.mdx +77 -0
- package/docs/v6/tutorials/advanced-test.mdx +81 -0
- package/docs/v6/tutorials/basic-test.mdx +45 -0
- package/docs/v7/_drafts/agents.mdx +843 -0
- package/docs/v7/_drafts/architecture.mdx +399 -0
- package/docs/v7/_drafts/auto-cache-key.mdx +167 -0
- package/docs/v7/_drafts/awesome-logs-quick-ref.mdx +100 -0
- package/docs/v7/_drafts/best-practices.mdx +486 -0
- package/docs/v7/_drafts/caching-ai.mdx +215 -0
- package/docs/v7/_drafts/caching-selectors.mdx +424 -0
- package/docs/v7/_drafts/caching.mdx +366 -0
- package/docs/v7/_drafts/cli-to-sdk-migration.mdx +425 -0
- package/docs/v7/_drafts/commands/assert.mdx +45 -0
- package/docs/v7/_drafts/commands/exec.mdx +276 -0
- package/docs/v7/_drafts/commands/focus-application.mdx +44 -0
- package/docs/v7/_drafts/commands/hover-image.mdx +69 -0
- package/docs/v7/_drafts/commands/hover-text.mdx +47 -0
- package/docs/v7/_drafts/commands/if.mdx +53 -0
- package/docs/v7/_drafts/commands/match-image.mdx +67 -0
- package/docs/v7/_drafts/commands/press-keys.mdx +87 -0
- package/docs/v7/_drafts/commands/remember.mdx +49 -0
- package/docs/v7/_drafts/commands/run.mdx +44 -0
- package/docs/v7/_drafts/commands/scroll-until-image.mdx +66 -0
- package/docs/v7/_drafts/commands/scroll-until-text.mdx +60 -0
- package/docs/v7/_drafts/commands/scroll.mdx +69 -0
- package/docs/v7/_drafts/commands/type.mdx +45 -0
- package/docs/v7/_drafts/commands/wait-for-image.mdx +54 -0
- package/docs/v7/_drafts/commands/wait-for-text.mdx +48 -0
- package/docs/v7/_drafts/commands/wait.mdx +45 -0
- package/docs/v7/_drafts/configuration.mdx +378 -0
- package/docs/v7/_drafts/contributing.mdx +174 -0
- package/docs/v7/_drafts/core.mdx +458 -0
- package/docs/v7/_drafts/dashcam-title-feature.mdx +89 -0
- package/docs/v7/_drafts/debugging.mdx +349 -0
- package/docs/v7/_drafts/error-handling.mdx +501 -0
- package/docs/v7/_drafts/faq.mdx +393 -0
- package/docs/v7/_drafts/hooks.mdx +360 -0
- package/docs/v7/_drafts/init-command.mdx +95 -0
- package/docs/v7/_drafts/installation.mdx +420 -0
- package/docs/v7/_drafts/migration.mdx +562 -0
- package/docs/v7/_drafts/observable.mdx +604 -0
- package/docs/v7/_drafts/playwright.mdx +342 -0
- package/docs/v7/_drafts/plugin-migration.mdx +220 -0
- package/docs/v7/_drafts/powerful.mdx +419 -0
- package/docs/v7/_drafts/presets.mdx +210 -0
- package/docs/v7/_drafts/progressive-disclosure.mdx +230 -0
- package/docs/v7/_drafts/prompt-cache.mdx +200 -0
- package/docs/v7/_drafts/provision.mdx +390 -0
- package/docs/v7/_drafts/quick-start-test-recording.mdx +214 -0
- package/docs/v7/_drafts/readme.mdx +135 -0
- package/docs/v7/_drafts/reports.mdx +414 -0
- package/docs/v7/_drafts/scalable.mdx +763 -0
- package/docs/v7/_drafts/screenshot.mdx +155 -0
- package/docs/v7/_drafts/sdk-awesome-logs.mdx +468 -0
- package/docs/v7/_drafts/sdk-browser-rendering.mdx +167 -0
- package/docs/v7/_drafts/sdk-migration.mdx +474 -0
- package/docs/v7/_drafts/sdk-v7-complete.mdx +345 -0
- package/docs/v7/_drafts/self-hosting.mdx +369 -0
- package/docs/v7/_drafts/test-recording.mdx +382 -0
- package/docs/v7/_drafts/troubleshooting.mdx +526 -0
- package/docs/v7/_drafts/vitest-plugin.mdx +477 -0
- package/docs/v7/_drafts/vitest.mdx +535 -0
- package/docs/v7/_drafts/writing-tests.mdx +25 -0
- package/docs/v7/ai.mdx +205 -0
- package/docs/v7/assert.mdx +316 -0
- package/docs/v7/aws-setup.mdx +449 -0
- package/docs/v7/cache.mdx +223 -0
- package/docs/v7/caching.mdx +128 -0
- package/docs/v7/captcha.mdx +159 -0
- package/docs/v7/ci-cd.mdx +603 -0
- package/docs/v7/click.mdx +287 -0
- package/docs/v7/client.mdx +478 -0
- package/docs/v7/copilot/auto-healing.mdx +265 -0
- package/docs/v7/copilot/creating-tests.mdx +156 -0
- package/docs/v7/copilot/github.mdx +143 -0
- package/docs/v7/copilot/running-tests.mdx +149 -0
- package/docs/v7/copilot/setup.mdx +143 -0
- package/docs/v7/customizing-devices.mdx +319 -0
- package/docs/v7/dashcam.mdx +419 -0
- package/docs/v7/debugging-with-screenshots.mdx +402 -0
- package/docs/v7/device-config.mdx +317 -0
- package/docs/v7/double-click.mdx +102 -0
- package/docs/v7/elements.mdx +606 -0
- package/docs/v7/enterprise.mdx +9 -0
- package/docs/v7/errors.mdx +248 -0
- package/docs/v7/events.mdx +358 -0
- package/docs/v7/examples/ai.mdx +72 -0
- package/docs/v7/examples/assert.mdx +72 -0
- package/docs/v7/examples/captcha-api.mdx +92 -0
- package/docs/v7/examples/chrome-extension.mdx +132 -0
- package/docs/v7/examples/drag-and-drop.mdx +100 -0
- package/docs/v7/examples/element-not-found.mdx +67 -0
- package/docs/v7/examples/exec-output.mdx +85 -0
- package/docs/v7/examples/exec-pwsh.mdx +83 -0
- package/docs/v7/examples/focus-window.mdx +62 -0
- package/docs/v7/examples/hover-image.mdx +94 -0
- package/docs/v7/examples/hover-text.mdx +69 -0
- package/docs/v7/examples/installer.mdx +91 -0
- package/docs/v7/examples/launch-vscode-linux.mdx +101 -0
- package/docs/v7/examples/match-image.mdx +96 -0
- package/docs/v7/examples/press-keys.mdx +92 -0
- package/docs/v7/examples/scroll-keyboard.mdx +79 -0
- package/docs/v7/examples/scroll-until-image.mdx +81 -0
- package/docs/v7/examples/scroll-until-text.mdx +109 -0
- package/docs/v7/examples/scroll.mdx +81 -0
- package/docs/v7/examples/type.mdx +92 -0
- package/docs/v7/examples/windows-installer.mdx +89 -0
- package/docs/v7/exec.mdx +318 -0
- package/docs/v7/find.mdx +830 -0
- package/docs/v7/focus-application.mdx +294 -0
- package/docs/v7/generating-tests.mdx +36 -0
- package/docs/v7/hosted.mdx +158 -0
- package/docs/v7/hover.mdx +279 -0
- package/docs/v7/locating-elements.mdx +71 -0
- package/docs/v7/making-assertions.mdx +32 -0
- package/docs/v7/mcp.mdx +9 -0
- package/docs/v7/mouse-down.mdx +161 -0
- package/docs/v7/mouse-up.mdx +164 -0
- package/docs/v7/parse.mdx +237 -0
- package/docs/v7/performing-actions.mdx +54 -0
- package/docs/v7/press-keys.mdx +349 -0
- package/docs/v7/provision.mdx +333 -0
- package/docs/v7/quickstart.mdx +173 -0
- package/docs/v7/redraw.mdx +216 -0
- package/docs/v7/reusable-code.mdx +249 -0
- package/docs/v7/right-click.mdx +123 -0
- package/docs/v7/running-tests.mdx +185 -0
- package/docs/v7/screenshot.mdx +249 -0
- package/docs/v7/screenshots.mdx +186 -0
- package/docs/v7/scroll.mdx +336 -0
- package/docs/v7/secrets.mdx +115 -0
- package/docs/v7/self-hosted.mdx +149 -0
- package/docs/v7/type.mdx +358 -0
- package/docs/v7/variables.mdx +111 -0
- package/docs/v7/wait.mdx +52 -0
- package/docs/v7/waiting-for-elements.mdx +90 -0
- package/docs/v7/what-is-testdriver.mdx +54 -0
- package/eslint.config.js +67 -0
- package/examples/ai.test.mjs +31 -0
- package/examples/assert.test.mjs +47 -0
- package/examples/chrome-extension.test.mjs +97 -0
- package/examples/config.mjs +5 -0
- package/examples/element-not-found.test.mjs +27 -0
- package/examples/exec-output.test.mjs +60 -0
- package/examples/exec-pwsh.test.mjs +58 -0
- package/examples/findall-coffee-icons.test.mjs +42 -0
- package/examples/focus-window.test.mjs +37 -0
- package/examples/formatted-logging.test.mjs +27 -0
- package/examples/hover-image.test.mjs +53 -0
- package/examples/hover-text-with-description.test.mjs +57 -0
- package/examples/hover-text.test.mjs +28 -0
- package/examples/installer.test.mjs +50 -0
- package/examples/launch-vscode-linux.test.mjs +55 -0
- package/examples/match-image.test.mjs +55 -0
- package/examples/parse.test.mjs +19 -0
- package/examples/press-keys.test.mjs +44 -0
- package/examples/prompt.test.mjs +34 -0
- package/examples/scroll-keyboard.test.mjs +38 -0
- package/examples/scroll-until-image.test.mjs +40 -0
- package/examples/scroll.test.mjs +42 -0
- package/examples/type.test.mjs +46 -0
- package/examples/windows-installer.test.mjs +54 -0
- package/index.js +2 -0
- package/interfaces/cli/commands/init.js +438 -0
- package/interfaces/cli/commands/setup.js +382 -0
- package/interfaces/cli/lib/base.js +285 -0
- package/interfaces/cli.js +20 -0
- package/interfaces/junit-reporter.js +290 -0
- package/interfaces/logger.js +388 -0
- package/interfaces/readline.js +234 -0
- package/interfaces/shared-test-state.mjs +64 -0
- package/interfaces/vitest-plugin.d.ts +115 -0
- package/interfaces/vitest-plugin.mjs +1698 -0
- package/lib/captcha/solver.js +358 -0
- package/lib/core/Dashcam.js +533 -0
- package/lib/core/index.d.ts +172 -0
- package/lib/core/index.js +12 -0
- package/lib/environments.json +18 -0
- package/lib/github-comment-formatter.js +263 -0
- package/lib/github-comment.mjs +452 -0
- package/lib/init-project.js +575 -0
- package/lib/presets/index.mjs +331 -0
- package/lib/resolve-channel.js +46 -0
- package/lib/sentry.js +417 -0
- package/lib/vitest/hooks.d.ts +57 -0
- package/lib/vitest/hooks.mjs +674 -0
- package/lib/vitest/setup-aws.mjs +247 -0
- package/lib/vitest/setup-self-hosted.mjs +151 -0
- package/lib/vitest/setup.mjs +46 -0
- package/manual/captcha-api.test.mjs +51 -0
- package/manual/drag-and-drop.test.mjs +59 -0
- package/manual/flake-diffthreshold-001.test.mjs +9 -0
- package/manual/flake-diffthreshold-01.test.mjs +9 -0
- package/manual/flake-diffthreshold-05.test.mjs +9 -0
- package/manual/flake-noredraw-cache.test.mjs +9 -0
- package/manual/flake-noredraw-nocache.test.mjs +9 -0
- package/manual/flake-redraw-cache.test.mjs +9 -0
- package/manual/flake-redraw-nocache.test.mjs +9 -0
- package/manual/flake-rocket-match.test.mjs +30 -0
- package/manual/flake-shared.mjs +51 -0
- package/manual/no-provision.test.mjs +31 -0
- package/manual/packer-hover-image.test.mjs +176 -0
- package/manual/scroll-until-text.test.mjs +68 -0
- package/manual/test-init-command.js +223 -0
- package/mcp-server/README.md +322 -0
- package/mcp-server/dist/codegen.d.ts +9 -0
- package/mcp-server/dist/codegen.js +165 -0
- package/mcp-server/dist/mcp-app.html +114 -0
- package/mcp-server/dist/package.json +1 -0
- package/mcp-server/dist/provision-types.d.ts +290 -0
- package/mcp-server/dist/provision-types.js +174 -0
- package/mcp-server/dist/server.d.ts +6 -0
- package/mcp-server/dist/server.mjs +1925 -0
- package/mcp-server/dist/session.d.ts +85 -0
- package/mcp-server/dist/session.js +152 -0
- package/mcp-server/mcp-app.html +28 -0
- package/mcp-server/mcp-config.example.json +19 -0
- package/mcp-server/package-lock.json +4027 -0
- package/mcp-server/package.json +31 -0
- package/mcp-server/src/codegen.ts +189 -0
- package/mcp-server/src/mcp-app.css +360 -0
- package/mcp-server/src/mcp-app.ts +547 -0
- package/mcp-server/src/provision-types.ts +209 -0
- package/mcp-server/src/server.ts +2391 -0
- package/mcp-server/src/session.ts +194 -0
- package/mcp-server/tsconfig.json +16 -0
- package/mcp-server/vite.config.ts +23 -0
- package/package.json +158 -0
- package/schema.json +1046 -0
- package/scripts/generate-skills.js +94 -0
- package/sdk-log-formatter.js +1157 -0
- package/sdk.d.ts +1486 -0
- package/sdk.js +4336 -0
- package/setup/aws/cloudformation.yaml +463 -0
- package/setup/aws/disable-defender.sh +42 -0
- package/setup/aws/install-dev-runner.sh +79 -0
- package/setup/aws/spawn-runner.sh +289 -0
- package/test/captcha-solver.test.mjs +152 -0
- package/test/chrome-remote-debugging.test.mjs +66 -0
- package/test/duckduckgo/experiment.test.mjs +28 -0
- package/test/duckduckgo/setup.test.mjs +29 -0
- package/test/manual/debug-locate-response.js +82 -0
- package/test/manual/reconnect-provision.test.mjs +49 -0
- package/test/manual/test-console-logs.test.mjs +42 -0
- package/test/manual/test-find-api.js +73 -0
- package/test/manual/test-init.sh +54 -0
- package/test/manual/test-prompt-cache.js +97 -0
- package/test/manual/test-provision-auth.mjs +22 -0
- package/test/manual/test-sandbox-render.js +29 -0
- package/test/manual/test-sdk-methods.js +15 -0
- package/test/manual/test-sdk-refactor.js +53 -0
- package/test/manual/test-stack-trace.mjs +57 -0
- package/test/manual/verify-element-api.js +89 -0
- package/test/manual/verify-types.js +0 -0
- package/test/manual-unawaited-promise.test.mjs +31 -0
- package/vitest.config.mjs +58 -0
- package/vitest.runner.config.mjs +33 -0
- package/vscode-extension/.vscodeignore +12 -0
- package/vscode-extension/README.md +94 -0
- package/vscode-extension/media/icon.png +0 -0
- package/vscode-extension/package-lock.json +4126 -0
- package/vscode-extension/package.json +86 -0
- package/vscode-extension/src/extension.ts +829 -0
- package/vscode-extension/testdriverai-0.1.0.vsix +0 -0
- package/vscode-extension/tsconfig.json +16 -0
|
@@ -0,0 +1,1698 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { postOrUpdateTestResults } from "../lib/github-comment.mjs";
|
|
8
|
+
import { setTestRunInfo } from "./shared-test-state.mjs";
|
|
9
|
+
|
|
10
|
+
// Use createRequire to import CommonJS modules without esbuild processing
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const channelConfig = require("../lib/resolve-channel.js");
|
|
13
|
+
|
|
14
|
+
// Import Sentry for error reporting
|
|
15
|
+
const Sentry = require("@sentry/node");
|
|
16
|
+
const chalk = require("chalk");
|
|
17
|
+
|
|
18
|
+
// Track if Sentry has been initialized
|
|
19
|
+
let sentryInitialized = false;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Initialize Sentry for test failure reporting
|
|
23
|
+
* Uses same configuration as lib/sentry.js for consistency
|
|
24
|
+
*/
|
|
25
|
+
function initializeSentry() {
|
|
26
|
+
if (sentryInitialized) return;
|
|
27
|
+
|
|
28
|
+
// Respect telemetry opt-out
|
|
29
|
+
if (process.env.TD_TELEMETRY === "false") {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const version = resolveTestDriverVersion() || "unknown";
|
|
35
|
+
|
|
36
|
+
Sentry.init({
|
|
37
|
+
dsn:
|
|
38
|
+
process.env.SENTRY_DSN ||
|
|
39
|
+
"https://452bd5a00dbd83a38ee8813e11c57694@o4510262629236736.ingest.us.sentry.io/4510480443637760",
|
|
40
|
+
environment: "vitest",
|
|
41
|
+
release: version,
|
|
42
|
+
sampleRate: 1.0,
|
|
43
|
+
tracesSampleRate: 1.0,
|
|
44
|
+
enableLogs: true,
|
|
45
|
+
integrations: [Sentry.httpIntegration(), Sentry.nodeContextIntegration()],
|
|
46
|
+
initialScope: {
|
|
47
|
+
tags: {
|
|
48
|
+
platform: os.platform(),
|
|
49
|
+
arch: os.arch(),
|
|
50
|
+
nodeVersion: process.version,
|
|
51
|
+
runner: "vitest",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
// Filter out events that should not be reported to Sentry
|
|
55
|
+
beforeSend(event, hint) {
|
|
56
|
+
const error = hint.originalException;
|
|
57
|
+
|
|
58
|
+
// Don't send user-cancelled errors
|
|
59
|
+
if (error && error.message && error.message.includes("User cancelled")) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Don't send test failures - these are expected behavior, not bugs in the SDK
|
|
64
|
+
// Test failures indicate the test found a problem, which is the intended use case
|
|
65
|
+
if (event.exception?.values) {
|
|
66
|
+
for (const exception of event.exception.values) {
|
|
67
|
+
// Filter out TestFailure type (from Vitest test failures)
|
|
68
|
+
if (exception.type === "TestFailure") {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Filter out common user code errors (ReferenceError, TypeError from user tests)
|
|
73
|
+
// Only report if the error originates from TestDriver SDK code, not user test code
|
|
74
|
+
const isUserCodeError = exception.stacktrace?.frames?.some(frame => {
|
|
75
|
+
const filename = frame.filename || frame.abs_path || "";
|
|
76
|
+
// Check if error is from user test files (not from SDK internals)
|
|
77
|
+
return filename.includes("/tests/") ||
|
|
78
|
+
filename.includes("/test/") ||
|
|
79
|
+
filename.includes(".test.") ||
|
|
80
|
+
filename.includes(".spec.");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (isUserCodeError && (exception.type === "ReferenceError" || exception.type === "TypeError")) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Filter out ElementNotFoundError - expected test outcome, not a crash
|
|
88
|
+
if (exception.type === "ElementNotFoundError") {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return event;
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
sentryInitialized = true;
|
|
99
|
+
logger.debug("Sentry initialized for vitest");
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// Sentry init failed - continue without it
|
|
102
|
+
logger.debug("Failed to initialize Sentry:", err.message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Flush Sentry events before process exit
|
|
108
|
+
* @param {number} [timeout=2000] - Timeout in ms
|
|
109
|
+
*/
|
|
110
|
+
async function flushSentry(timeout = 2000) {
|
|
111
|
+
if (!sentryInitialized) return;
|
|
112
|
+
try {
|
|
113
|
+
await Sentry.flush(timeout);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
// Ignore flush errors
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the TestDriver SDK version using multiple strategies.
|
|
121
|
+
* Similar to resolveVitestVersion(), guards against import.meta.url rewriting.
|
|
122
|
+
* @returns {string|null}
|
|
123
|
+
*/
|
|
124
|
+
function resolveTestDriverVersion() {
|
|
125
|
+
try {
|
|
126
|
+
return require("../package.json").version;
|
|
127
|
+
} catch { }
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const cwdRequire = createRequire(path.join(process.cwd(), "package.json"));
|
|
131
|
+
return cwdRequire("testdriverai/package.json").version;
|
|
132
|
+
} catch { }
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const pkgPath = path.join(process.cwd(), "node_modules", "testdriverai", "package.json");
|
|
136
|
+
return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
|
|
137
|
+
} catch { }
|
|
138
|
+
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Minimum required Vitest major version
|
|
144
|
+
*/
|
|
145
|
+
const MINIMUM_VITEST_VERSION = 4;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Try to read vitest's package.json version using multiple resolution strategies.
|
|
149
|
+
* Vitest's Vite-based transform pipeline can rewrite import.meta.url, causing
|
|
150
|
+
* createRequire to resolve from the wrong location. We fall back to resolving
|
|
151
|
+
* from process.cwd() and then to reading directly from node_modules.
|
|
152
|
+
* @returns {string|null} The vitest version string, or null if not found
|
|
153
|
+
*/
|
|
154
|
+
function resolveVitestVersion() {
|
|
155
|
+
// Strategy 1: createRequire from import.meta.url (standard CJS interop)
|
|
156
|
+
try {
|
|
157
|
+
return require("vitest/package.json").version;
|
|
158
|
+
} catch { }
|
|
159
|
+
|
|
160
|
+
// Strategy 2: createRequire from process.cwd() (works when import.meta.url is rewritten)
|
|
161
|
+
try {
|
|
162
|
+
const cwdRequire = createRequire(path.join(process.cwd(), "package.json"));
|
|
163
|
+
return cwdRequire("vitest/package.json").version;
|
|
164
|
+
} catch { }
|
|
165
|
+
|
|
166
|
+
// Strategy 3: read directly from node_modules on disk
|
|
167
|
+
try {
|
|
168
|
+
const vitestPkgPath = path.join(process.cwd(), "node_modules", "vitest", "package.json");
|
|
169
|
+
return JSON.parse(fs.readFileSync(vitestPkgPath, "utf8")).version;
|
|
170
|
+
} catch { }
|
|
171
|
+
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Check that Vitest version meets minimum requirements
|
|
177
|
+
* @throws {Error} if Vitest version is below minimum or not installed
|
|
178
|
+
*/
|
|
179
|
+
function checkVitestVersion() {
|
|
180
|
+
const version = resolveVitestVersion();
|
|
181
|
+
|
|
182
|
+
if (!version) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
"TestDriver requires Vitest to be installed. " +
|
|
185
|
+
"Please install it: npm install vitest@latest",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const major = parseInt(version.split(".")[0], 10);
|
|
190
|
+
if (major < MINIMUM_VITEST_VERSION) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`TestDriver requires Vitest >= ${MINIMUM_VITEST_VERSION}.0.0, but found ${version}. ` +
|
|
193
|
+
`Please upgrade Vitest: npm install vitest@latest`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Check Vitest version at plugin load time
|
|
199
|
+
checkVitestVersion();
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Simple logger for the vitest plugin
|
|
203
|
+
* Supports log levels: debug, info, warn, error
|
|
204
|
+
* Control via TD_LOG_LEVEL environment variable (default: "info")
|
|
205
|
+
* Set TD_LOG_LEVEL=debug for verbose output
|
|
206
|
+
*/
|
|
207
|
+
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
208
|
+
const currentLogLevel =
|
|
209
|
+
LOG_LEVELS[process.env.TD_LOG_LEVEL?.toLowerCase()] ?? LOG_LEVELS.info;
|
|
210
|
+
|
|
211
|
+
const logger = {
|
|
212
|
+
debug: (...args) => {
|
|
213
|
+
if (currentLogLevel <= LOG_LEVELS.debug) {
|
|
214
|
+
console.log("[TestDriver]", ...args);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
info: (...args) => {
|
|
218
|
+
if (currentLogLevel <= LOG_LEVELS.info) {
|
|
219
|
+
console.log("[TestDriver]", ...args);
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
warn: (...args) => {
|
|
223
|
+
if (currentLogLevel <= LOG_LEVELS.warn) {
|
|
224
|
+
console.warn("[TestDriver]", ...args);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
error: (...args) => {
|
|
228
|
+
if (currentLogLevel <= LOG_LEVELS.error) {
|
|
229
|
+
console.error("[TestDriver]", ...args);
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Timeout wrapper for promises
|
|
236
|
+
* @param {Promise} promise - Promise to wrap
|
|
237
|
+
* @param {number} timeoutMs - Timeout in milliseconds
|
|
238
|
+
* @param {string} operationName - Name of operation for error message
|
|
239
|
+
* @returns {Promise} Promise that rejects if timeout is reached
|
|
240
|
+
*/
|
|
241
|
+
function withTimeout(promise, timeoutMs, operationName) {
|
|
242
|
+
return Promise.race([
|
|
243
|
+
promise,
|
|
244
|
+
new Promise((_, reject) =>
|
|
245
|
+
setTimeout(
|
|
246
|
+
() =>
|
|
247
|
+
reject(new Error(`${operationName} timed out after ${timeoutMs}ms`)),
|
|
248
|
+
timeoutMs,
|
|
249
|
+
),
|
|
250
|
+
),
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Vitest Plugin for TestDriver
|
|
256
|
+
*
|
|
257
|
+
* Records test runs, test cases, and associates them with dashcam recordings.
|
|
258
|
+
* Uses plugin architecture for better global state management.
|
|
259
|
+
*
|
|
260
|
+
* ## How it works:
|
|
261
|
+
*
|
|
262
|
+
* 1. **Plugin State**: All state is managed in a single `pluginState` object
|
|
263
|
+
* - No class instances or complex scoping
|
|
264
|
+
* - Easy to access from anywhere in the plugin
|
|
265
|
+
* - Dashcam URLs tracked in memory (no temp files!)
|
|
266
|
+
*
|
|
267
|
+
* 2. **Dashcam URL Registration**: Tests register dashcam URLs via simple API
|
|
268
|
+
* - `globalThis.__testdriverPlugin.registerDashcamUrl(testId, url, platform)`
|
|
269
|
+
* - No file system operations
|
|
270
|
+
* - No complex matching logic
|
|
271
|
+
* - Direct association via test ID
|
|
272
|
+
*
|
|
273
|
+
* 3. **Test Recording Flow**:
|
|
274
|
+
* - `onTestRunStart`: Create test run record
|
|
275
|
+
* - `onTestCaseReady`: Track test start time
|
|
276
|
+
* - `onTestCaseResult`: Record individual test result (immediate)
|
|
277
|
+
* - `onTestRunEnd`: Complete test run with final stats
|
|
278
|
+
*
|
|
279
|
+
* 4. **Platform Detection**: Automatically detects platform from SDK client
|
|
280
|
+
* - No manual configuration needed
|
|
281
|
+
* - Stored when dashcam URL is registered
|
|
282
|
+
*/
|
|
283
|
+
|
|
284
|
+
// Shared state that can be imported by both the reporter and setup files
|
|
285
|
+
export const pluginState = {
|
|
286
|
+
testRun: null,
|
|
287
|
+
testRunId: null,
|
|
288
|
+
testRunCompleted: false,
|
|
289
|
+
client: null,
|
|
290
|
+
startTime: null,
|
|
291
|
+
testCases: new Map(),
|
|
292
|
+
recordedTestCases: [], // Store recorded test case data for GitHub comment
|
|
293
|
+
token: null,
|
|
294
|
+
detectedPlatform: null,
|
|
295
|
+
pendingTestCaseRecords: new Set(),
|
|
296
|
+
ciProvider: null,
|
|
297
|
+
gitInfo: {},
|
|
298
|
+
apiKey: null,
|
|
299
|
+
apiRoot: null,
|
|
300
|
+
// TestDriver options to pass to all instances
|
|
301
|
+
testDriverOptions: {},
|
|
302
|
+
// Dashcam URL tracking (in-memory, no files needed!)
|
|
303
|
+
dashcamUrls: new Map(), // testId -> [{url, platform, attempt}]
|
|
304
|
+
lastDashcamUrl: null, // Fallback for when test ID isn't available
|
|
305
|
+
// Suite-level test run tracking
|
|
306
|
+
suiteTestRuns: new Map(), // suiteId -> { runId, testRunDbId, token }
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// Export functions that can be used by the reporter or tests
|
|
310
|
+
export function registerDashcamUrl(testId, url, platform, attempt) {
|
|
311
|
+
logger.debug(`Registering dashcam URL for test ${testId} (attempt ${attempt || 1}):`, url);
|
|
312
|
+
// Support multiple attempts per test - store as array
|
|
313
|
+
if (!pluginState.dashcamUrls.has(testId)) {
|
|
314
|
+
pluginState.dashcamUrls.set(testId, []);
|
|
315
|
+
}
|
|
316
|
+
pluginState.dashcamUrls.get(testId).push({ url, platform, attempt: attempt || 1 });
|
|
317
|
+
pluginState.lastDashcamUrl = url;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function getDashcamUrl(testId) {
|
|
321
|
+
const entries = pluginState.dashcamUrls.get(testId);
|
|
322
|
+
if (!entries) return undefined;
|
|
323
|
+
// Return the last entry for backward compatibility (single URL callers)
|
|
324
|
+
return entries[entries.length - 1];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function getAllDashcamUrls(testId) {
|
|
328
|
+
return pluginState.dashcamUrls.get(testId) || [];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function clearDashcamUrls() {
|
|
332
|
+
pluginState.dashcamUrls.clear();
|
|
333
|
+
pluginState.lastDashcamUrl = null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function getSuiteTestRun(suiteId) {
|
|
337
|
+
return pluginState.suiteTestRuns.get(suiteId);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function setSuiteTestRun(suiteId, runData) {
|
|
341
|
+
logger.debug(`Setting test run for suite ${suiteId}:`, runData);
|
|
342
|
+
pluginState.suiteTestRuns.set(suiteId, runData);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function clearSuiteTestRun(suiteId) {
|
|
346
|
+
pluginState.suiteTestRuns.delete(suiteId);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function getPluginState() {
|
|
350
|
+
return pluginState;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Export API helper functions for direct use from tests
|
|
354
|
+
export async function authenticateWithApiKey(apiKey, apiRoot) {
|
|
355
|
+
if (!apiKey) {
|
|
356
|
+
const error = new Error(
|
|
357
|
+
"TD_API_KEY is not configured. Get your API key at https://console.testdriver.ai/team",
|
|
358
|
+
);
|
|
359
|
+
error.code = "MISSING_API_KEY";
|
|
360
|
+
error.isAuthError = true;
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const url = `${apiRoot}/auth/exchange-api-key`;
|
|
365
|
+
let response;
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
response = await withTimeout(
|
|
369
|
+
fetch(url, {
|
|
370
|
+
method: "POST",
|
|
371
|
+
headers: {
|
|
372
|
+
"Content-Type": "application/json",
|
|
373
|
+
},
|
|
374
|
+
body: JSON.stringify({ apiKey }),
|
|
375
|
+
}),
|
|
376
|
+
15000,
|
|
377
|
+
"Authentication",
|
|
378
|
+
);
|
|
379
|
+
} catch (fetchError) {
|
|
380
|
+
// Network-level error (fetch failed entirely)
|
|
381
|
+
const networkError = new Error(
|
|
382
|
+
`Unable to reach TestDriver API at ${apiRoot}. ` +
|
|
383
|
+
"Check your internet connection and try again.",
|
|
384
|
+
);
|
|
385
|
+
networkError.code = "NETWORK_ERROR";
|
|
386
|
+
networkError.isNetworkError = true;
|
|
387
|
+
networkError.originalError = fetchError;
|
|
388
|
+
throw networkError;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!response.ok) {
|
|
392
|
+
let data = {};
|
|
393
|
+
try {
|
|
394
|
+
data = await response.json();
|
|
395
|
+
} catch {
|
|
396
|
+
// Response wasn't JSON, use empty object
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Invalid API key (401)
|
|
400
|
+
if (response.status === 401) {
|
|
401
|
+
const authError = new Error(
|
|
402
|
+
data.message ||
|
|
403
|
+
"Invalid API key. Please check your TD_API_KEY and try again. " +
|
|
404
|
+
"Get your API key at https://console.testdriver.ai/team",
|
|
405
|
+
);
|
|
406
|
+
authError.code = data.error || "INVALID_API_KEY";
|
|
407
|
+
authError.isAuthError = true;
|
|
408
|
+
throw authError;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Server errors (5xx) - API is down or having issues
|
|
412
|
+
if (response.status >= 500) {
|
|
413
|
+
const serverError = new Error(
|
|
414
|
+
data.message ||
|
|
415
|
+
`TestDriver API is currently unavailable (HTTP ${response.status}). Please try again later.`,
|
|
416
|
+
);
|
|
417
|
+
serverError.code = data.error || "API_UNAVAILABLE";
|
|
418
|
+
serverError.isServerError = true;
|
|
419
|
+
throw serverError;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Rate limiting (429)
|
|
423
|
+
if (response.status === 429) {
|
|
424
|
+
const rateLimitError = new Error(
|
|
425
|
+
"Too many requests to TestDriver API. Please wait a moment and try again.",
|
|
426
|
+
);
|
|
427
|
+
rateLimitError.code = "RATE_LIMITED";
|
|
428
|
+
rateLimitError.isRateLimitError = true;
|
|
429
|
+
throw rateLimitError;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Other HTTP errors
|
|
433
|
+
throw new Error(
|
|
434
|
+
`Authentication failed: ${response.status} ${response.statusText}` +
|
|
435
|
+
(data.message ? ` - ${data.message}` : ""),
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const data = await response.json();
|
|
440
|
+
return data.token;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export async function createTestRunDirect(token, apiRoot, testRunData) {
|
|
444
|
+
const url = `${apiRoot}/api/v1/testdriver/test-run-create`;
|
|
445
|
+
const response = await withTimeout(
|
|
446
|
+
fetch(url, {
|
|
447
|
+
method: "POST",
|
|
448
|
+
headers: {
|
|
449
|
+
"Content-Type": "application/json",
|
|
450
|
+
Authorization: `Bearer ${token}`,
|
|
451
|
+
},
|
|
452
|
+
body: JSON.stringify(testRunData),
|
|
453
|
+
}),
|
|
454
|
+
10000,
|
|
455
|
+
"Create Test Run",
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
if (!response.ok) {
|
|
459
|
+
const errorText = await response.text();
|
|
460
|
+
throw new Error(
|
|
461
|
+
`API error: ${response.status} ${response.statusText} - ${errorText}`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return await response.json();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export async function recordTestCaseDirect(token, apiRoot, testCaseData) {
|
|
469
|
+
const url = `${apiRoot}/api/v1/testdriver/test-case-create`;
|
|
470
|
+
const response = await withTimeout(
|
|
471
|
+
fetch(url, {
|
|
472
|
+
method: "POST",
|
|
473
|
+
headers: {
|
|
474
|
+
"Content-Type": "application/json",
|
|
475
|
+
Authorization: `Bearer ${token}`,
|
|
476
|
+
},
|
|
477
|
+
body: JSON.stringify(testCaseData),
|
|
478
|
+
}),
|
|
479
|
+
10000,
|
|
480
|
+
"Record Test Case",
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
if (!response.ok) {
|
|
484
|
+
const errorText = await response.text();
|
|
485
|
+
throw new Error(
|
|
486
|
+
`API error: ${response.status} ${response.statusText} - ${errorText}`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return await response.json();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Import TestDriverSDK using require to avoid esbuild transformation issues
|
|
494
|
+
const TestDriverSDK = require("../sdk.js");
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Create a TestDriver client for use in beforeAll/beforeEach hooks
|
|
498
|
+
* This is for the shared instance pattern where one driver is used across multiple tests
|
|
499
|
+
*
|
|
500
|
+
* @param {object} options - TestDriver options
|
|
501
|
+
* @param {string} [options.apiKey] - TestDriver API key (defaults to process.env.TD_API_KEY)
|
|
502
|
+
* @param {boolean} [options.headless] - Run sandbox in headless mode
|
|
503
|
+
* @returns {Promise<TestDriver>} Connected TestDriver client instance
|
|
504
|
+
*
|
|
505
|
+
* @example
|
|
506
|
+
* let testdriver;
|
|
507
|
+
* beforeAll(async () => {
|
|
508
|
+
* testdriver = await createTestDriver({ headless: true });
|
|
509
|
+
* await testdriver.provision.chrome({ url: 'https://example.com' });
|
|
510
|
+
* });
|
|
511
|
+
*/
|
|
512
|
+
export async function createTestDriver(options = {}) {
|
|
513
|
+
// Get global plugin options if available
|
|
514
|
+
const pluginOptions =
|
|
515
|
+
globalThis.__testdriverPlugin?.state?.testDriverOptions || {};
|
|
516
|
+
|
|
517
|
+
// Merge options: plugin global options < test-specific options
|
|
518
|
+
const mergedOptions = { ...pluginOptions, ...options };
|
|
519
|
+
|
|
520
|
+
// Support TD_OS environment variable for specifying target OS (linux, mac, windows)
|
|
521
|
+
// Priority: test options > plugin options > environment variable > default (linux)
|
|
522
|
+
if (!mergedOptions.os && process.env.TD_OS) {
|
|
523
|
+
mergedOptions.os = process.env.TD_OS;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Extract TestDriver-specific options
|
|
527
|
+
const apiKey = mergedOptions.apiKey || process.env.TD_API_KEY;
|
|
528
|
+
|
|
529
|
+
// Build config for TestDriverSDK constructor
|
|
530
|
+
const config = { ...mergedOptions };
|
|
531
|
+
delete config.apiKey;
|
|
532
|
+
|
|
533
|
+
// Use TD_API_ROOT from environment if not provided in config
|
|
534
|
+
if (!config.apiRoot && process.env.TD_API_ROOT) {
|
|
535
|
+
config.apiRoot = process.env.TD_API_ROOT;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const testdriver = new TestDriverSDK(apiKey, config);
|
|
539
|
+
|
|
540
|
+
// Connect to sandbox
|
|
541
|
+
await testdriver.auth();
|
|
542
|
+
await testdriver.connect();
|
|
543
|
+
|
|
544
|
+
return testdriver;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Register a test with a shared TestDriver instance
|
|
549
|
+
* Call this at the start of each test to associate the test context with the driver
|
|
550
|
+
*
|
|
551
|
+
* @param {TestDriver} testdriver - TestDriver client instance from createTestDriver
|
|
552
|
+
* @param {object} context - Vitest test context (from async (context) => {})
|
|
553
|
+
*
|
|
554
|
+
* @example
|
|
555
|
+
* it("step01: verify login", async (context) => {
|
|
556
|
+
* registerTest(testdriver, context);
|
|
557
|
+
* const result = await testdriver.assert("login form visible");
|
|
558
|
+
* });
|
|
559
|
+
*/
|
|
560
|
+
export function registerTest(testdriver, context) {
|
|
561
|
+
if (!testdriver) {
|
|
562
|
+
throw new Error("registerTest() requires a TestDriver instance");
|
|
563
|
+
}
|
|
564
|
+
if (!context || !context.task) {
|
|
565
|
+
throw new Error(
|
|
566
|
+
"registerTest() requires Vitest context. Pass the context parameter from your test function.",
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
testdriver.__vitestContext = context.task;
|
|
571
|
+
logger.debug(`Registered test: ${context.task.name}`);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Clean up a TestDriver client created with createTestDriver
|
|
576
|
+
* Call this in afterAll to properly disconnect and stop recordings
|
|
577
|
+
*
|
|
578
|
+
* @param {TestDriver} testdriver - TestDriver client instance
|
|
579
|
+
*
|
|
580
|
+
* @example
|
|
581
|
+
* afterAll(async () => {
|
|
582
|
+
* await cleanupTestDriver(testdriver);
|
|
583
|
+
* });
|
|
584
|
+
*/
|
|
585
|
+
export async function cleanupTestDriver(testdriver) {
|
|
586
|
+
if (!testdriver) {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
try {
|
|
591
|
+
// Stop dashcam if it was started
|
|
592
|
+
if (testdriver._dashcam && testdriver._dashcam.recording) {
|
|
593
|
+
try {
|
|
594
|
+
const dashcamUrl = await testdriver.dashcam.stop();
|
|
595
|
+
const debugMode =
|
|
596
|
+
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
597
|
+
if (debugMode) {
|
|
598
|
+
console.log("š„ Dashcam URL:", dashcamUrl);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Register dashcam URL in memory for the reporter
|
|
602
|
+
if (dashcamUrl && globalThis.__testdriverPlugin?.registerDashcamUrl) {
|
|
603
|
+
const testId = testdriver.__vitestContext?.id || "unknown";
|
|
604
|
+
const platform = testdriver.os || "linux";
|
|
605
|
+
globalThis.__testdriverPlugin.registerDashcamUrl(
|
|
606
|
+
testId,
|
|
607
|
+
dashcamUrl,
|
|
608
|
+
platform,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
} catch (error) {
|
|
612
|
+
console.error("ā Failed to stop dashcam:", error.message);
|
|
613
|
+
if (
|
|
614
|
+
error.name === "NotFoundError" ||
|
|
615
|
+
error.responseData?.error === "NotFoundError"
|
|
616
|
+
) {
|
|
617
|
+
console.log(
|
|
618
|
+
" ā¹ļø Sandbox session already terminated - dashcam stop skipped",
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
await testdriver.disconnect();
|
|
625
|
+
} catch (error) {
|
|
626
|
+
console.error("Error disconnecting client:", error);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Handle process termination and mark test run as cancelled
|
|
632
|
+
*/
|
|
633
|
+
async function handleProcessExit() {
|
|
634
|
+
logger.debug("handleProcessExit called");
|
|
635
|
+
logger.debug("testRun:", !!pluginState.testRun);
|
|
636
|
+
logger.debug("testRunId:", pluginState.testRunId);
|
|
637
|
+
logger.debug("testRunCompleted:", pluginState.testRunCompleted);
|
|
638
|
+
|
|
639
|
+
if (!pluginState.testRun || !pluginState.testRunId) {
|
|
640
|
+
logger.debug("No test run to cancel - skipping cleanup");
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Prevent duplicate completion
|
|
645
|
+
if (pluginState.testRunCompleted) {
|
|
646
|
+
logger.debug("Test run already completed - skipping cancellation");
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
logger.debug("Marking test run as cancelled...");
|
|
651
|
+
|
|
652
|
+
try {
|
|
653
|
+
const stats = {
|
|
654
|
+
totalTests: pluginState.testCases.size,
|
|
655
|
+
passedTests: 0,
|
|
656
|
+
failedTests: 0,
|
|
657
|
+
skippedTests: 0,
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
const completeData = {
|
|
661
|
+
runId: pluginState.testRunId,
|
|
662
|
+
status: "cancelled",
|
|
663
|
+
totalTests: stats.totalTests,
|
|
664
|
+
passedTests: stats.passedTests,
|
|
665
|
+
failedTests: stats.failedTests,
|
|
666
|
+
skippedTests: stats.skippedTests,
|
|
667
|
+
duration: Date.now() - pluginState.startTime,
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
// Update platform if detected
|
|
671
|
+
const platform = getPlatform();
|
|
672
|
+
if (platform) {
|
|
673
|
+
completeData.platform = platform;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
logger.debug("Calling completeTestRun with:", JSON.stringify(completeData));
|
|
677
|
+
await completeTestRun(completeData);
|
|
678
|
+
pluginState.testRunCompleted = true;
|
|
679
|
+
logger.info("Test run marked as cancelled");
|
|
680
|
+
} catch (error) {
|
|
681
|
+
logger.error("Failed to mark test run as cancelled:", error.message);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Set up process exit handlers
|
|
686
|
+
let exitHandlersRegistered = false;
|
|
687
|
+
let isExiting = false;
|
|
688
|
+
let isCancelling = false; // Track if we're in the process of cancelling due to SIGINT/SIGTERM
|
|
689
|
+
|
|
690
|
+
function registerExitHandlers() {
|
|
691
|
+
if (exitHandlersRegistered) return;
|
|
692
|
+
exitHandlersRegistered = true;
|
|
693
|
+
|
|
694
|
+
// Handle Ctrl+C - use 'once' and prepend to run before Vitest's handler
|
|
695
|
+
process.prependOnceListener("SIGINT", () => {
|
|
696
|
+
logger.debug("SIGINT received, cleaning up...");
|
|
697
|
+
if (isExiting) {
|
|
698
|
+
logger.debug("Already exiting, skipping duplicate handler");
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
isExiting = true;
|
|
702
|
+
isCancelling = true; // Mark that we're cancelling
|
|
703
|
+
|
|
704
|
+
// Temporarily override process.exit to prevent Vitest from exiting before we're done
|
|
705
|
+
const originalExit = process.exit;
|
|
706
|
+
let exitCalled = false;
|
|
707
|
+
let exitCode = 130;
|
|
708
|
+
|
|
709
|
+
process.exit = (code) => {
|
|
710
|
+
if (!exitCalled) {
|
|
711
|
+
exitCalled = true;
|
|
712
|
+
exitCode = code ?? 130;
|
|
713
|
+
logger.debug(
|
|
714
|
+
`process.exit(${exitCode}) called, waiting for cleanup...`,
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
handleProcessExit()
|
|
720
|
+
.then(() => {
|
|
721
|
+
logger.debug("Cleanup completed successfully");
|
|
722
|
+
})
|
|
723
|
+
.catch((err) => {
|
|
724
|
+
logger.error("Error during SIGINT cleanup:", err.message);
|
|
725
|
+
})
|
|
726
|
+
.finally(() => {
|
|
727
|
+
logger.debug(`Exiting with code ${exitCode}`);
|
|
728
|
+
// Restore and call original exit
|
|
729
|
+
process.exit = originalExit;
|
|
730
|
+
process.exit(exitCode);
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// Handle kill command
|
|
735
|
+
process.prependOnceListener("SIGTERM", () => {
|
|
736
|
+
logger.debug("SIGTERM received, cleaning up...");
|
|
737
|
+
if (isExiting) return;
|
|
738
|
+
isExiting = true;
|
|
739
|
+
isCancelling = true;
|
|
740
|
+
|
|
741
|
+
const originalExit = process.exit;
|
|
742
|
+
let exitCode = 143;
|
|
743
|
+
|
|
744
|
+
process.exit = (code) => {
|
|
745
|
+
exitCode = code ?? 143;
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
handleProcessExit()
|
|
749
|
+
.then(() => {
|
|
750
|
+
logger.debug("Cleanup completed successfully");
|
|
751
|
+
})
|
|
752
|
+
.catch((err) => {
|
|
753
|
+
logger.error("Error during SIGTERM cleanup:", err.message);
|
|
754
|
+
})
|
|
755
|
+
.finally(() => {
|
|
756
|
+
logger.debug(`Exiting with code ${exitCode}`);
|
|
757
|
+
process.exit = originalExit;
|
|
758
|
+
process.exit(exitCode);
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Create the TestDriver Vitest plugin
|
|
765
|
+
* This sets up global state and provides the registration API
|
|
766
|
+
*/
|
|
767
|
+
export default function testDriverPlugin(options = {}) {
|
|
768
|
+
// Store options but don't read env vars yet - they may not be loaded
|
|
769
|
+
// Environment variables will be read in onInit after setupFiles run
|
|
770
|
+
pluginState.apiRoot =
|
|
771
|
+
options.apiRoot ||
|
|
772
|
+
process.env.TD_API_ROOT ||
|
|
773
|
+
channelConfig.channels[channelConfig.active];
|
|
774
|
+
pluginState.ciProvider = detectCI();
|
|
775
|
+
pluginState.gitInfo = getGitInfo();
|
|
776
|
+
|
|
777
|
+
// Store TestDriver-specific options (excluding plugin-specific ones)
|
|
778
|
+
const { apiKey, apiRoot, ...testDriverOptions } = options;
|
|
779
|
+
pluginState.testDriverOptions = testDriverOptions;
|
|
780
|
+
|
|
781
|
+
// Register process exit handlers to handle cancellation
|
|
782
|
+
registerExitHandlers();
|
|
783
|
+
|
|
784
|
+
// Note: globalThis setup happens in vitestSetup.mjs for worker processes
|
|
785
|
+
logger.debug("TestDriver plugin initializing...");
|
|
786
|
+
logger.debug("API root:", pluginState.apiRoot);
|
|
787
|
+
logger.debug("API key from options:", !!options.apiKey);
|
|
788
|
+
logger.debug("API key from env (at config time):", !!process.env.TD_API_KEY);
|
|
789
|
+
logger.debug("CI Provider:", pluginState.ciProvider || "none");
|
|
790
|
+
if (Object.keys(testDriverOptions).length > 0) {
|
|
791
|
+
logger.debug("Global TestDriver options:", testDriverOptions);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Create reporter instance
|
|
795
|
+
const reporter = new TestDriverReporter(options);
|
|
796
|
+
|
|
797
|
+
// Add name property for Vitest
|
|
798
|
+
reporter.name = "testdriver";
|
|
799
|
+
|
|
800
|
+
return reporter;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* TestDriver Reporter Class
|
|
805
|
+
* Handles Vitest test lifecycle events
|
|
806
|
+
*/
|
|
807
|
+
class TestDriverReporter {
|
|
808
|
+
constructor(options = {}) {
|
|
809
|
+
this.options = options;
|
|
810
|
+
logger.debug("Reporter created with options:", {
|
|
811
|
+
hasApiKey: !!options.apiKey,
|
|
812
|
+
hasApiRoot: !!options.apiRoot,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async onInit(ctx) {
|
|
817
|
+
this.ctx = ctx;
|
|
818
|
+
logger.debug("onInit called - UPDATED VERSION");
|
|
819
|
+
|
|
820
|
+
// Initialize Sentry for error reporting
|
|
821
|
+
initializeSentry();
|
|
822
|
+
|
|
823
|
+
// Store project root for making file paths relative
|
|
824
|
+
pluginState.projectRoot = ctx.config.root || process.cwd();
|
|
825
|
+
logger.debug("Project root:", pluginState.projectRoot);
|
|
826
|
+
|
|
827
|
+
// NOW read the API key and API root (after setupFiles have run, including dotenv/config)
|
|
828
|
+
pluginState.apiKey = this.options.apiKey || process.env.TD_API_KEY;
|
|
829
|
+
pluginState.apiRoot =
|
|
830
|
+
this.options.apiRoot ||
|
|
831
|
+
process.env.TD_API_ROOT ||
|
|
832
|
+
channelConfig.channels[channelConfig.active];
|
|
833
|
+
logger.debug("API key from options:", !!this.options.apiKey);
|
|
834
|
+
logger.debug("API key from env (at onInit):", !!process.env.TD_API_KEY);
|
|
835
|
+
logger.debug("API root from options:", this.options.apiRoot);
|
|
836
|
+
logger.debug("API root from env (at onInit):", process.env.TD_API_ROOT);
|
|
837
|
+
logger.debug("Final API key set:", !!pluginState.apiKey);
|
|
838
|
+
logger.debug("Final API root set:", pluginState.apiRoot);
|
|
839
|
+
|
|
840
|
+
// Initialize test run
|
|
841
|
+
await this.initializeTestRun();
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async initializeTestRun() {
|
|
845
|
+
logger.debug("initializeTestRun called");
|
|
846
|
+
logger.debug("API key present:", !!pluginState.apiKey);
|
|
847
|
+
logger.debug("API root:", pluginState.apiRoot);
|
|
848
|
+
|
|
849
|
+
// Environment info is printed by the SDK when each test initializes,
|
|
850
|
+
// so we skip the duplicate banner here in the reporter.
|
|
851
|
+
|
|
852
|
+
// Check if we should enable the reporter
|
|
853
|
+
if (!pluginState.apiKey) {
|
|
854
|
+
logger.warn("No API key provided, skipping test recording");
|
|
855
|
+
logger.debug(
|
|
856
|
+
"API key sources - options:",
|
|
857
|
+
!!this.options.apiKey,
|
|
858
|
+
"env:",
|
|
859
|
+
!!process.env.TD_API_KEY,
|
|
860
|
+
);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
try {
|
|
865
|
+
// Exchange API key for JWT token
|
|
866
|
+
logger.debug("Authenticating with API...");
|
|
867
|
+
await authenticate();
|
|
868
|
+
logger.debug("Authentication successful, token received");
|
|
869
|
+
|
|
870
|
+
// Generate unique run ID
|
|
871
|
+
pluginState.testRunId = generateRunId();
|
|
872
|
+
pluginState.startTime = Date.now();
|
|
873
|
+
pluginState.testRunCompleted = false; // Reset completion flag
|
|
874
|
+
|
|
875
|
+
// Create test run via direct API call
|
|
876
|
+
const testRunData = {
|
|
877
|
+
runId: pluginState.testRunId,
|
|
878
|
+
suiteName: getSuiteName(),
|
|
879
|
+
...pluginState.gitInfo,
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
// Session ID will be added from the first test result file that includes it
|
|
883
|
+
|
|
884
|
+
// Only add ciProvider if it's not null
|
|
885
|
+
if (pluginState.ciProvider) {
|
|
886
|
+
testRunData.ciProvider = pluginState.ciProvider;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Platform will be set from the first test result file
|
|
890
|
+
// Default to linux if no tests write platform info
|
|
891
|
+
testRunData.platform = "linux";
|
|
892
|
+
|
|
893
|
+
// Send version metadata
|
|
894
|
+
testRunData.nodeVersion = process.version;
|
|
895
|
+
const tdVer = resolveTestDriverVersion();
|
|
896
|
+
if (tdVer) {
|
|
897
|
+
testRunData.testDriverVersion = tdVer;
|
|
898
|
+
}
|
|
899
|
+
const vitestVer = resolveVitestVersion();
|
|
900
|
+
if (vitestVer) {
|
|
901
|
+
testRunData.vitestVersion = vitestVer;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
logger.debug("Creating test run with data:", JSON.stringify(testRunData));
|
|
905
|
+
pluginState.testRun = await createTestRun(testRunData);
|
|
906
|
+
logger.debug("Test run created:", JSON.stringify(pluginState.testRun));
|
|
907
|
+
|
|
908
|
+
// Store in environment variables for worker processes to access
|
|
909
|
+
process.env.TD_TEST_RUN_ID = pluginState.testRunId;
|
|
910
|
+
process.env.TD_TEST_RUN_DB_ID = pluginState.testRun.data?.id || "";
|
|
911
|
+
process.env.TD_TEST_RUN_TOKEN = pluginState.token;
|
|
912
|
+
|
|
913
|
+
// Also store in shared state module (won't work across processes but good for main)
|
|
914
|
+
setTestRunInfo({
|
|
915
|
+
testRun: pluginState.testRun,
|
|
916
|
+
testRunId: pluginState.testRunId,
|
|
917
|
+
token: pluginState.token,
|
|
918
|
+
apiKey: pluginState.apiKey,
|
|
919
|
+
apiRoot: pluginState.apiRoot,
|
|
920
|
+
startTime: pluginState.startTime,
|
|
921
|
+
});
|
|
922
|
+
} catch (error) {
|
|
923
|
+
logger.error("Failed to initialize:", error.message);
|
|
924
|
+
pluginState.apiKey = null;
|
|
925
|
+
pluginState.token = null;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
async onTestRunEnd(testModules, unhandledErrors, reason) {
|
|
930
|
+
logger.debug("onTestRunEnd called with reason:", reason);
|
|
931
|
+
logger.debug("API key present:", !!pluginState.apiKey);
|
|
932
|
+
logger.debug("Test run present:", !!pluginState.testRun);
|
|
933
|
+
logger.debug("Test run ID:", pluginState.testRunId);
|
|
934
|
+
logger.debug("isCancelling:", isCancelling);
|
|
935
|
+
logger.debug("testRunCompleted:", pluginState.testRunCompleted);
|
|
936
|
+
|
|
937
|
+
// If we're cancelling due to SIGINT/SIGTERM, skip - handleProcessExit will handle it
|
|
938
|
+
if (isCancelling) {
|
|
939
|
+
logger.debug(
|
|
940
|
+
"Cancellation in progress via signal handler, skipping onTestRunEnd",
|
|
941
|
+
);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// If already completed (by handleProcessExit), skip
|
|
946
|
+
if (pluginState.testRunCompleted) {
|
|
947
|
+
logger.debug("Test run already completed, skipping");
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (!pluginState.apiKey) {
|
|
952
|
+
logger.warn(
|
|
953
|
+
"Skipping completion - no API key (was it cleared after init failure?)",
|
|
954
|
+
);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (!pluginState.testRun) {
|
|
959
|
+
logger.warn(
|
|
960
|
+
"Skipping completion - no test run created (check initialization logs)",
|
|
961
|
+
);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
logger.debug("Completing test run...");
|
|
966
|
+
|
|
967
|
+
try {
|
|
968
|
+
// Calculate statistics from testModules
|
|
969
|
+
const stats = calculateStatsFromModules(testModules);
|
|
970
|
+
|
|
971
|
+
logger.debug("Stats:", stats);
|
|
972
|
+
|
|
973
|
+
// Determine overall status based on stats (not reason, which is unreliable in parallel runs)
|
|
974
|
+
let status = "passed";
|
|
975
|
+
if (stats.failedTests > 0) {
|
|
976
|
+
status = "failed";
|
|
977
|
+
} else if (reason === "interrupted") {
|
|
978
|
+
status = "cancelled";
|
|
979
|
+
} else if (stats.totalTests === 0) {
|
|
980
|
+
status = "cancelled";
|
|
981
|
+
} else if (stats.passedTests === 0 && stats.skippedTests === 0) {
|
|
982
|
+
// No tests actually ran (all were filtered/excluded)
|
|
983
|
+
status = "cancelled";
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Complete test run via API
|
|
987
|
+
logger.debug(
|
|
988
|
+
`Completing test run ${pluginState.testRunId} with status: ${status}`,
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
const completeData = {
|
|
992
|
+
runId: pluginState.testRunId,
|
|
993
|
+
status,
|
|
994
|
+
totalTests: stats.totalTests,
|
|
995
|
+
passedTests: stats.passedTests,
|
|
996
|
+
failedTests: stats.failedTests,
|
|
997
|
+
skippedTests: stats.skippedTests,
|
|
998
|
+
duration: Date.now() - pluginState.startTime,
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
// Update platform if detected from test results
|
|
1002
|
+
const platform = getPlatform();
|
|
1003
|
+
logger.debug(
|
|
1004
|
+
`Platform detection result: ${platform}, detectedPlatform in state: ${pluginState.detectedPlatform}`,
|
|
1005
|
+
);
|
|
1006
|
+
if (platform) {
|
|
1007
|
+
completeData.platform = platform;
|
|
1008
|
+
logger.debug(`Updating test run with platform: ${platform}`);
|
|
1009
|
+
} else {
|
|
1010
|
+
logger.warn(
|
|
1011
|
+
`No platform detected, test run will keep default platform`,
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Wait for any pending operations (shouldn't be any, but just in case)
|
|
1016
|
+
if (pluginState.pendingTestCaseRecords.size > 0) {
|
|
1017
|
+
logger.debug(
|
|
1018
|
+
`Waiting for ${pluginState.pendingTestCaseRecords.size} pending operations...`,
|
|
1019
|
+
);
|
|
1020
|
+
await Promise.all(Array.from(pluginState.pendingTestCaseRecords));
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Test cases are reported directly from teardownTest
|
|
1024
|
+
logger.debug("Calling completeTestRun API...");
|
|
1025
|
+
logger.debug("Complete data:", JSON.stringify(completeData));
|
|
1026
|
+
|
|
1027
|
+
const completeResponse = await completeTestRun(completeData);
|
|
1028
|
+
logger.debug("API response:", JSON.stringify(completeResponse));
|
|
1029
|
+
|
|
1030
|
+
// Mark test run as completed to prevent duplicate completion
|
|
1031
|
+
pluginState.testRunCompleted = true;
|
|
1032
|
+
|
|
1033
|
+
// Output the test run URL for CI to capture
|
|
1034
|
+
const testRunDbId = process.env.TD_TEST_RUN_DB_ID;
|
|
1035
|
+
const consoleUrl = getConsoleUrl(pluginState.apiRoot);
|
|
1036
|
+
if (testRunDbId) {
|
|
1037
|
+
const testRunUrl = `${consoleUrl}/runs/${testRunDbId}`;
|
|
1038
|
+
logger.info(`View test run: ${testRunUrl}`);
|
|
1039
|
+
// Output in a parseable format for CI
|
|
1040
|
+
console.log(`TESTDRIVER_RUN_URL=${testRunUrl}`);
|
|
1041
|
+
|
|
1042
|
+
// Post GitHub comment if in CI environment
|
|
1043
|
+
await postGitHubCommentIfEnabled(testRunUrl, stats, completeData);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
logger.info(
|
|
1047
|
+
`Test run completed: ${stats.passedTests}/${stats.totalTests} passed`,
|
|
1048
|
+
);
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
logger.error("Failed to complete test run:", error.message);
|
|
1051
|
+
logger.debug("Error stack:", error.stack);
|
|
1052
|
+
} finally {
|
|
1053
|
+
// Flush any pending Sentry events before process exits
|
|
1054
|
+
await flushSentry();
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
onTestCaseReady(test) {
|
|
1059
|
+
if (!pluginState.apiKey || !pluginState.testRun) return;
|
|
1060
|
+
|
|
1061
|
+
pluginState.testCases.set(test.id, {
|
|
1062
|
+
test,
|
|
1063
|
+
startTime: Date.now(),
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
async onTestCaseResult(test) {
|
|
1068
|
+
if (!pluginState.apiKey || !pluginState.testRun) return;
|
|
1069
|
+
|
|
1070
|
+
const result = test.result();
|
|
1071
|
+
const status =
|
|
1072
|
+
result.state === "passed"
|
|
1073
|
+
? "passed"
|
|
1074
|
+
: result.state === "skipped"
|
|
1075
|
+
? "skipped"
|
|
1076
|
+
: "failed";
|
|
1077
|
+
|
|
1078
|
+
logger.debug(`Test case completed: ${test.name} (${status})`);
|
|
1079
|
+
|
|
1080
|
+
// Calculate duration from tracked start time
|
|
1081
|
+
const testCase = pluginState.testCases.get(test.id);
|
|
1082
|
+
const duration = testCase ? Date.now() - testCase.startTime : 0;
|
|
1083
|
+
|
|
1084
|
+
logger.debug(
|
|
1085
|
+
`Calculated duration: ${duration}ms (startTime: ${testCase?.startTime}, now: ${Date.now()})`,
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
// Read test metadata from Vitest's task.meta (set in test hooks)
|
|
1089
|
+
const meta = test.meta();
|
|
1090
|
+
logger.debug(`Test meta for ${test.id}:`, meta);
|
|
1091
|
+
|
|
1092
|
+
const dashcamUrl = meta.dashcamUrl || null;
|
|
1093
|
+
const dashcamUrls = meta.dashcamUrls || []; // Per-attempt URLs
|
|
1094
|
+
const sessionId = meta.sessionId || null;
|
|
1095
|
+
const platform = meta.platform || null;
|
|
1096
|
+
const sandboxId = meta.sandboxId || null;
|
|
1097
|
+
let testFile = meta.testFile || "unknown";
|
|
1098
|
+
const testOrder = meta.testOrder !== undefined ? meta.testOrder : 0;
|
|
1099
|
+
|
|
1100
|
+
// If testFile not in meta, fallback to test object properties
|
|
1101
|
+
if (testFile === "unknown") {
|
|
1102
|
+
const absolutePath =
|
|
1103
|
+
test.module?.task?.filepath ||
|
|
1104
|
+
test.module?.file?.filepath ||
|
|
1105
|
+
test.module?.file?.name ||
|
|
1106
|
+
test.file?.filepath ||
|
|
1107
|
+
test.file?.name ||
|
|
1108
|
+
test.suite?.file?.filepath ||
|
|
1109
|
+
test.suite?.file?.name ||
|
|
1110
|
+
test.location?.file ||
|
|
1111
|
+
"unknown";
|
|
1112
|
+
testFile =
|
|
1113
|
+
pluginState.projectRoot && absolutePath !== "unknown"
|
|
1114
|
+
? path.relative(pluginState.projectRoot, absolutePath)
|
|
1115
|
+
: absolutePath;
|
|
1116
|
+
logger.debug(`Resolved testFile from fallback: ${testFile}`);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Update test run platform from first test that reports it
|
|
1120
|
+
if (platform && !pluginState.detectedPlatform) {
|
|
1121
|
+
pluginState.detectedPlatform = platform;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Get test run info from environment variables
|
|
1125
|
+
const testRunId = process.env.TD_TEST_RUN_ID;
|
|
1126
|
+
const token = process.env.TD_TEST_RUN_TOKEN;
|
|
1127
|
+
|
|
1128
|
+
if (!testRunId || !token) {
|
|
1129
|
+
logger.warn(
|
|
1130
|
+
`Test run not initialized, skipping test case recording for: ${test.name}`,
|
|
1131
|
+
);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
try {
|
|
1136
|
+
let errorMessage = null;
|
|
1137
|
+
let errorStack = null;
|
|
1138
|
+
|
|
1139
|
+
if (
|
|
1140
|
+
result.state === "failed" &&
|
|
1141
|
+
result.errors &&
|
|
1142
|
+
result.errors.length > 0
|
|
1143
|
+
) {
|
|
1144
|
+
const error = result.errors[0];
|
|
1145
|
+
errorMessage = error.message;
|
|
1146
|
+
errorStack = error.stack;
|
|
1147
|
+
|
|
1148
|
+
// Note: We do NOT report test failures to Sentry.
|
|
1149
|
+
// Test failures are expected behavior (they indicate a test found a bug).
|
|
1150
|
+
// We only want actual SDK crashes and exceptions reported to Sentry.
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const suiteName = test.suite?.name;
|
|
1154
|
+
const startTime = Date.now() - duration; // Calculate start time from duration
|
|
1155
|
+
// In Vitest v4, retryCount is on diagnostic(), not result()
|
|
1156
|
+
// result() only returns { state, errors }, while diagnostic() has retryCount, duration, etc.
|
|
1157
|
+
const diagnostic = test.diagnostic?.();
|
|
1158
|
+
const retryCount = diagnostic?.retryCount || 0;
|
|
1159
|
+
const testRunDbId = process.env.TD_TEST_RUN_DB_ID;
|
|
1160
|
+
const consoleUrl = getConsoleUrl(pluginState.apiRoot);
|
|
1161
|
+
const hasRetries = retryCount > 0 && dashcamUrls.length > 1;
|
|
1162
|
+
|
|
1163
|
+
// Record a single test case with all metadata
|
|
1164
|
+
const testCaseData = {
|
|
1165
|
+
runId: testRunId,
|
|
1166
|
+
testName: test.name,
|
|
1167
|
+
testFile: testFile,
|
|
1168
|
+
testOrder: testOrder,
|
|
1169
|
+
status,
|
|
1170
|
+
startTime: startTime,
|
|
1171
|
+
endTime: Date.now(),
|
|
1172
|
+
duration: duration,
|
|
1173
|
+
retries: retryCount,
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
// Add sessionId if available
|
|
1177
|
+
if (sessionId) {
|
|
1178
|
+
testCaseData.sessionId = sessionId;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Only include replayUrl if we have a valid dashcam URL
|
|
1182
|
+
if (dashcamUrl) {
|
|
1183
|
+
testCaseData.replayUrl = dashcamUrl;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Include per-attempt replay URLs for retry visibility
|
|
1187
|
+
if (dashcamUrls.length > 0) {
|
|
1188
|
+
const attemptUrls = dashcamUrls
|
|
1189
|
+
.map(a => ({ attempt: a.attempt, url: a.url || null, sessionId: a.sessionId || null }));
|
|
1190
|
+
testCaseData.replayUrls = attemptUrls;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (suiteName) testCaseData.suiteName = suiteName;
|
|
1194
|
+
if (errorMessage) testCaseData.errorMessage = errorMessage;
|
|
1195
|
+
if (errorStack) testCaseData.errorStack = errorStack;
|
|
1196
|
+
|
|
1197
|
+
logger.debug(
|
|
1198
|
+
`Recording test case: ${test.name} (${status}) with testFile: ${testFile}, testOrder: ${testOrder}, duration: ${duration}ms, replay: ${dashcamUrl ? "yes" : "no"}`,
|
|
1199
|
+
);
|
|
1200
|
+
|
|
1201
|
+
const testCaseResponse = await recordTestCaseDirect(
|
|
1202
|
+
token,
|
|
1203
|
+
pluginState.apiRoot,
|
|
1204
|
+
testCaseData,
|
|
1205
|
+
);
|
|
1206
|
+
|
|
1207
|
+
const testCaseDbId = testCaseResponse.data?.id;
|
|
1208
|
+
|
|
1209
|
+
// Store test case data for GitHub comment generation
|
|
1210
|
+
pluginState.recordedTestCases.push({
|
|
1211
|
+
...testCaseData,
|
|
1212
|
+
id: testCaseDbId,
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
console.log("");
|
|
1216
|
+
console.log(
|
|
1217
|
+
chalk.cyan(`š Test Report: ${consoleUrl}/runs/${testRunDbId}/${testCaseDbId}`),
|
|
1218
|
+
);
|
|
1219
|
+
console.log("");
|
|
1220
|
+
|
|
1221
|
+
// If there were retries, list all per-attempt dashcam URLs for debugging
|
|
1222
|
+
if (hasRetries) {
|
|
1223
|
+
const validAttempts = dashcamUrls.filter(a => a.url);
|
|
1224
|
+
if (validAttempts.length > 0) {
|
|
1225
|
+
console.log(`š Retry attempts (${dashcamUrls.length} total):`);
|
|
1226
|
+
for (const attempt of validAttempts) {
|
|
1227
|
+
console.log(` Attempt ${attempt.attempt}: ${attempt.url}`);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Output parseable format for docs generation (examples only)
|
|
1233
|
+
if (testFile.startsWith("examples/")) {
|
|
1234
|
+
const testFileName = path.basename(testFile);
|
|
1235
|
+
console.log(
|
|
1236
|
+
`TESTDRIVER_EXAMPLE_URL::${testFileName}::${consoleUrl}/runs/${testRunDbId}/${testCaseDbId}`,
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
} catch (error) {
|
|
1240
|
+
logger.error("Failed to report test case:", error.message);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// ============================================================================
|
|
1246
|
+
// Helper Functions
|
|
1247
|
+
// ============================================================================
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Maps an API root URL to its corresponding web console URL.
|
|
1251
|
+
* The API and web console are served from different domains/ports.
|
|
1252
|
+
*
|
|
1253
|
+
* @param {string} apiRoot - The API root URL (e.g., https://api.testdriver.ai)
|
|
1254
|
+
* @returns {string} The corresponding web console URL
|
|
1255
|
+
*/
|
|
1256
|
+
function getConsoleUrl(apiRoot) {
|
|
1257
|
+
// Explicit override ā use TD_CONSOLE_URL when deliberately set
|
|
1258
|
+
if (process.env.TD_CONSOLE_URL) return process.env.TD_CONSOLE_URL;
|
|
1259
|
+
|
|
1260
|
+
if (!apiRoot) return "https://console.testdriver.ai";
|
|
1261
|
+
|
|
1262
|
+
// Fly.io: swap "-api" for "-web" in the hostname
|
|
1263
|
+
// e.g. preview-138-api.fly.dev -> preview-138-web.fly.dev
|
|
1264
|
+
// td-test-api.fly.dev -> td-test-web.fly.dev
|
|
1265
|
+
const flyMatch = apiRoot.match(/https:\/\/([\w-]+)-api\.fly\.dev/);
|
|
1266
|
+
if (flyMatch) {
|
|
1267
|
+
return `https://${flyMatch[1]}-web.fly.dev`;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Known channel API URLs -> console equivalents
|
|
1271
|
+
// e.g. https://api-canary.testdriver.ai -> https://console-canary.testdriver.ai
|
|
1272
|
+
for (const url of Object.values(channelConfig.channels)) {
|
|
1273
|
+
if (url === apiRoot) {
|
|
1274
|
+
return url.replace("api", "console").replace("1337", "3001");
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Local development
|
|
1279
|
+
if (apiRoot.includes("ngrok.io") || apiRoot.includes("trycloudflare.com") || apiRoot.includes("localhost")) {
|
|
1280
|
+
return "http://localhost:3001";
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Render PR previews (legacy)
|
|
1284
|
+
const renderPrMatch = apiRoot.match(/https:\/\/([\w-]+)-api(-[\w]+)?(-pr-\d+)?\.onrender\.com/);
|
|
1285
|
+
if (renderPrMatch) {
|
|
1286
|
+
const [, prefix, suffix, prSuffix] = renderPrMatch;
|
|
1287
|
+
const webPrefix = (prefix === 'testdriver' && suffix) ? 'web' + suffix : prefix + '-web';
|
|
1288
|
+
return `https://${webPrefix}${prSuffix || ''}.onrender.com`;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
return apiRoot;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function generateRunId() {
|
|
1295
|
+
return `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function getSuiteName() {
|
|
1299
|
+
return process.env.npm_package_name || path.basename(process.cwd());
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function getPlatform() {
|
|
1303
|
+
// First try to get platform from SDK client detected during test execution
|
|
1304
|
+
if (pluginState.detectedPlatform) {
|
|
1305
|
+
logger.debug(
|
|
1306
|
+
`Using platform from SDK client: ${pluginState.detectedPlatform}`,
|
|
1307
|
+
);
|
|
1308
|
+
return pluginState.detectedPlatform;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Try to get platform from dashcam URLs (registered during test cleanup)
|
|
1312
|
+
for (const [, entries] of pluginState.dashcamUrls) {
|
|
1313
|
+
// entries is now an array of {url, platform, attempt}
|
|
1314
|
+
const arr = Array.isArray(entries) ? entries : [entries];
|
|
1315
|
+
for (const data of arr) {
|
|
1316
|
+
if (data.platform) {
|
|
1317
|
+
logger.debug(
|
|
1318
|
+
`Using platform from dashcam URL registration: ${data.platform}`,
|
|
1319
|
+
);
|
|
1320
|
+
return data.platform;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
logger.debug("Platform not yet detected from client");
|
|
1326
|
+
return null;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function calculateStatsFromModules(testModules) {
|
|
1330
|
+
let totalTests = 0;
|
|
1331
|
+
let passedTests = 0;
|
|
1332
|
+
let failedTests = 0;
|
|
1333
|
+
let skippedTests = 0;
|
|
1334
|
+
|
|
1335
|
+
// Guard against corrupt or circular test tree structures
|
|
1336
|
+
// (can happen with --sequence.concurrent in some Vitest versions)
|
|
1337
|
+
const seen = new Set();
|
|
1338
|
+
|
|
1339
|
+
for (const testModule of testModules) {
|
|
1340
|
+
try {
|
|
1341
|
+
for (const testCase of testModule.children.allTests()) {
|
|
1342
|
+
// Deduplicate - skip if we've already counted this test
|
|
1343
|
+
if (seen.has(testCase.id)) continue;
|
|
1344
|
+
seen.add(testCase.id);
|
|
1345
|
+
|
|
1346
|
+
const result = testCase.result();
|
|
1347
|
+
if (result.state === "passed") {
|
|
1348
|
+
passedTests++;
|
|
1349
|
+
totalTests++;
|
|
1350
|
+
} else if (result.state === "failed") {
|
|
1351
|
+
failedTests++;
|
|
1352
|
+
totalTests++;
|
|
1353
|
+
} else if (result.state === "skipped") {
|
|
1354
|
+
skippedTests++;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
} catch (err) {
|
|
1358
|
+
logger.warn(`Error calculating stats for module: ${err.message}`);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
return { totalTests, passedTests, failedTests, skippedTests };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function detectCI() {
|
|
1366
|
+
if (process.env.GITHUB_ACTIONS) return "github";
|
|
1367
|
+
if (process.env.GITLAB_CI) return "gitlab";
|
|
1368
|
+
if (process.env.CIRCLECI) return "circle";
|
|
1369
|
+
if (process.env.TRAVIS) return "travis";
|
|
1370
|
+
if (process.env.JENKINS_URL) return "jenkins";
|
|
1371
|
+
if (process.env.BUILDKITE) return "buildkite";
|
|
1372
|
+
return null;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function getGitInfo() {
|
|
1376
|
+
const info = {};
|
|
1377
|
+
|
|
1378
|
+
if (process.env.GITHUB_ACTIONS) {
|
|
1379
|
+
if (process.env.GITHUB_REPOSITORY)
|
|
1380
|
+
info.repo = process.env.GITHUB_REPOSITORY;
|
|
1381
|
+
if (process.env.GITHUB_REF_NAME) info.branch = process.env.GITHUB_REF_NAME;
|
|
1382
|
+
if (process.env.GITHUB_SHA) info.commit = process.env.GITHUB_SHA;
|
|
1383
|
+
if (process.env.GITHUB_ACTOR) info.author = process.env.GITHUB_ACTOR;
|
|
1384
|
+
} else if (process.env.GITLAB_CI) {
|
|
1385
|
+
if (process.env.CI_PROJECT_PATH) info.repo = process.env.CI_PROJECT_PATH;
|
|
1386
|
+
if (process.env.CI_COMMIT_BRANCH)
|
|
1387
|
+
info.branch = process.env.CI_COMMIT_BRANCH;
|
|
1388
|
+
if (process.env.CI_COMMIT_SHA) info.commit = process.env.CI_COMMIT_SHA;
|
|
1389
|
+
if (process.env.GITLAB_USER_LOGIN)
|
|
1390
|
+
info.author = process.env.GITLAB_USER_LOGIN;
|
|
1391
|
+
} else if (process.env.CIRCLECI) {
|
|
1392
|
+
if (
|
|
1393
|
+
process.env.CIRCLE_PROJECT_USERNAME &&
|
|
1394
|
+
process.env.CIRCLE_PROJECT_REPONAME
|
|
1395
|
+
) {
|
|
1396
|
+
info.repo = `${process.env.CIRCLE_PROJECT_USERNAME}/${process.env.CIRCLE_PROJECT_REPONAME}`;
|
|
1397
|
+
}
|
|
1398
|
+
if (process.env.CIRCLE_BRANCH) info.branch = process.env.CIRCLE_BRANCH;
|
|
1399
|
+
if (process.env.CIRCLE_SHA1) info.commit = process.env.CIRCLE_SHA1;
|
|
1400
|
+
if (process.env.CIRCLE_USERNAME) info.author = process.env.CIRCLE_USERNAME;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// If not in CI or if commit info is missing, try to get it from local git
|
|
1404
|
+
if (!info.commit) {
|
|
1405
|
+
try {
|
|
1406
|
+
info.commit = execSync("git rev-parse HEAD", {
|
|
1407
|
+
encoding: "utf8",
|
|
1408
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
1409
|
+
}).trim();
|
|
1410
|
+
logger.debug("Git commit from local:", info.commit);
|
|
1411
|
+
} catch (e) {
|
|
1412
|
+
logger.debug("Failed to get git commit:", e.message);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
if (!info.branch) {
|
|
1417
|
+
try {
|
|
1418
|
+
info.branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1419
|
+
encoding: "utf8",
|
|
1420
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
1421
|
+
}).trim();
|
|
1422
|
+
logger.debug("Git branch from local:", info.branch);
|
|
1423
|
+
} catch (e) {
|
|
1424
|
+
logger.debug("Failed to get git branch:", e.message);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
if (!info.author) {
|
|
1429
|
+
try {
|
|
1430
|
+
info.author = execSync("git config user.name", {
|
|
1431
|
+
encoding: "utf8",
|
|
1432
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
1433
|
+
}).trim();
|
|
1434
|
+
logger.debug("Git author from local:", info.author);
|
|
1435
|
+
} catch (e) {
|
|
1436
|
+
logger.debug("Failed to get git author:", e.message);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
if (!info.repo) {
|
|
1441
|
+
try {
|
|
1442
|
+
const remoteUrl = execSync("git config --get remote.origin.url", {
|
|
1443
|
+
encoding: "utf8",
|
|
1444
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
1445
|
+
}).trim();
|
|
1446
|
+
|
|
1447
|
+
// Extract repo from git URL (supports both SSH and HTTPS)
|
|
1448
|
+
// SSH: git@github.com:user/repo.git
|
|
1449
|
+
// HTTPS: https://github.com/user/repo.git
|
|
1450
|
+
const match = remoteUrl.match(/[:/]([^/:]+\/[^/:]+?)(\.git)?$/);
|
|
1451
|
+
if (match) {
|
|
1452
|
+
info.repo = match[1];
|
|
1453
|
+
logger.debug("Git repo from local:", info.repo);
|
|
1454
|
+
}
|
|
1455
|
+
} catch (e) {
|
|
1456
|
+
logger.debug("Failed to get git repo:", e.message);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
logger.debug("Collected git info:", info);
|
|
1461
|
+
return info;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// ============================================================================
|
|
1465
|
+
// GitHub Comment Helper
|
|
1466
|
+
// ============================================================================
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* Extract PR number from GitHub Actions environment
|
|
1470
|
+
* Checks multiple sources: env vars, event file, and GITHUB_REF
|
|
1471
|
+
* @returns {string|null} PR number or null if not found
|
|
1472
|
+
*/
|
|
1473
|
+
function extractPRNumber() {
|
|
1474
|
+
// Try direct environment variables first
|
|
1475
|
+
let prNumber =
|
|
1476
|
+
process.env.GITHUB_PR_NUMBER ||
|
|
1477
|
+
process.env.TD_GITHUB_PR ||
|
|
1478
|
+
process.env.PR_NUMBER;
|
|
1479
|
+
|
|
1480
|
+
if (prNumber) {
|
|
1481
|
+
return prNumber;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// Try to extract from GitHub Actions event path
|
|
1485
|
+
if (process.env.GITHUB_EVENT_PATH) {
|
|
1486
|
+
try {
|
|
1487
|
+
const eventData = JSON.parse(
|
|
1488
|
+
fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"),
|
|
1489
|
+
);
|
|
1490
|
+
if (eventData.pull_request?.number) {
|
|
1491
|
+
return String(eventData.pull_request.number);
|
|
1492
|
+
}
|
|
1493
|
+
} catch (err) {
|
|
1494
|
+
logger.debug("Could not read GitHub event file:", err.message);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Try to extract from GITHUB_REF (refs/pull/123/merge or refs/pull/123/head)
|
|
1499
|
+
if (process.env.GITHUB_REF) {
|
|
1500
|
+
const match = process.env.GITHUB_REF.match(
|
|
1501
|
+
/refs\/pull\/(\d+)\/(merge|head)/,
|
|
1502
|
+
);
|
|
1503
|
+
if (match) {
|
|
1504
|
+
return match[1];
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
return null;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Post GitHub comment with test results if enabled
|
|
1513
|
+
* Checks for GitHub token and PR number in environment variables
|
|
1514
|
+
* @param {string} testRunUrl - URL to the test run
|
|
1515
|
+
* @param {Object} stats - Test statistics
|
|
1516
|
+
* @param {Object} completeData - Test run completion data
|
|
1517
|
+
*/
|
|
1518
|
+
async function postGitHubCommentIfEnabled(testRunUrl, stats, completeData) {
|
|
1519
|
+
try {
|
|
1520
|
+
// Check if GitHub comments are explicitly disabled
|
|
1521
|
+
if (process.env.TESTDRIVER_SKIP_GITHUB_COMMENT === "true") {
|
|
1522
|
+
logger.debug(
|
|
1523
|
+
"GitHub comments disabled via TESTDRIVER_SKIP_GITHUB_COMMENT",
|
|
1524
|
+
);
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// Check if GitHub comment posting is enabled
|
|
1529
|
+
const githubToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
1530
|
+
const prNumber = extractPRNumber();
|
|
1531
|
+
const commitSha = process.env.GITHUB_SHA || pluginState.gitInfo.commit;
|
|
1532
|
+
|
|
1533
|
+
// Only post if we have a token and either a PR number or commit SHA
|
|
1534
|
+
if (!githubToken) {
|
|
1535
|
+
logger.debug("GitHub token not found, skipping comment posting");
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
if (!prNumber && !commitSha) {
|
|
1540
|
+
logger.debug(
|
|
1541
|
+
"Neither PR number nor commit SHA found, skipping comment posting",
|
|
1542
|
+
);
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// Extract owner/repo from git info
|
|
1547
|
+
const repo = pluginState.gitInfo.repo;
|
|
1548
|
+
if (!repo) {
|
|
1549
|
+
logger.warn("Repository info not available, skipping GitHub comment");
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
const [owner, repoName] = repo.split("/");
|
|
1554
|
+
if (!owner || !repoName) {
|
|
1555
|
+
logger.warn("Invalid repository format, expected owner/repo");
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
logger.debug("Preparing GitHub comment...");
|
|
1560
|
+
|
|
1561
|
+
// Prepare test run data for comment
|
|
1562
|
+
const testRunData = {
|
|
1563
|
+
runId: pluginState.testRunId,
|
|
1564
|
+
status: completeData.status,
|
|
1565
|
+
totalTests: stats.totalTests,
|
|
1566
|
+
passedTests: stats.passedTests,
|
|
1567
|
+
failedTests: stats.failedTests,
|
|
1568
|
+
skippedTests: stats.skippedTests,
|
|
1569
|
+
duration: completeData.duration,
|
|
1570
|
+
testRunUrl,
|
|
1571
|
+
platform:
|
|
1572
|
+
completeData.platform || pluginState.detectedPlatform || "unknown",
|
|
1573
|
+
branch: pluginState.gitInfo.branch || "unknown",
|
|
1574
|
+
commit: commitSha || "unknown",
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1577
|
+
// Use recorded test cases from pluginState
|
|
1578
|
+
const testCases = pluginState.recordedTestCases || [];
|
|
1579
|
+
|
|
1580
|
+
logger.info(
|
|
1581
|
+
`Posting GitHub comment with ${testCases.length} test cases...`,
|
|
1582
|
+
);
|
|
1583
|
+
|
|
1584
|
+
// Post or update GitHub comment
|
|
1585
|
+
const githubOptions = {
|
|
1586
|
+
token: githubToken,
|
|
1587
|
+
owner,
|
|
1588
|
+
repo: repoName,
|
|
1589
|
+
prNumber: prNumber ? parseInt(prNumber, 10) : undefined,
|
|
1590
|
+
commitSha: commitSha,
|
|
1591
|
+
};
|
|
1592
|
+
|
|
1593
|
+
const comment = await postOrUpdateTestResults(
|
|
1594
|
+
testRunData,
|
|
1595
|
+
testCases,
|
|
1596
|
+
githubOptions,
|
|
1597
|
+
);
|
|
1598
|
+
logger.info(`ā
GitHub comment posted: ${comment.html_url}`);
|
|
1599
|
+
console.log(`\nš GitHub Comment: ${comment.html_url}\n`);
|
|
1600
|
+
} catch (error) {
|
|
1601
|
+
logger.warn("Failed to post GitHub comment:", error.message);
|
|
1602
|
+
logger.debug("GitHub comment error stack:", error.stack);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// ============================================================================
|
|
1607
|
+
// API Methods
|
|
1608
|
+
// ============================================================================
|
|
1609
|
+
|
|
1610
|
+
async function authenticate() {
|
|
1611
|
+
const url = `${pluginState.apiRoot}/auth/exchange-api-key`;
|
|
1612
|
+
const response = await withTimeout(
|
|
1613
|
+
fetch(url, {
|
|
1614
|
+
method: "POST",
|
|
1615
|
+
headers: {
|
|
1616
|
+
"Content-Type": "application/json",
|
|
1617
|
+
},
|
|
1618
|
+
body: JSON.stringify({
|
|
1619
|
+
apiKey: pluginState.apiKey,
|
|
1620
|
+
}),
|
|
1621
|
+
}),
|
|
1622
|
+
10000,
|
|
1623
|
+
"Internal Authentication",
|
|
1624
|
+
);
|
|
1625
|
+
|
|
1626
|
+
if (!response.ok) {
|
|
1627
|
+
throw new Error(
|
|
1628
|
+
`Authentication failed: ${response.status} ${response.statusText}`,
|
|
1629
|
+
);
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
const data = await response.json();
|
|
1633
|
+
pluginState.token = data.token;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
async function createTestRun(data) {
|
|
1637
|
+
const url = `${pluginState.apiRoot}/api/v1/testdriver/test-run-create`;
|
|
1638
|
+
const response = await withTimeout(
|
|
1639
|
+
fetch(url, {
|
|
1640
|
+
method: "POST",
|
|
1641
|
+
headers: {
|
|
1642
|
+
"Content-Type": "application/json",
|
|
1643
|
+
Authorization: `Bearer ${pluginState.token}`,
|
|
1644
|
+
},
|
|
1645
|
+
body: JSON.stringify(data),
|
|
1646
|
+
}),
|
|
1647
|
+
10000,
|
|
1648
|
+
"Internal Create Test Run",
|
|
1649
|
+
);
|
|
1650
|
+
|
|
1651
|
+
if (!response.ok) {
|
|
1652
|
+
const errorText = await response.text();
|
|
1653
|
+
throw new Error(
|
|
1654
|
+
`API error: ${response.status} ${response.statusText} - ${errorText}`,
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
return await response.json();
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
async function completeTestRun(data) {
|
|
1662
|
+
const url = `${pluginState.apiRoot}/api/v1/testdriver/test-run-complete`;
|
|
1663
|
+
logger.debug(`completeTestRun: POSTing to ${url}`);
|
|
1664
|
+
|
|
1665
|
+
try {
|
|
1666
|
+
const response = await withTimeout(
|
|
1667
|
+
fetch(url, {
|
|
1668
|
+
method: "POST",
|
|
1669
|
+
headers: {
|
|
1670
|
+
"Content-Type": "application/json",
|
|
1671
|
+
Authorization: `Bearer ${pluginState.token}`,
|
|
1672
|
+
},
|
|
1673
|
+
body: JSON.stringify(data),
|
|
1674
|
+
}),
|
|
1675
|
+
10000,
|
|
1676
|
+
"Internal Complete Test Run",
|
|
1677
|
+
);
|
|
1678
|
+
|
|
1679
|
+
logger.debug(`completeTestRun: Response status ${response.status}`);
|
|
1680
|
+
|
|
1681
|
+
if (!response.ok) {
|
|
1682
|
+
const errorText = await response.text();
|
|
1683
|
+
throw new Error(
|
|
1684
|
+
`API error: ${response.status} ${response.statusText} - ${errorText}`,
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
const result = await response.json();
|
|
1689
|
+
logger.debug(`completeTestRun: Success`);
|
|
1690
|
+
return result;
|
|
1691
|
+
} catch (error) {
|
|
1692
|
+
logger.error(`completeTestRun: Error - ${error.message}`);
|
|
1693
|
+
throw error;
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// Global state setup moved to setup file (vitestSetup.mjs)
|
|
1698
|
+
// The setup file imports the exported functions and makes them available globally in worker processes
|