@testdriverai/agent 7.8.0-test.38

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