testdriverai 6.2.2 → 7.0.0

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 (264) hide show
  1. package/.github/workflows/acceptance-linux.yml +75 -0
  2. package/.github/workflows/acceptance-sdk-tests.yml +133 -0
  3. package/.vscode/settings.json +5 -1
  4. package/MIGRATION.md +389 -0
  5. package/PLUGIN_MIGRATION.md +222 -0
  6. package/PROMPT_CACHE.md +200 -0
  7. package/SDK_LOGGING.md +222 -0
  8. package/SDK_MIGRATION.md +474 -0
  9. package/SDK_README.md +1122 -0
  10. package/{testdriver → _testdriver}/acceptance/drag-and-drop.yaml +2 -2
  11. package/{testdriver → _testdriver}/acceptance/snippets/login.yaml +1 -1
  12. package/_testdriver/examples/desktop/lifecycle/prerun.yaml +0 -0
  13. package/{testdriver → _testdriver}/examples/web/lifecycle/prerun.yaml +6 -1
  14. package/{testdriver → _testdriver}/lifecycle/postrun.yaml +3 -2
  15. package/_testdriver/lifecycle/prerun.yaml +15 -0
  16. package/{testdriver → _testdriver}/lifecycle/provision.yaml +7 -2
  17. package/agent/index.js +258 -68
  18. package/agent/interface.js +15 -0
  19. package/agent/lib/cache.js +142 -0
  20. package/agent/lib/commander.js +1 -39
  21. package/agent/lib/commands.js +143 -188
  22. package/agent/lib/redraw.js +6 -3
  23. package/agent/lib/sandbox.js +19 -5
  24. package/agent/lib/sdk.js +1 -0
  25. package/agent/lib/system.js +0 -3
  26. package/agent/lib/validation.js +1 -7
  27. package/debug-locate-response.js +82 -0
  28. package/debug-screenshot-1763401388589.png +0 -0
  29. package/debugger/index.html +15 -4
  30. package/docs/ARCHITECTURE.md +424 -0
  31. package/docs/AWESOME_LOGS_QUICK_REF.md +100 -0
  32. package/docs/QUICK_START_TEST_RECORDING.md +215 -0
  33. package/docs/SDK_AWESOME_LOGS.md +468 -0
  34. package/docs/TEST_RECORDING.md +388 -0
  35. package/docs/docs.json +232 -152
  36. package/docs/sdk-browser-rendering.md +167 -0
  37. package/docs/v6/getting-started/self-hosting.mdx +407 -0
  38. package/docs/{guide → v6/guide}/dashcam.mdx +1 -1
  39. package/docs/{guide → v6/guide}/environment-variables.mdx +4 -5
  40. package/docs/{guide → v6/guide}/lifecycle.mdx +1 -1
  41. package/docs/v6/overview/comparison.mdx +101 -0
  42. package/docs/v7/README.md +135 -0
  43. package/docs/v7/api/ai.mdx +205 -0
  44. package/docs/v7/api/assert.mdx +285 -0
  45. package/docs/v7/api/assertions.mdx +403 -0
  46. package/docs/v7/api/click.mdx +287 -0
  47. package/docs/v7/api/client.mdx +322 -0
  48. package/docs/v7/api/elements.mdx +479 -0
  49. package/docs/v7/api/exec.mdx +346 -0
  50. package/docs/v7/api/find.mdx +316 -0
  51. package/docs/v7/api/focusApplication.mdx +294 -0
  52. package/docs/v7/api/hover.mdx +279 -0
  53. package/docs/v7/api/pressKeys.mdx +349 -0
  54. package/docs/v7/api/sandbox.mdx +404 -0
  55. package/docs/v7/api/scroll.mdx +300 -0
  56. package/docs/v7/api/type.mdx +314 -0
  57. package/docs/v7/commands/assert.mdx +45 -0
  58. package/docs/v7/commands/exec.mdx +282 -0
  59. package/docs/v7/commands/focus-application.mdx +44 -0
  60. package/docs/v7/commands/hover-image.mdx +69 -0
  61. package/docs/v7/commands/hover-text.mdx +47 -0
  62. package/docs/v7/commands/if.mdx +53 -0
  63. package/docs/v7/commands/match-image.mdx +67 -0
  64. package/docs/v7/commands/press-keys.mdx +87 -0
  65. package/docs/v7/commands/remember.mdx +49 -0
  66. package/docs/v7/commands/run.mdx +44 -0
  67. package/docs/v7/commands/scroll-until-image.mdx +66 -0
  68. package/docs/v7/commands/scroll-until-text.mdx +60 -0
  69. package/docs/v7/commands/scroll.mdx +69 -0
  70. package/docs/v7/commands/type.mdx +45 -0
  71. package/docs/v7/commands/wait-for-image.mdx +54 -0
  72. package/docs/v7/commands/wait-for-text.mdx +48 -0
  73. package/docs/v7/commands/wait.mdx +45 -0
  74. package/docs/v7/getting-started/quickstart.mdx +199 -0
  75. package/docs/v7/guides/migration.mdx +562 -0
  76. package/docs/{getting-started → v7/guides}/self-hosting.mdx +11 -12
  77. package/docs/v7/playwright.mdx +342 -0
  78. package/eslint.config.js +19 -1
  79. package/examples/run-tests-with-recording.sh +70 -0
  80. package/examples/screenshot-example.js +63 -0
  81. package/examples/sdk-awesome-logs-demo.js +177 -0
  82. package/examples/sdk-cache-thresholds.js +96 -0
  83. package/examples/sdk-element-properties.js +155 -0
  84. package/examples/sdk-simple-example.js +65 -0
  85. package/examples/test-recording-example.test.js +166 -0
  86. package/interfaces/cli/lib/base.js +10 -4
  87. package/interfaces/logger.js +2 -1
  88. package/interfaces/shared-test-state.mjs +69 -0
  89. package/interfaces/vitest-plugin.mjs +744 -0
  90. package/mcp-server/AI_GUIDELINES.md +57 -0
  91. package/package.json +18 -5
  92. package/schema.json +8 -29
  93. package/scripts/view-test-results.mjs +96 -0
  94. package/sdk-log-formatter.js +714 -0
  95. package/sdk.d.ts +735 -0
  96. package/sdk.js +1906 -0
  97. package/{.github/workflows/self-hosted.yml → self-hosted.yml} +13 -4
  98. package/setup/aws/cloudformation.yaml +9 -2
  99. package/test/mcp-example-test.yaml +27 -0
  100. package/test-find-api.js +73 -0
  101. package/test-prompt-cache.js +96 -0
  102. package/test-sandbox-render.js +28 -0
  103. package/test-sdk-methods.js +15 -0
  104. package/test-sdk-refactor.js +53 -0
  105. package/test-stack-trace.mjs +57 -0
  106. package/testdriver/acceptance-sdk/QUICK_REFERENCE.md +61 -0
  107. package/testdriver/acceptance-sdk/README.md +128 -0
  108. package/testdriver/acceptance-sdk/TEST_REPORTING.md +245 -0
  109. package/testdriver/acceptance-sdk/assert.test.mjs +44 -0
  110. package/testdriver/acceptance-sdk/drag-and-drop.test.mjs +70 -0
  111. package/testdriver/acceptance-sdk/element-not-found.test.mjs +38 -0
  112. package/testdriver/acceptance-sdk/exec-js.test.mjs +55 -0
  113. package/testdriver/acceptance-sdk/exec-output.test.mjs +71 -0
  114. package/testdriver/acceptance-sdk/exec-pwsh.test.mjs +69 -0
  115. package/testdriver/acceptance-sdk/focus-window.test.mjs +48 -0
  116. package/testdriver/acceptance-sdk/formatted-logging.test.mjs +41 -0
  117. package/testdriver/acceptance-sdk/hover-image.test.mjs +43 -0
  118. package/testdriver/acceptance-sdk/hover-text-with-description.test.mjs +50 -0
  119. package/testdriver/acceptance-sdk/hover-text.test.mjs +41 -0
  120. package/testdriver/acceptance-sdk/match-image.test.mjs +48 -0
  121. package/testdriver/acceptance-sdk/press-keys.test.mjs +64 -0
  122. package/testdriver/acceptance-sdk/prompt.test.mjs +45 -0
  123. package/testdriver/acceptance-sdk/scroll-keyboard.test.mjs +52 -0
  124. package/testdriver/acceptance-sdk/scroll-until-image.test.mjs +51 -0
  125. package/testdriver/acceptance-sdk/scroll-until-text.test.mjs +42 -0
  126. package/testdriver/acceptance-sdk/scroll.test.mjs +50 -0
  127. package/testdriver/acceptance-sdk/setup/globalTeardown.mjs +11 -0
  128. package/testdriver/acceptance-sdk/setup/lifecycleHelpers.mjs +239 -0
  129. package/testdriver/acceptance-sdk/setup/testHelpers.mjs +648 -0
  130. package/testdriver/acceptance-sdk/setup/vitestSetup.mjs +40 -0
  131. package/testdriver/acceptance-sdk/type-checking-demo.js +49 -0
  132. package/testdriver/acceptance-sdk/type.test.mjs +84 -0
  133. package/verify-element-api.js +89 -0
  134. package/verify-types.js +0 -0
  135. package/vitest.config.example.js +19 -0
  136. package/vitest.config.mjs +65 -0
  137. package/vitest.config.mjs.bak +44 -0
  138. package/.github/workflows/acceptance-v6.yml +0 -169
  139. package/docs/overview/comparison.mdx +0 -82
  140. package/testdriver/lifecycle/prerun.yaml +0 -17
  141. /package/{testdriver/examples/desktop/lifecycle/prerun.yaml → .env.example} +0 -0
  142. /package/{testdriver → _testdriver}/acceptance/assert.yaml +0 -0
  143. /package/{testdriver → _testdriver}/acceptance/dashcam.yaml +0 -0
  144. /package/{testdriver → _testdriver}/acceptance/embed.yaml +0 -0
  145. /package/{testdriver → _testdriver}/acceptance/exec-js.yaml +0 -0
  146. /package/{testdriver → _testdriver}/acceptance/exec-output.yaml +0 -0
  147. /package/{testdriver → _testdriver}/acceptance/exec-shell.yaml +0 -0
  148. /package/{testdriver → _testdriver}/acceptance/focus-window.yaml +0 -0
  149. /package/{testdriver → _testdriver}/acceptance/hover-image.yaml +0 -0
  150. /package/{testdriver → _testdriver}/acceptance/hover-text-with-description.yaml +0 -0
  151. /package/{testdriver → _testdriver}/acceptance/hover-text.yaml +0 -0
  152. /package/{testdriver → _testdriver}/acceptance/if-else.yaml +0 -0
  153. /package/{testdriver → _testdriver}/acceptance/match-image.yaml +0 -0
  154. /package/{testdriver → _testdriver}/acceptance/press-keys.yaml +0 -0
  155. /package/{testdriver → _testdriver}/acceptance/prompt.yaml +0 -0
  156. /package/{testdriver → _testdriver}/acceptance/remember.yaml +0 -0
  157. /package/{testdriver → _testdriver}/acceptance/screenshots/cart.png +0 -0
  158. /package/{testdriver → _testdriver}/acceptance/scroll-keyboard.yaml +0 -0
  159. /package/{testdriver → _testdriver}/acceptance/scroll-until-image.yaml +0 -0
  160. /package/{testdriver → _testdriver}/acceptance/scroll-until-text.yaml +0 -0
  161. /package/{testdriver → _testdriver}/acceptance/scroll.yaml +0 -0
  162. /package/{testdriver → _testdriver}/acceptance/snippets/match-cart.yaml +0 -0
  163. /package/{testdriver → _testdriver}/acceptance/type.yaml +0 -0
  164. /package/{testdriver → _testdriver}/behavior/failure.yaml +0 -0
  165. /package/{testdriver → _testdriver}/behavior/hover-text.yaml +0 -0
  166. /package/{testdriver → _testdriver}/behavior/lifecycle/postrun.yaml +0 -0
  167. /package/{testdriver → _testdriver}/behavior/lifecycle/prerun.yaml +0 -0
  168. /package/{testdriver → _testdriver}/behavior/lifecycle/provision.yaml +0 -0
  169. /package/{testdriver → _testdriver}/behavior/secrets.yaml +0 -0
  170. /package/{testdriver → _testdriver}/edge-cases/dashcam-chrome.yaml +0 -0
  171. /package/{testdriver → _testdriver}/edge-cases/exec-pwsh-multiline.yaml +0 -0
  172. /package/{testdriver → _testdriver}/edge-cases/js-exception.yaml +0 -0
  173. /package/{testdriver → _testdriver}/edge-cases/js-promise.yaml +0 -0
  174. /package/{testdriver → _testdriver}/edge-cases/lifecycle/postrun.yaml +0 -0
  175. /package/{testdriver → _testdriver}/edge-cases/prompt-in-middle.yaml +0 -0
  176. /package/{testdriver → _testdriver}/edge-cases/prompt-nested.yaml +0 -0
  177. /package/{testdriver → _testdriver}/edge-cases/success-test.yaml +0 -0
  178. /package/{testdriver → _testdriver}/examples/android/example.yaml +0 -0
  179. /package/{testdriver → _testdriver}/examples/android/lifecycle/postrun.yaml +0 -0
  180. /package/{testdriver → _testdriver}/examples/android/lifecycle/provision.yaml +0 -0
  181. /package/{testdriver → _testdriver}/examples/android/readme.md +0 -0
  182. /package/{testdriver → _testdriver}/examples/chrome-extension/lifecycle/provision.yaml +0 -0
  183. /package/{testdriver → _testdriver}/examples/desktop/lifecycle/provision.yaml +0 -0
  184. /package/{testdriver → _testdriver}/examples/vscode-extension/lifecycle/provision.yaml +0 -0
  185. /package/{testdriver → _testdriver}/examples/web/lifecycle/postrun.yaml +0 -0
  186. /package/docs/{account → v6/account}/dashboard.mdx +0 -0
  187. /package/docs/{account → v6/account}/enterprise.mdx +0 -0
  188. /package/docs/{account → v6/account}/pricing.mdx +0 -0
  189. /package/docs/{account → v6/account}/projects.mdx +0 -0
  190. /package/docs/{account → v6/account}/team.mdx +0 -0
  191. /package/docs/{action → v6/action}/ami.mdx +0 -0
  192. /package/docs/{action → v6/action}/performance.mdx +0 -0
  193. /package/docs/{action → v6/action}/secrets.mdx +0 -0
  194. /package/docs/{apps → v6/apps}/chrome-extensions.mdx +0 -0
  195. /package/docs/{apps → v6/apps}/desktop-apps.mdx +0 -0
  196. /package/docs/{apps → v6/apps}/mobile-apps.mdx +0 -0
  197. /package/docs/{apps → v6/apps}/static-websites.mdx +0 -0
  198. /package/docs/{apps → v6/apps}/tauri-apps.mdx +0 -0
  199. /package/docs/{bugs → v6/bugs}/jira.mdx +0 -0
  200. /package/docs/{cli → v6/cli}/overview.mdx +0 -0
  201. /package/docs/{commands → v6/commands}/assert.mdx +0 -0
  202. /package/docs/{commands → v6/commands}/exec.mdx +0 -0
  203. /package/docs/{commands → v6/commands}/focus-application.mdx +0 -0
  204. /package/docs/{commands → v6/commands}/hover-image.mdx +0 -0
  205. /package/docs/{commands → v6/commands}/hover-text.mdx +0 -0
  206. /package/docs/{commands → v6/commands}/if.mdx +0 -0
  207. /package/docs/{commands → v6/commands}/match-image.mdx +0 -0
  208. /package/docs/{commands → v6/commands}/press-keys.mdx +0 -0
  209. /package/docs/{commands → v6/commands}/remember.mdx +0 -0
  210. /package/docs/{commands → v6/commands}/run.mdx +0 -0
  211. /package/docs/{commands → v6/commands}/scroll-until-image.mdx +0 -0
  212. /package/docs/{commands → v6/commands}/scroll-until-text.mdx +0 -0
  213. /package/docs/{commands → v6/commands}/scroll.mdx +0 -0
  214. /package/docs/{commands → v6/commands}/type.mdx +0 -0
  215. /package/docs/{commands → v6/commands}/wait-for-image.mdx +0 -0
  216. /package/docs/{commands → v6/commands}/wait-for-text.mdx +0 -0
  217. /package/docs/{commands → v6/commands}/wait.mdx +0 -0
  218. /package/docs/{exporting → v6/exporting}/junit.mdx +0 -0
  219. /package/docs/{exporting → v6/exporting}/playwright.mdx +0 -0
  220. /package/docs/{features → v6/features}/auto-healing.mdx +0 -0
  221. /package/docs/{features → v6/features}/generation.mdx +0 -0
  222. /package/docs/{features → v6/features}/parallel-testing.mdx +0 -0
  223. /package/docs/{features → v6/features}/reusable-snippets.mdx +0 -0
  224. /package/docs/{features → v6/features}/selectorless.mdx +0 -0
  225. /package/docs/{features → v6/features}/visual-assertions.mdx +0 -0
  226. /package/docs/{getting-started → v6/getting-started}/ci.mdx +0 -0
  227. /package/docs/{getting-started → v6/getting-started}/cli.mdx +0 -0
  228. /package/docs/{getting-started → v6/getting-started}/editing.mdx +0 -0
  229. /package/docs/{getting-started → v6/getting-started}/playwright.mdx +0 -0
  230. /package/docs/{getting-started → v6/getting-started}/running.mdx +0 -0
  231. /package/docs/{getting-started → v6/getting-started}/vscode.mdx +0 -0
  232. /package/docs/{guide → v6/guide}/assertions.mdx +0 -0
  233. /package/docs/{guide → v6/guide}/authentication.mdx +0 -0
  234. /package/docs/{guide → v6/guide}/code.mdx +0 -0
  235. /package/docs/{guide → v6/guide}/locating.mdx +0 -0
  236. /package/docs/{guide → v6/guide}/protips.mdx +0 -0
  237. /package/docs/{guide → v6/guide}/variables.mdx +0 -0
  238. /package/docs/{guide → v6/guide}/waiting.mdx +0 -0
  239. /package/docs/{importing → v6/importing}/csv.mdx +0 -0
  240. /package/docs/{importing → v6/importing}/gherkin.mdx +0 -0
  241. /package/docs/{importing → v6/importing}/jira.mdx +0 -0
  242. /package/docs/{importing → v6/importing}/testrail.mdx +0 -0
  243. /package/docs/{integrations → v6/integrations}/electron.mdx +0 -0
  244. /package/docs/{integrations → v6/integrations}/netlify.mdx +0 -0
  245. /package/docs/{integrations → v6/integrations}/vercel.mdx +0 -0
  246. /package/docs/{interactive → v6/interactive}/explore.mdx +0 -0
  247. /package/docs/{interactive → v6/interactive}/run.mdx +0 -0
  248. /package/docs/{interactive → v6/interactive}/save.mdx +0 -0
  249. /package/docs/{overview → v6/overview}/faq.mdx +0 -0
  250. /package/docs/{overview → v6/overview}/performance.mdx +0 -0
  251. /package/docs/{overview → v6/overview}/quickstart.mdx +0 -0
  252. /package/docs/{overview → v6/overview}/what-is-testdriver.mdx +0 -0
  253. /package/docs/{scenarios → v6/scenarios}/ai-chatbot.mdx +0 -0
  254. /package/docs/{scenarios → v6/scenarios}/cookie-banner.mdx +0 -0
  255. /package/docs/{scenarios → v6/scenarios}/file-upload.mdx +0 -0
  256. /package/docs/{scenarios → v6/scenarios}/form-filling.mdx +0 -0
  257. /package/docs/{scenarios → v6/scenarios}/log-in.mdx +0 -0
  258. /package/docs/{scenarios → v6/scenarios}/pdf-generation.mdx +0 -0
  259. /package/docs/{scenarios → v6/scenarios}/spell-check.mdx +0 -0
  260. /package/docs/{security → v6/security}/action.mdx +0 -0
  261. /package/docs/{security → v6/security}/agent.mdx +0 -0
  262. /package/docs/{security → v6/security}/platform.mdx +0 -0
  263. /package/docs/{tutorials → v6/tutorials}/advanced-test.mdx +0 -0
  264. /package/docs/{tutorials → v6/tutorials}/basic-test.mdx +0 -0
package/sdk.js ADDED
@@ -0,0 +1,1906 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+ const { formatter } = require("./sdk-log-formatter");
7
+
8
+ /**
9
+ * Custom error class for element operation failures
10
+ * Includes debugging information like screenshots and AI responses
11
+ */
12
+ class ElementNotFoundError extends Error {
13
+ constructor(message, debugInfo = {}) {
14
+ super(message);
15
+ this.name = "ElementNotFoundError";
16
+ this.screenshot = debugInfo.screenshot;
17
+ this.aiResponse = debugInfo.aiResponse;
18
+ this.description = debugInfo.description;
19
+ this.timestamp = new Date().toISOString();
20
+ this.screenshotPath = null;
21
+
22
+ // Capture stack trace but skip internal frames
23
+ if (Error.captureStackTrace) {
24
+ Error.captureStackTrace(this, ElementNotFoundError);
25
+ }
26
+
27
+ // Write screenshot to temp directory
28
+ if (this.screenshot) {
29
+ try {
30
+ const tempDir = path.join(os.tmpdir(), "testdriver-debug");
31
+ if (!fs.existsSync(tempDir)) {
32
+ fs.mkdirSync(tempDir, { recursive: true });
33
+ }
34
+
35
+ const filename = `screenshot-${Date.now()}.png`;
36
+ this.screenshotPath = path.join(tempDir, filename);
37
+
38
+ // Remove data:image/png;base64, prefix if present
39
+ const base64Data = this.screenshot.replace(
40
+ /^data:image\/\w+;base64,/,
41
+ "",
42
+ );
43
+ const buffer = Buffer.from(base64Data, "base64");
44
+
45
+ fs.writeFileSync(this.screenshotPath, buffer);
46
+ } catch {
47
+ // If screenshot save fails, don't break the error
48
+ // Can't emit from constructor, just skip logging
49
+ }
50
+ }
51
+
52
+ // Save cached image if available
53
+ this.cachedImagePath = null;
54
+ if (debugInfo.cachedImageUrl) {
55
+ this.cachedImagePath = debugInfo.cachedImageUrl;
56
+ }
57
+
58
+ // Save pixel diff image if available
59
+ this.pixelDiffPath = null;
60
+ if (debugInfo.pixelDiffImage) {
61
+ try {
62
+ const tempDir = path.join(os.tmpdir(), "testdriver-debug");
63
+ if (!fs.existsSync(tempDir)) {
64
+ fs.mkdirSync(tempDir, { recursive: true });
65
+ }
66
+
67
+ const filename = `pixel-diff-error-${Date.now()}.png`;
68
+ this.pixelDiffPath = path.join(tempDir, filename);
69
+
70
+ const base64Data = debugInfo.pixelDiffImage.replace(
71
+ /^data:image\/\w+;base64,/,
72
+ "",
73
+ );
74
+ const buffer = Buffer.from(base64Data, "base64");
75
+
76
+ fs.writeFileSync(this.pixelDiffPath, buffer);
77
+ } catch {
78
+ // Silently skip logging error from constructor
79
+ }
80
+ }
81
+
82
+ // Extract similarity and input text from AI response
83
+ const similarity = this.aiResponse?.similarity ?? null;
84
+ const cacheHit =
85
+ this.aiResponse?.cacheHit ?? this.aiResponse?.cached ?? false;
86
+ const cacheStrategy = this.aiResponse?.cacheStrategy ?? null;
87
+ const cacheCreatedAt = this.aiResponse?.cacheCreatedAt ?? null;
88
+ const cacheDiffPercent = this.aiResponse?.cacheDiffPercent ?? null;
89
+ const threshold = debugInfo.threshold ?? null;
90
+ const inputText =
91
+ this.aiResponse?.input_text ?? this.aiResponse?.element ?? null;
92
+
93
+ // Enhance error message with debugging hints
94
+ this.message += `\n\n=== Debug Information ===`;
95
+ this.message += `\nElement searched for: "${this.description}"`;
96
+
97
+ if (threshold !== null) {
98
+ const similarityRequired = ((1 - threshold) * 100).toFixed(1);
99
+ this.message += `\nCache threshold: ${threshold} (${similarityRequired}% similarity required)`;
100
+ }
101
+
102
+ if (cacheHit) {
103
+ this.message += `\nCache: HIT`;
104
+ if (cacheStrategy) {
105
+ this.message += ` (${cacheStrategy} strategy)`;
106
+ }
107
+ if (cacheCreatedAt) {
108
+ const cacheAge = Math.round(
109
+ (Date.now() - new Date(cacheCreatedAt).getTime()) / 1000,
110
+ );
111
+ this.message += `\nCache created: ${new Date(cacheCreatedAt).toISOString()} (${cacheAge}s ago)`;
112
+ }
113
+ if (cacheDiffPercent !== null) {
114
+ this.message += `\nCache pixel diff: ${(cacheDiffPercent * 100).toFixed(2)}%`;
115
+ }
116
+ } else {
117
+ this.message += `\nCache: MISS`;
118
+ }
119
+
120
+ if (similarity !== null) {
121
+ const similarityPercent = (similarity * 100).toFixed(2);
122
+ this.message += `\nSimilarity score: ${similarityPercent}%`;
123
+
124
+ if (threshold !== null && similarity < 1 - threshold) {
125
+ this.message += ` (below threshold)`;
126
+ }
127
+ }
128
+
129
+ if (inputText) {
130
+ this.message += `\nInput text: "${inputText}"`;
131
+ }
132
+
133
+ if (this.screenshotPath) {
134
+ this.message += `\nCurrent screenshot: ${this.screenshotPath}`;
135
+ }
136
+
137
+ if (this.cachedImagePath) {
138
+ this.message += `\nCached image URL: ${this.cachedImagePath}`;
139
+ }
140
+
141
+ if (this.pixelDiffPath) {
142
+ this.message += `\nPixel diff image: ${this.pixelDiffPath}`;
143
+ }
144
+
145
+ if (this.aiResponse) {
146
+ const responseText =
147
+ this.aiResponse.response?.content?.[0]?.text ||
148
+ this.aiResponse.content?.[0]?.text ||
149
+ "No detailed response available";
150
+ this.message += `\n\nAI Response:\n${responseText}`;
151
+ }
152
+
153
+ // Clean up stack trace to only show userland code
154
+ if (this.stack) {
155
+ const lines = this.stack.split("\n");
156
+ const filteredLines = [lines[0]]; // Keep error message line
157
+
158
+ // Skip frames until we find userland code (not sdk.js internals)
159
+ let foundUserland = false;
160
+ for (let i = 1; i < lines.length; i++) {
161
+ const line = lines[i];
162
+
163
+ // Skip internal Element method frames (click, hover, etc.)
164
+ if (
165
+ line.includes("Element.click") ||
166
+ line.includes("Element.hover") ||
167
+ line.includes("Element.doubleClick") ||
168
+ line.includes("Element.rightClick") ||
169
+ line.includes("Element.mouseDown") ||
170
+ line.includes("Element.mouseUp")
171
+ ) {
172
+ continue;
173
+ }
174
+
175
+ // Once we hit userland code, include everything from there
176
+ if (!line.includes("sdk.js") || foundUserland) {
177
+ foundUserland = true;
178
+ filteredLines.push(line);
179
+ }
180
+ }
181
+
182
+ this.stack = filteredLines.join("\n");
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Element class representing a located or to-be-located element
189
+ */
190
+ class Element {
191
+ constructor(description, sdk, system, commands) {
192
+ this.description = description;
193
+ this.sdk = sdk;
194
+ this.system = system;
195
+ this.commands = commands;
196
+ this.coordinates = null;
197
+ /* The above code is a JavaScript comment block that sets the `_found` property of an object to
198
+ `false`. The code snippet does not contain any executable code, it is just a comment. */
199
+ this._found = false;
200
+ this._response = null;
201
+ this._screenshot = null;
202
+ this._threshold = null; // Store the threshold used for this find
203
+ }
204
+
205
+ /**
206
+ * Check if element was found
207
+ * @returns {boolean} True if element coordinates were located
208
+ */
209
+ found() {
210
+ return this._found;
211
+ }
212
+
213
+ /**
214
+ * Find the element on screen
215
+ * @param {string} [newDescription] - Optional new description to search for
216
+ * @param {number} [cacheThreshold] - Cache threshold for this specific find (overrides global setting)
217
+ * @returns {Promise<Element>} This element instance
218
+ */
219
+ async find(newDescription, cacheThreshold) {
220
+ const description = newDescription || this.description;
221
+ if (newDescription) {
222
+ this.description = newDescription;
223
+ }
224
+
225
+ const startTime = Date.now();
226
+
227
+ const debugMode =
228
+ process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
229
+
230
+ // Log finding action
231
+ const { events } = require("./agent/events.js");
232
+ const findingMessage = formatter.formatElementFinding(description);
233
+ this.sdk.emitter.emit(events.log.log, findingMessage);
234
+
235
+ try {
236
+ const screenshot = await this.system.captureScreenBase64();
237
+ // Only store screenshot in DEBUG mode to prevent memory leaks
238
+ if (debugMode) {
239
+ this._screenshot = screenshot;
240
+ }
241
+
242
+ // Use per-command threshold if provided, otherwise fall back to global threshold
243
+ const threshold =
244
+ cacheThreshold ?? this.sdk.cacheThresholds?.find ?? 0.05;
245
+
246
+ // Store the threshold for debugging
247
+ this._threshold = threshold;
248
+
249
+ // Debug log threshold
250
+ if (debugMode) {
251
+ const { events } = require("./agent/events.js");
252
+ this.sdk.emitter.emit(
253
+ events.log.debug,
254
+ `🔍 find() threshold: ${threshold} (cache ${threshold < 0 ? "DISABLED" : "ENABLED"})`,
255
+ );
256
+ }
257
+
258
+ const response = await this.sdk.apiClient.req("find", {
259
+ element: description,
260
+ image: screenshot,
261
+ threshold: threshold,
262
+ os: this.sdk.os,
263
+ resolution: this.sdk.resolution,
264
+ });
265
+
266
+ const duration = Date.now() - startTime;
267
+
268
+ console.log("AI Response Text:", response?.response.content[0]?.text);
269
+
270
+ if (response && response.coordinates) {
271
+ // Store response but clear large base64 data to prevent memory leaks
272
+ this._response = this._sanitizeResponse(response);
273
+ this.coordinates = response.coordinates;
274
+ this._found = true;
275
+
276
+ // Log debug information when element is found
277
+ this._logFoundDebug(response, duration);
278
+ } else {
279
+ this._response = this._sanitizeResponse(response);
280
+ this._found = false;
281
+ }
282
+ } catch (error) {
283
+ this._response = error.response
284
+ ? this._sanitizeResponse(error.response)
285
+ : null;
286
+ this._found = false;
287
+ }
288
+
289
+ return this;
290
+ }
291
+
292
+ /**
293
+ * Sanitize response by removing large base64 data to prevent memory leaks
294
+ * @private
295
+ * @param {Object} response - API response
296
+ * @returns {Object} Sanitized response
297
+ */
298
+ _sanitizeResponse(response) {
299
+ if (!response) return null;
300
+
301
+ // Only keep base64 data in DEBUG mode
302
+ const debugMode =
303
+ process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
304
+ if (debugMode) {
305
+ return response;
306
+ }
307
+
308
+ // Create shallow copy and remove large base64 fields
309
+ const sanitized = { ...response };
310
+ delete sanitized.croppedImage;
311
+ delete sanitized.screenshot;
312
+
313
+ return sanitized;
314
+ }
315
+
316
+ /**
317
+ * Log debug information when element is successfully found
318
+ * @private
319
+ */
320
+ async _logFoundDebug(response, duration) {
321
+ const debugInfo = {
322
+ description: this.description,
323
+ coordinates: this.coordinates,
324
+ duration: `${duration}ms`,
325
+ cacheHit:
326
+ response.cacheHit || response.cache_hit || response.cached || false,
327
+ cacheStrategy: response.cacheStrategy || null,
328
+ similarity: response.similarity ?? null,
329
+ confidence: response.confidence ?? null,
330
+ };
331
+
332
+ // Emit element found as log:log event
333
+ const { events } = require("./agent/events.js");
334
+ const formattedMessage = formatter.formatElementFound(this.description, {
335
+ x: this.coordinates.x,
336
+ y: this.coordinates.y,
337
+ duration: debugInfo.duration,
338
+ cacheHit: debugInfo.cacheHit,
339
+ });
340
+ this.sdk.emitter.emit(events.log.log, formattedMessage);
341
+
342
+ // Log cache information in debug mode
343
+ const debugMode =
344
+ process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
345
+ if (debugMode) {
346
+ const { events } = require("./agent/events.js");
347
+ this.sdk.emitter.emit(events.log.debug, "Element Found:");
348
+ this.sdk.emitter.emit(
349
+ events.log.debug,
350
+ ` Description: ${debugInfo.description}`,
351
+ );
352
+ this.sdk.emitter.emit(
353
+ events.log.debug,
354
+ ` Coordinates: (${this.coordinates.x}, ${this.coordinates.y})`,
355
+ );
356
+ this.sdk.emitter.emit(
357
+ events.log.debug,
358
+ ` Duration: ${debugInfo.duration}`,
359
+ );
360
+ this.sdk.emitter.emit(
361
+ events.log.debug,
362
+ ` Cache Hit: ${debugInfo.cacheHit ? "✅ YES" : "❌ NO"}`,
363
+ );
364
+ if (debugInfo.cacheHit) {
365
+ this.sdk.emitter.emit(
366
+ events.log.debug,
367
+ ` Cache Strategy: ${debugInfo.cacheStrategy || "unknown"}`,
368
+ );
369
+ this.sdk.emitter.emit(
370
+ events.log.debug,
371
+ ` Similarity: ${debugInfo.similarity !== null ? (debugInfo.similarity * 100).toFixed(2) + "%" : "N/A"}`,
372
+ );
373
+ if (response.cacheCreatedAt) {
374
+ const cacheAge = Math.round(
375
+ (Date.now() - new Date(response.cacheCreatedAt).getTime()) / 1000,
376
+ );
377
+ this.sdk.emitter.emit(
378
+ events.log.debug,
379
+ ` Cache Age: ${cacheAge}s (created: ${new Date(response.cacheCreatedAt).toISOString()})`,
380
+ );
381
+ }
382
+ if (response.cachedImageUrl) {
383
+ this.sdk.emitter.emit(
384
+ events.log.debug,
385
+ ` Cached Image URL: ${response.cachedImageUrl}`,
386
+ );
387
+ }
388
+ if (response.cacheDiffPercent !== undefined) {
389
+ this.sdk.emitter.emit(
390
+ events.log.debug,
391
+ ` Pixel Diff: ${(response.cacheDiffPercent * 100).toFixed(2)}%`,
392
+ );
393
+ }
394
+ }
395
+ if (debugInfo.confidence !== null) {
396
+ this.sdk.emitter.emit(
397
+ events.log.debug,
398
+ ` Confidence: ${(debugInfo.confidence * 100).toFixed(2)}%`,
399
+ );
400
+ }
401
+
402
+ // Log available response fields for debugging
403
+ this.sdk.emitter.emit(
404
+ events.log.debug,
405
+ ` Has croppedImage: ${!!response.croppedImage}`,
406
+ );
407
+ this.sdk.emitter.emit(
408
+ events.log.debug,
409
+ ` Has screenshot: ${!!response.screenshot}`,
410
+ );
411
+ this.sdk.emitter.emit(
412
+ events.log.debug,
413
+ ` Has cachedImageUrl: ${!!response.cachedImageUrl}`,
414
+ );
415
+ this.sdk.emitter.emit(
416
+ events.log.debug,
417
+ ` Has pixelDiffImage: ${!!response.pixelDiffImage}`,
418
+ );
419
+ }
420
+
421
+ // Save cropped image with red circle if available
422
+ let croppedImagePath = null;
423
+ if (response.croppedImage) {
424
+ try {
425
+ const tempDir = path.join(os.tmpdir(), "testdriver-debug");
426
+ if (!fs.existsSync(tempDir)) {
427
+ fs.mkdirSync(tempDir, { recursive: true });
428
+ }
429
+
430
+ const filename = `element-found-${Date.now()}.png`;
431
+ croppedImagePath = path.join(tempDir, filename);
432
+
433
+ // Remove data:image/png;base64, prefix if present
434
+ const base64Data = response.croppedImage.replace(
435
+ /^data:image\/\w+;base64,/,
436
+ "",
437
+ );
438
+ const buffer = Buffer.from(base64Data, "base64");
439
+
440
+ fs.writeFileSync(croppedImagePath, buffer);
441
+
442
+ if (debugMode) {
443
+ const { events } = require("./agent/events.js");
444
+ this.sdk.emitter.emit(
445
+ events.log.debug,
446
+ ` Debug Image: ${croppedImagePath}`,
447
+ );
448
+ }
449
+ } catch (err) {
450
+ const { events } = require("./agent/events.js");
451
+ const errorMsg = formatter.formatError(
452
+ "Failed to save debug image",
453
+ err,
454
+ );
455
+ this.sdk.emitter.emit(events.log.log, errorMsg);
456
+ }
457
+ }
458
+
459
+ // Save cached screenshot if available and this was a cache hit
460
+ let cachedScreenshotPath = null;
461
+ if (debugInfo.cacheHit && response.screenshot) {
462
+ try {
463
+ const tempDir = path.join(os.tmpdir(), "testdriver-debug");
464
+ if (!fs.existsSync(tempDir)) {
465
+ fs.mkdirSync(tempDir, { recursive: true });
466
+ }
467
+
468
+ const filename = `cached-screenshot-${Date.now()}.png`;
469
+ cachedScreenshotPath = path.join(tempDir, filename);
470
+
471
+ // Remove data:image/png;base64, prefix if present
472
+ const base64Data = response.screenshot.replace(
473
+ /^data:image\/\w+;base64,/,
474
+ "",
475
+ );
476
+ const buffer = Buffer.from(base64Data, "base64");
477
+
478
+ fs.writeFileSync(cachedScreenshotPath, buffer);
479
+
480
+ if (debugMode) {
481
+ const { events } = require("./agent/events.js");
482
+ this.sdk.emitter.emit(
483
+ events.log.debug,
484
+ ` Cached Screenshot: ${cachedScreenshotPath}`,
485
+ );
486
+ }
487
+ } catch (err) {
488
+ const { events } = require("./agent/events.js");
489
+ const errorMsg = formatter.formatError(
490
+ "Failed to save cached screenshot",
491
+ err,
492
+ );
493
+ this.sdk.emitter.emit(events.log.log, errorMsg);
494
+ }
495
+ }
496
+
497
+ // Save pixel diff image if available and this was a cache hit
498
+ let pixelDiffPath = null;
499
+ if (debugInfo.cacheHit && response.pixelDiffImage) {
500
+ try {
501
+ const tempDir = path.join(os.tmpdir(), "testdriver-debug");
502
+ if (!fs.existsSync(tempDir)) {
503
+ fs.mkdirSync(tempDir, { recursive: true });
504
+ }
505
+
506
+ const filename = `pixel-diff-${Date.now()}.png`;
507
+ pixelDiffPath = path.join(tempDir, filename);
508
+
509
+ // Remove data:image/png;base64, prefix if present
510
+ const base64Data = response.pixelDiffImage.replace(
511
+ /^data:image\/\w+;base64,/,
512
+ "",
513
+ );
514
+ const buffer = Buffer.from(base64Data, "base64");
515
+
516
+ fs.writeFileSync(pixelDiffPath, buffer);
517
+
518
+ if (debugMode) {
519
+ const { events } = require("./agent/events.js");
520
+ this.sdk.emitter.emit(
521
+ events.log.debug,
522
+ ` Pixel Diff Image: ${pixelDiffPath}`,
523
+ );
524
+ }
525
+ } catch (err) {
526
+ const { events } = require("./agent/events.js");
527
+ const errorMsg = formatter.formatError(
528
+ "Failed to save pixel diff image",
529
+ err,
530
+ );
531
+ this.sdk.emitter.emit(events.log.log, errorMsg);
532
+ }
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Click on the element
538
+ * @param {ClickAction} [action='click'] - Type of click action
539
+ * @returns {Promise<void>}
540
+ */
541
+ async click(action = "click") {
542
+ if (!this._found || !this.coordinates) {
543
+ throw new ElementNotFoundError(
544
+ `Element "${this.description}" not found.`,
545
+ {
546
+ description: this.description,
547
+ screenshot: this._screenshot,
548
+ aiResponse: this._response,
549
+ threshold: this._threshold,
550
+ cachedImageUrl: this._response?.cachedImageUrl,
551
+ pixelDiffImage: this._response?.pixelDiffImage,
552
+ },
553
+ );
554
+ }
555
+
556
+ // Log the action
557
+ const { events } = require("./agent/events.js");
558
+ const actionName = action === "click" ? "click" : action.replace("-", " ");
559
+ const formattedMessage = formatter.formatAction(
560
+ actionName,
561
+ this.description,
562
+ );
563
+ this.sdk.emitter.emit(events.log.log, formattedMessage);
564
+
565
+ if (action === "hover") {
566
+ await this.commands.hover(this.coordinates.x, this.coordinates.y);
567
+ } else {
568
+ await this.commands.click(this.coordinates.x, this.coordinates.y, action);
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Hover over the element
574
+ * @returns {Promise<void>}
575
+ */
576
+ async hover() {
577
+ if (!this._found || !this.coordinates) {
578
+ throw new ElementNotFoundError(
579
+ `Element "${this.description}" not found.`,
580
+ {
581
+ description: this.description,
582
+ screenshot: this._screenshot,
583
+ aiResponse: this._response,
584
+ threshold: this._threshold,
585
+ cachedImageUrl: this._response?.cachedImageUrl,
586
+ pixelDiffImage: this._response?.pixelDiffImage,
587
+ },
588
+ );
589
+ }
590
+
591
+ // Log the hover action
592
+ const { events } = require("./agent/events.js");
593
+ const formattedMessage = formatter.formatAction("hover", this.description);
594
+ this.sdk.emitter.emit(events.log.log, formattedMessage);
595
+
596
+ await this.commands.hover(this.coordinates.x, this.coordinates.y);
597
+ }
598
+
599
+ /**
600
+ * Double-click on the element
601
+ * @returns {Promise<void>}
602
+ */
603
+ async doubleClick() {
604
+ return this.click("double-click");
605
+ }
606
+
607
+ /**
608
+ * Right-click on the element
609
+ * @returns {Promise<void>}
610
+ */
611
+ async rightClick() {
612
+ return this.click("right-click");
613
+ }
614
+
615
+ /**
616
+ * Press mouse button down on this element
617
+ * @returns {Promise<void>}
618
+ */
619
+ async mouseDown() {
620
+ return this.click("mouseDown");
621
+ }
622
+
623
+ /**
624
+ * Release mouse button on this element
625
+ * @returns {Promise<void>}
626
+ */
627
+ async mouseUp() {
628
+ return this.click("mouseUp");
629
+ }
630
+
631
+ /**
632
+ * Get the coordinates of the element
633
+ * @returns {{x: number, y: number, centerX: number, centerY: number}|null}
634
+ */
635
+ getCoordinates() {
636
+ return this.coordinates;
637
+ }
638
+
639
+ /**
640
+ * Get the x coordinate (top-left)
641
+ * @returns {number|null}
642
+ */
643
+ get x() {
644
+ return this.coordinates?.x ?? null;
645
+ }
646
+
647
+ /**
648
+ * Get the y coordinate (top-left)
649
+ * @returns {number|null}
650
+ */
651
+ get y() {
652
+ return this.coordinates?.y ?? null;
653
+ }
654
+
655
+ /**
656
+ * Get the center x coordinate
657
+ * @returns {number|null}
658
+ */
659
+ get centerX() {
660
+ return this.coordinates?.centerX ?? null;
661
+ }
662
+
663
+ /**
664
+ * Get the center y coordinate
665
+ * @returns {number|null}
666
+ */
667
+ get centerY() {
668
+ return this.coordinates?.centerY ?? null;
669
+ }
670
+
671
+ /**
672
+ * Get the full API response data
673
+ * @returns {Object|null}
674
+ */
675
+ getResponse() {
676
+ return this._response;
677
+ }
678
+
679
+ /**
680
+ * Get element screenshot if available
681
+ * @returns {string|null} Base64 encoded screenshot
682
+ */
683
+ get screenshot() {
684
+ return this._response?.screenshot ?? null;
685
+ }
686
+
687
+ /**
688
+ * Get element confidence score if available
689
+ * @returns {number|null}
690
+ */
691
+ get confidence() {
692
+ return this._response?.confidence ?? null;
693
+ }
694
+
695
+ /**
696
+ * Get element width if available
697
+ * @returns {number|null}
698
+ */
699
+ get width() {
700
+ return this._response?.width ?? null;
701
+ }
702
+
703
+ /**
704
+ * Get element height if available
705
+ * @returns {number|null}
706
+ */
707
+ get height() {
708
+ return this._response?.height ?? null;
709
+ }
710
+
711
+ /**
712
+ * Get element bounding box if available
713
+ * @returns {Object|null}
714
+ */
715
+ get boundingBox() {
716
+ return this._response?.boundingBox ?? null;
717
+ }
718
+
719
+ /**
720
+ * Get element text content if available
721
+ * @returns {string|null}
722
+ */
723
+ get text() {
724
+ return this._response?.text ?? null;
725
+ }
726
+
727
+ /**
728
+ * Get element label if available
729
+ * @returns {string|null}
730
+ */
731
+ get label() {
732
+ return this._response?.label ?? null;
733
+ }
734
+
735
+ /**
736
+ * Save the debug screenshot to a file for manual inspection
737
+ * @param {string} [filepath] - Path to save the screenshot (defaults to ./debug-screenshot-{timestamp}.png)
738
+ * @returns {Promise<string>} Path to the saved screenshot
739
+ */
740
+ async saveDebugScreenshot(filepath) {
741
+ if (!this._screenshot) {
742
+ throw new Error("No screenshot available.");
743
+ }
744
+
745
+ const fs = require("fs").promises;
746
+ const path = require("path");
747
+
748
+ const defaultPath = `./debug-screenshot-${Date.now()}.png`;
749
+ const savePath = filepath || defaultPath;
750
+
751
+ // Remove data:image/png;base64, prefix if present
752
+ const base64Data = this._screenshot.replace(/^data:image\/\w+;base64,/, "");
753
+ const buffer = Buffer.from(base64Data, "base64");
754
+
755
+ await fs.writeFile(savePath, buffer);
756
+ return path.resolve(savePath);
757
+ }
758
+
759
+ /**
760
+ * Get debug information about the last find operation
761
+ * @returns {Object} Debug information including AI response and screenshot metadata
762
+ */
763
+ getDebugInfo() {
764
+ return {
765
+ description: this.description,
766
+ found: this._found,
767
+ coordinates: this.coordinates,
768
+ aiResponse: this._response,
769
+ hasScreenshot: !!this._screenshot,
770
+ screenshotSize: this._screenshot ? this._screenshot.length : 0,
771
+ };
772
+ }
773
+
774
+ /**
775
+ * Clean up element resources to prevent memory leaks
776
+ * Call this when you're done with the element
777
+ */
778
+ destroy() {
779
+ this._screenshot = null;
780
+ this._response = null;
781
+ this.coordinates = null;
782
+ this.sdk = null;
783
+ this.system = null;
784
+ this.commands = null;
785
+ }
786
+ }
787
+
788
+ /**
789
+ * TestDriver SDK
790
+ *
791
+ * This SDK provides programmatic access to TestDriver's AI-powered testing capabilities.
792
+ *
793
+ * @example
794
+ * const TestDriver = require('testdriverai');
795
+ *
796
+ * const client = new TestDriver(process.env.TD_API_KEY);
797
+ * await client.connect();
798
+ *
799
+ * // New API
800
+ * const element = await client.find('Submit button');
801
+ * await element.click();
802
+ *
803
+ * // Legacy API (deprecated)
804
+ * await client.hoverText('Submit');
805
+ * await client.click();
806
+ */
807
+
808
+ /**
809
+ * @typedef {'click' | 'right-click' | 'double-click' | 'hover' | 'mouseDown' | 'mouseUp'} ClickAction
810
+ * @typedef {'up' | 'down' | 'left' | 'right'} ScrollDirection
811
+ * @typedef {'keyboard' | 'mouse'} ScrollMethod
812
+ * @typedef {'ai' | 'turbo'} TextMatchMethod
813
+ * @typedef {'js' | 'pwsh'} ExecLanguage
814
+ * @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
815
+ */
816
+
817
+ const TestDriverAgent = require("./agent/index.js");
818
+ const { events } = require("./agent/events.js");
819
+ const { createMarkdownLogger } = require("./interfaces/logger.js");
820
+
821
+ class TestDriverSDK {
822
+ constructor(apiKey, options = {}) {
823
+ // Set up environment with API key
824
+ const environment = {
825
+ TD_API_KEY: apiKey,
826
+ TD_API_ROOT: options.apiRoot || "https://testdriver-api.onrender.com",
827
+ TD_RESOLUTION: options.resolution || "1366x768",
828
+ TD_ANALYTICS: options.analytics !== false,
829
+ ...options.environment,
830
+ };
831
+
832
+ // Create the underlying agent with minimal CLI args
833
+ this.agent = new TestDriverAgent(environment, {
834
+ command: "sdk",
835
+ args: [],
836
+ options: {
837
+ os: options.os || "linux",
838
+ },
839
+ });
840
+
841
+ // Store options for later use
842
+ this.options = options;
843
+
844
+ // Store os and resolution for API requests
845
+ this.os = options.os || "linux";
846
+ this.resolution = options.resolution || "1366x768";
847
+
848
+ // Store newSandbox preference from options
849
+ this.newSandbox =
850
+ options.newSandbox !== undefined ? options.newSandbox : false;
851
+
852
+ // Store headless preference from options
853
+ this.headless = options.headless !== undefined ? options.headless : false;
854
+
855
+ // Store IP address if provided for direct connection
856
+ this.ip = options.ip || null;
857
+
858
+ // Store sandbox configuration options
859
+ this.sandboxAmi = options.sandboxAmi || null;
860
+ this.sandboxOs = options.sandboxOs || null;
861
+ this.sandboxInstance = options.sandboxInstance || null;
862
+
863
+ // Cache threshold configuration
864
+ // threshold = pixel difference allowed (0.05 = 5% difference, 95% similarity)
865
+ // cache: false option disables cache completely by setting threshold to -1
866
+ // Also support TD_NO_CACHE environment variable
867
+ const useCache =
868
+ options.cache !== false && process.env.TD_NO_CACHE !== "true";
869
+
870
+ // Note: Cannot emit events here as emitter is not yet available
871
+ // Logging will be done after connection
872
+
873
+ if (!useCache) {
874
+ // If cache is disabled, use -1 to bypass cache entirely
875
+ this.cacheThresholds = {
876
+ find: -1,
877
+ findAll: -1,
878
+ };
879
+ } else {
880
+ // Use configured thresholds or defaults
881
+ this.cacheThresholds = {
882
+ find: options.cacheThreshold?.find ?? 0.05,
883
+ findAll: options.cacheThreshold?.findAll ?? 0.05,
884
+ };
885
+ }
886
+
887
+ // Redraw threshold configuration
888
+ // threshold = percentage of pixels that must change to consider screen redrawn (0.1 = 0.1%)
889
+ this.redrawThreshold = options.redrawThreshold ?? 0.1;
890
+
891
+ // Track connection state
892
+ this.connected = false;
893
+ this.authenticated = false;
894
+
895
+ // Expose commonly used agent properties
896
+ this.emitter = this.agent.emitter;
897
+ this.config = this.agent.config;
898
+ this.session = this.agent.session;
899
+ this.apiClient = this.agent.sdk;
900
+ this.analytics = this.agent.analytics;
901
+ this.sandbox = this.agent.sandbox;
902
+ this.system = this.agent.system;
903
+ this.instance = null;
904
+
905
+ // Commands will be set up dynamically after connection
906
+ this.commands = null;
907
+
908
+ // Set up logging if enabled (after emitter is exposed)
909
+ this.loggingEnabled = options.logging !== false;
910
+
911
+ // Set up event listeners once (they live for the lifetime of the SDK instance)
912
+ this._setupLogging();
913
+ }
914
+
915
+ /**
916
+ * Authenticate with TestDriver API
917
+ * @returns {Promise<string>} Authentication token
918
+ */
919
+ async auth() {
920
+ if (this.authenticated) {
921
+ return;
922
+ }
923
+
924
+ const token = await this.apiClient.auth();
925
+ this.authenticated = true;
926
+ return token;
927
+ }
928
+
929
+ /**
930
+ * Connect to a sandbox environment
931
+ * @param {Object} options - Connection options
932
+ * @param {string} options.sandboxId - Existing sandbox ID to reconnect to
933
+ * @param {boolean} options.newSandbox - Force creation of a new sandbox
934
+ * @param {string} options.ip - Direct IP address to connect to
935
+ * @param {string} options.sandboxAmi - AMI to use for the sandbox
936
+ * @param {string} options.sandboxInstance - Instance type for the sandbox
937
+ * @param {string} options.os - Operating system for the sandbox (windows or linux)
938
+ * @param {boolean} options.reuseConnection - Reuse recent connection if available (default: true)
939
+ * @returns {Promise<Object>} Sandbox instance details
940
+ */
941
+ async connect(connectOptions = {}) {
942
+ if (this.connected) {
943
+ throw new Error(
944
+ "Already connected. Create a new TestDriver instance to connect again.",
945
+ );
946
+ }
947
+
948
+ // Authenticate first if not already authenticated
949
+ if (!this.authenticated) {
950
+ await this.auth();
951
+ }
952
+
953
+ // Initialize debugger server before connecting to sandbox
954
+ // This ensures the debuggerUrl is available for renderSandbox
955
+ await this._initializeDebugger();
956
+
957
+ // Map SDK connect options to agent buildEnv options
958
+ // Use connectOptions.newSandbox if provided, otherwise fall back to this.newSandbox
959
+ // Use connectOptions.headless if provided, otherwise fall back to this.headless
960
+ const buildEnvOptions = {
961
+ headless:
962
+ connectOptions.headless !== undefined
963
+ ? connectOptions.headless
964
+ : this.headless,
965
+ new:
966
+ connectOptions.newSandbox !== undefined
967
+ ? connectOptions.newSandbox
968
+ : this.newSandbox,
969
+ };
970
+
971
+ // Set agent properties for buildEnv to use
972
+ if (connectOptions.sandboxId) {
973
+ this.agent.sandboxId = connectOptions.sandboxId;
974
+ }
975
+ // Use IP from connectOptions if provided, otherwise fall back to constructor IP
976
+ if (connectOptions.ip !== undefined) {
977
+ this.agent.ip = connectOptions.ip;
978
+ } else if (this.ip) {
979
+ this.agent.ip = this.ip;
980
+ }
981
+ // Use sandboxAmi from connectOptions if provided, otherwise fall back to constructor value
982
+ if (connectOptions.sandboxAmi !== undefined) {
983
+ this.agent.sandboxAmi = connectOptions.sandboxAmi;
984
+ } else if (this.sandboxAmi) {
985
+ this.agent.sandboxAmi = this.sandboxAmi;
986
+ }
987
+ // Use sandboxInstance from connectOptions if provided, otherwise fall back to constructor value
988
+ if (connectOptions.sandboxInstance !== undefined) {
989
+ this.agent.sandboxInstance = connectOptions.sandboxInstance;
990
+ } else if (this.sandboxInstance) {
991
+ this.agent.sandboxInstance = this.sandboxInstance;
992
+ }
993
+ // Use os from connectOptions if provided, otherwise fall back to constructor value
994
+ if (connectOptions.os !== undefined) {
995
+ this.agent.sandboxOs = connectOptions.os;
996
+ } else if (this.sandboxOs) {
997
+ this.agent.sandboxOs = this.sandboxOs;
998
+ }
999
+
1000
+ // Set redrawThreshold on agent's cliArgs.options
1001
+ this.agent.cliArgs.options.redrawThreshold = this.redrawThreshold;
1002
+
1003
+ // Use the agent's buildEnv method which handles all the connection logic
1004
+ await this.agent.buildEnv(buildEnvOptions);
1005
+
1006
+ // Get the instance from the agent
1007
+ this.instance = this.agent.instance;
1008
+
1009
+ // Expose the agent's commands, parser, and commander
1010
+ this.commands = this.agent.commands;
1011
+
1012
+ // Dynamically create command methods based on available commands
1013
+ this._setupCommandMethods();
1014
+
1015
+ this.connected = true;
1016
+ this.analytics.track("sdk.connect", {
1017
+ sandboxId: this.instance?.instanceId,
1018
+ });
1019
+
1020
+ return this.instance;
1021
+ }
1022
+
1023
+ /**
1024
+ * Disconnect from the sandbox
1025
+ * Note: After disconnecting, you cannot reconnect with the same SDK instance.
1026
+ * Create a new TestDriver instance if you need to connect again.
1027
+ * @returns {Promise<void>}
1028
+ */
1029
+ async disconnect() {
1030
+ if (this.connected && this.instance) {
1031
+ // Track disconnect event
1032
+ this.analytics.track("sdk.disconnect");
1033
+
1034
+ this.connected = false;
1035
+ this.instance = null;
1036
+ }
1037
+ }
1038
+
1039
+ /**
1040
+ * Get the current session ID
1041
+ * Used for tracking and associating dashcam recordings with test results
1042
+ * @returns {string|null} The session ID or null if not connected
1043
+ */
1044
+ getSessionId() {
1045
+ return this.session?.get() || null;
1046
+ }
1047
+
1048
+ // ====================================
1049
+ // Element Finding API
1050
+ // ====================================
1051
+
1052
+ /**
1053
+ * Find an element by description
1054
+ * Automatically locates the element and returns it
1055
+ *
1056
+ * @param {string} description - Description of the element to find
1057
+ * @param {number} [cacheThreshold] - Cache threshold for this specific find (overrides global setting)
1058
+ * @returns {Promise<Element>} Element instance that has been located
1059
+ *
1060
+ * @example
1061
+ * // Find and click immediately
1062
+ * const element = await client.find('the sign in button');
1063
+ * await element.click();
1064
+ *
1065
+ * @example
1066
+ * // Find with custom cache threshold
1067
+ * const element = await client.find('login button', 0.01);
1068
+ *
1069
+ * @example
1070
+ * // Poll until element is found
1071
+ * let element;
1072
+ * while (!element?.found()) {
1073
+ * element = await client.find('login button');
1074
+ * if (!element.found()) {
1075
+ * await new Promise(resolve => setTimeout(resolve, 1000));
1076
+ * }
1077
+ * }
1078
+ * await element.click();
1079
+ */
1080
+ async find(description, cacheThreshold) {
1081
+ this._ensureConnected();
1082
+ const element = new Element(description, this, this.system, this.commands);
1083
+ return await element.find(null, cacheThreshold);
1084
+ }
1085
+
1086
+ /**
1087
+ * Find all elements matching a description
1088
+ * Automatically locates all matching elements and returns them as an array
1089
+ *
1090
+ * @param {string} description - Description of the elements to find
1091
+ * @param {number} [cacheThreshold] - Cache threshold for this specific findAll (overrides global setting)
1092
+ * @returns {Promise<Element[]>} Array of Element instances that have been located
1093
+ *
1094
+ * @example
1095
+ * // Find all buttons and click the first one
1096
+ * const buttons = await client.findAll('button');
1097
+ * if (buttons.length > 0) {
1098
+ * await buttons[0].click();
1099
+ * }
1100
+ *
1101
+ * @example
1102
+ * // Find all list items with custom cache threshold
1103
+ * const items = await client.findAll('list item', 0.01);
1104
+ * for (const item of items) {
1105
+ * console.log(`Found item at (${item.x}, ${item.y})`);
1106
+ * }
1107
+ */
1108
+ async findAll(description, cacheThreshold) {
1109
+ this._ensureConnected();
1110
+
1111
+ const startTime = Date.now();
1112
+
1113
+ // Log finding all action
1114
+ const { events } = require("./agent/events.js");
1115
+ const findingMessage = formatter.formatElementsFinding(description);
1116
+ this.emitter.emit(events.log.log, findingMessage);
1117
+
1118
+ try {
1119
+ const screenshot = await this.system.captureScreenBase64();
1120
+
1121
+ // Use per-command threshold if provided, otherwise fall back to global threshold
1122
+ const threshold = cacheThreshold ?? this.cacheThresholds?.findAll ?? 0.05;
1123
+
1124
+ const response = await this.apiClient.req(
1125
+ "/api/v7.0.0/testdriver-agent/testdriver-find-all",
1126
+ {
1127
+ element: description,
1128
+ image: screenshot,
1129
+ threshold: threshold,
1130
+ os: this.os,
1131
+ resolution: this.resolution,
1132
+ },
1133
+ );
1134
+
1135
+ const duration = Date.now() - startTime;
1136
+
1137
+ if (response && response.elements && response.elements.length > 0) {
1138
+ // Log found elements
1139
+ const foundMessage = formatter.formatElementsFound(
1140
+ description,
1141
+ response.elements.length,
1142
+ {
1143
+ duration: `${duration}ms`,
1144
+ cacheHit: response.cached || false,
1145
+ },
1146
+ );
1147
+ this.emitter.emit(events.log.log, foundMessage);
1148
+
1149
+ // Create Element instances for each found element
1150
+ const elements = response.elements.map((elementData) => {
1151
+ const element = new Element(
1152
+ description,
1153
+ this,
1154
+ this.system,
1155
+ this.commands,
1156
+ );
1157
+
1158
+ // Set element as found with its coordinates
1159
+ element.coordinates = elementData.coordinates;
1160
+ element._found = true;
1161
+ element._response = this._sanitizeResponseForElement(
1162
+ response,
1163
+ elementData,
1164
+ );
1165
+
1166
+ // Only store screenshot in DEBUG mode
1167
+ const debugMode =
1168
+ process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
1169
+ if (debugMode) {
1170
+ element._screenshot = screenshot;
1171
+ }
1172
+
1173
+ return element;
1174
+ });
1175
+
1176
+ // Log debug information when elements are found
1177
+ if (process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG) {
1178
+ const { events } = require("./agent/events.js");
1179
+ this.emitter.emit(
1180
+ events.log.debug,
1181
+ `✓ Found ${elements.length} element(s): "${description}"`,
1182
+ );
1183
+ this.emitter.emit(
1184
+ events.log.debug,
1185
+ ` Cache: ${response.cached ? "HIT" : "MISS"}`,
1186
+ );
1187
+ this.emitter.emit(events.log.debug, ` Time: ${duration}ms`);
1188
+ }
1189
+
1190
+ return elements;
1191
+ } else {
1192
+ // No elements found - return empty array
1193
+ return [];
1194
+ }
1195
+ } catch (error) {
1196
+ const { events } = require("./agent/events.js");
1197
+ this.emitter.emit(events.log.log, `Error in findAll: ${error.message}`);
1198
+ return [];
1199
+ }
1200
+ }
1201
+
1202
+ /**
1203
+ * Sanitize response for individual element in findAll results
1204
+ * @private
1205
+ * @param {Object} response - Full API response
1206
+ * @param {Object} elementData - Individual element data
1207
+ * @returns {Object} Sanitized response for this element
1208
+ */
1209
+ _sanitizeResponseForElement(response, elementData) {
1210
+ const debugMode =
1211
+ process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
1212
+
1213
+ // Combine global response data with element-specific data
1214
+ const sanitized = {
1215
+ coordinates: elementData.coordinates,
1216
+ cached: response.cached || false,
1217
+ elementType: response.elementType,
1218
+ extractedText: response.extractedText,
1219
+ confidence: elementData.confidence,
1220
+ similarity: elementData.similarity,
1221
+ boundingBox: elementData.boundingBox,
1222
+ width: elementData.width,
1223
+ height: elementData.height,
1224
+ text: elementData.text,
1225
+ label: elementData.label,
1226
+ };
1227
+
1228
+ // Only keep large data in debug mode
1229
+ if (debugMode) {
1230
+ sanitized.croppedImage = elementData.croppedImage;
1231
+ sanitized.screenshot = response.screenshot;
1232
+ }
1233
+
1234
+ return sanitized;
1235
+ }
1236
+
1237
+ // ====================================
1238
+ // Command Methods Setup
1239
+ // ====================================
1240
+
1241
+ /**
1242
+ * Dynamically set up command methods based on available commands
1243
+ * This creates camelCase methods that wrap the underlying command functions
1244
+ * @private
1245
+ */
1246
+ _setupCommandMethods() {
1247
+ // Mapping from command names to SDK method names with type definitions
1248
+ const commandMapping = {
1249
+ "hover-text": {
1250
+ name: "hoverText",
1251
+ /**
1252
+ * Hover over text on screen
1253
+ * @deprecated Use find() and element.click() instead
1254
+ * @param {string} text - Text to find and hover over
1255
+ * @param {string | null} [description] - Optional description of the element
1256
+ * @param {ClickAction} [action='click'] - Action to perform
1257
+ * @param {TextMatchMethod} [method='turbo'] - Text matching method
1258
+ * @param {number} [timeout=5000] - Timeout in milliseconds
1259
+ * @returns {Promise<{x: number, y: number, centerX: number, centerY: number}>}
1260
+ */
1261
+ doc: "Hover over text on screen (deprecated - use find() instead)",
1262
+ },
1263
+ "hover-image": {
1264
+ name: "hoverImage",
1265
+ /**
1266
+ * Hover over an image on screen
1267
+ * @deprecated Use find() and element.click() instead
1268
+ * @param {string} description - Description of the image to find
1269
+ * @param {ClickAction} [action='click'] - Action to perform
1270
+ * @returns {Promise<{x: number, y: number, centerX: number, centerY: number}>}
1271
+ */
1272
+ doc: "Hover over an image on screen (deprecated - use find() instead)",
1273
+ },
1274
+ "match-image": {
1275
+ name: "matchImage",
1276
+ /**
1277
+ * Match and interact with an image template
1278
+ * @param {string} imagePath - Path to the image template
1279
+ * @param {ClickAction} [action='click'] - Action to perform
1280
+ * @param {boolean} [invert=false] - Invert the match
1281
+ * @returns {Promise<boolean>}
1282
+ */
1283
+ doc: "Match and interact with an image template",
1284
+ },
1285
+ type: {
1286
+ name: "type",
1287
+ /**
1288
+ * Type text
1289
+ * @param {string | number} text - Text to type
1290
+ * @param {number} [delay=250] - Delay between keystrokes in milliseconds
1291
+ * @returns {Promise<void>}
1292
+ */
1293
+ doc: "Type text",
1294
+ },
1295
+ "press-keys": {
1296
+ name: "pressKeys",
1297
+ /**
1298
+ * Press keyboard keys
1299
+ * @param {KeyboardKey[]} keys - Array of keys to press
1300
+ * @returns {Promise<void>}
1301
+ */
1302
+ doc: "Press keyboard keys",
1303
+ },
1304
+ click: {
1305
+ name: "click",
1306
+ /**
1307
+ * Click at coordinates
1308
+ * @param {number} x - X coordinate
1309
+ * @param {number} y - Y coordinate
1310
+ * @param {ClickAction} [action='click'] - Type of click action
1311
+ * @returns {Promise<void>}
1312
+ */
1313
+ doc: "Click at coordinates",
1314
+ },
1315
+ hover: {
1316
+ name: "hover",
1317
+ /**
1318
+ * Hover at coordinates
1319
+ * @param {number} x - X coordinate
1320
+ * @param {number} y - Y coordinate
1321
+ * @returns {Promise<void>}
1322
+ */
1323
+ doc: "Hover at coordinates",
1324
+ },
1325
+ scroll: {
1326
+ name: "scroll",
1327
+ /**
1328
+ * Scroll the page
1329
+ * @param {ScrollDirection} [direction='down'] - Direction to scroll
1330
+ * @param {number} [amount=300] - Amount to scroll in pixels
1331
+ * @returns {Promise<void>}
1332
+ */
1333
+ doc: "Scroll the page",
1334
+ },
1335
+ wait: {
1336
+ name: "wait",
1337
+ /**
1338
+ * Wait for specified time
1339
+ * @deprecated Consider using element polling with find() instead of arbitrary waits
1340
+ * @param {number} [timeout=3000] - Time to wait in milliseconds
1341
+ * @returns {Promise<void>}
1342
+ */
1343
+ doc: "Wait for specified time (deprecated - consider element polling instead)",
1344
+ },
1345
+ "wait-for-text": {
1346
+ name: "waitForText",
1347
+ /**
1348
+ * Wait for text to appear on screen
1349
+ * @deprecated Use find() in a polling loop instead
1350
+ * @param {string} text - Text to wait for
1351
+ * @param {number} [timeout=5000] - Timeout in milliseconds
1352
+ * @param {TextMatchMethod} [method='turbo'] - Text matching method
1353
+ * @param {boolean} [invert=false] - Invert the match (wait for text to disappear)
1354
+ * @returns {Promise<void>}
1355
+ */
1356
+ doc: "Wait for text to appear on screen (deprecated - use find() in a loop instead)",
1357
+ },
1358
+ "wait-for-image": {
1359
+ name: "waitForImage",
1360
+ /**
1361
+ * Wait for image to appear on screen
1362
+ * @deprecated Use find() in a polling loop instead
1363
+ * @param {string} description - Description of the image
1364
+ * @param {number} [timeout=10000] - Timeout in milliseconds
1365
+ * @param {boolean} [invert=false] - Invert the match (wait for image to disappear)
1366
+ * @returns {Promise<void>}
1367
+ */
1368
+ doc: "Wait for image to appear on screen (deprecated - use find() in a loop instead)",
1369
+ },
1370
+ "scroll-until-text": {
1371
+ name: "scrollUntilText",
1372
+ /**
1373
+ * Scroll until text is found
1374
+ * @param {string} text - Text to find
1375
+ * @param {ScrollDirection} [direction='down'] - Scroll direction
1376
+ * @param {number} [maxDistance=10000] - Maximum distance to scroll in pixels
1377
+ * @param {TextMatchMethod} [textMatchMethod='turbo'] - Text matching method
1378
+ * @param {ScrollMethod} [method='keyboard'] - Scroll method
1379
+ * @param {boolean} [invert=false] - Invert the match
1380
+ * @returns {Promise<void>}
1381
+ */
1382
+ doc: "Scroll until text is found",
1383
+ },
1384
+ "scroll-until-image": {
1385
+ name: "scrollUntilImage",
1386
+ /**
1387
+ * Scroll until image is found
1388
+ * @param {string} description - Description of the image (or use path parameter)
1389
+ * @param {ScrollDirection} [direction='down'] - Scroll direction
1390
+ * @param {number} [maxDistance=10000] - Maximum distance to scroll in pixels
1391
+ * @param {ScrollMethod} [method='keyboard'] - Scroll method
1392
+ * @param {string | null} [path=null] - Path to image template
1393
+ * @param {boolean} [invert=false] - Invert the match
1394
+ * @returns {Promise<void>}
1395
+ */
1396
+ doc: "Scroll until image is found",
1397
+ },
1398
+ "focus-application": {
1399
+ name: "focusApplication",
1400
+ /**
1401
+ * Focus an application by name
1402
+ * @param {string} name - Application name
1403
+ * @returns {Promise<string>}
1404
+ */
1405
+ doc: "Focus an application by name",
1406
+ },
1407
+ remember: {
1408
+ name: "remember",
1409
+ /**
1410
+ * Extract and remember information from the screen using AI
1411
+ * @param {string} description - What to remember
1412
+ * @returns {Promise<string>}
1413
+ */
1414
+ doc: "Extract and remember information from the screen",
1415
+ },
1416
+ assert: {
1417
+ name: "assert",
1418
+ /**
1419
+ * Make an AI-powered assertion
1420
+ * @param {string} assertion - Assertion to check
1421
+ * @returns {Promise<boolean>}
1422
+ */
1423
+ doc: "Make an AI-powered assertion",
1424
+ },
1425
+ exec: {
1426
+ name: "exec",
1427
+ /**
1428
+ * Execute code in the sandbox
1429
+ * @param {ExecLanguage} language - Language ('js' or 'pwsh')
1430
+ * @param {string} code - Code to execute
1431
+ * @param {number} timeout - Timeout in milliseconds
1432
+ * @param {boolean} [silent=false] - Suppress output
1433
+ * @returns {Promise<string>}
1434
+ */
1435
+ doc: "Execute code in the sandbox",
1436
+ },
1437
+ };
1438
+
1439
+ // Create SDK methods dynamically from commands
1440
+ Object.keys(this.commands).forEach((commandName) => {
1441
+ const command = this.commands[commandName];
1442
+ const methodInfo = commandMapping[commandName];
1443
+
1444
+ if (!methodInfo) {
1445
+ // Skip commands not in mapping
1446
+ return;
1447
+ }
1448
+
1449
+ const methodName = methodInfo.name;
1450
+
1451
+ // Create the wrapper method with proper stack trace handling
1452
+ this[methodName] = async function (...args) {
1453
+ this._ensureConnected();
1454
+
1455
+ // Capture the call site for better error reporting
1456
+ const callSite = {};
1457
+ Error.captureStackTrace(callSite, this[methodName]);
1458
+
1459
+ try {
1460
+ return await command(...args);
1461
+ } catch (error) {
1462
+ // Ensure we have a proper Error object with a message
1463
+ let properError = error;
1464
+ if (!(error instanceof Error)) {
1465
+ // If it's not an Error object, create one with a proper message
1466
+ const errorMessage =
1467
+ error?.message || error?.reason || JSON.stringify(error);
1468
+ properError = new Error(errorMessage);
1469
+ // Preserve additional properties
1470
+ if (error?.code) properError.code = error.code;
1471
+ if (error?.fullError) properError.fullError = error.fullError;
1472
+ }
1473
+
1474
+ // Replace the stack trace to point to the actual caller instead of SDK internals
1475
+ if (Error.captureStackTrace && callSite.stack) {
1476
+ // Preserve the error message but use the captured call site stack
1477
+ const errorMessage = properError.stack?.split("\n")[0];
1478
+ const callerStack = callSite.stack?.split("\n").slice(1); // Skip "Error" line
1479
+ properError.stack = errorMessage + "\n" + callerStack.join("\n");
1480
+ }
1481
+ throw properError;
1482
+ }
1483
+ }.bind(this);
1484
+
1485
+ // Preserve the original function's name for better debugging
1486
+ Object.defineProperty(this[methodName], "name", {
1487
+ value: methodName,
1488
+ writable: false,
1489
+ });
1490
+ });
1491
+ }
1492
+
1493
+ // ====================================
1494
+ // Helper Methods
1495
+ // ====================================
1496
+
1497
+ /**
1498
+ * Capture a screenshot of the current screen
1499
+ * @param {number} [scale=1] - Scale factor for the screenshot (1 = original size)
1500
+ * @param {boolean} [silent=false] - Whether to suppress logging
1501
+ * @param {boolean} [mouse=false] - Whether to include mouse cursor
1502
+ * @returns {Promise<string>} Base64 encoded PNG screenshot
1503
+ *
1504
+ * @example
1505
+ * // Capture a screenshot
1506
+ * const screenshot = await client.screenshot();
1507
+ * fs.writeFileSync('screenshot.png', Buffer.from(screenshot, 'base64'));
1508
+ *
1509
+ * @example
1510
+ * // Capture with mouse cursor visible
1511
+ * const screenshot = await client.screenshot(1, false, true);
1512
+ */
1513
+ async screenshot(scale = 1, silent = false, mouse = false) {
1514
+ this._ensureConnected();
1515
+ return await this.system.captureScreenBase64(scale, silent, mouse);
1516
+ }
1517
+
1518
+ /**
1519
+ * Ensure the SDK is connected before running commands
1520
+ * @private
1521
+ */
1522
+ _ensureConnected() {
1523
+ if (!this.connected) {
1524
+ throw new Error("SDK is not connected. Call connect() first.");
1525
+ }
1526
+ }
1527
+
1528
+ /**
1529
+ * Get the current sandbox instance details
1530
+ * @returns {Object|null} Sandbox instance
1531
+ */
1532
+ getInstance() {
1533
+ return this.instance;
1534
+ }
1535
+
1536
+ /**
1537
+ * Enable or disable logging output
1538
+ * @param {boolean} enabled - Whether to enable logging
1539
+ */
1540
+ setLogging(enabled) {
1541
+ this.loggingEnabled = enabled;
1542
+ if (enabled && !this._loggingSetup) {
1543
+ this._setupLogging();
1544
+ }
1545
+ }
1546
+
1547
+ /**
1548
+ * Get the event emitter for custom event handling
1549
+ * @returns {EventEmitter2} Event emitter
1550
+ */
1551
+ getEmitter() {
1552
+ return this.emitter;
1553
+ }
1554
+
1555
+ /**
1556
+ * Set test context for enhanced logging (integrates with Vitest)
1557
+ * @param {Object} context - Test context with file, test name, start time
1558
+ * @param {string} [context.file] - Current test file name
1559
+ * @param {string} [context.test] - Current test name
1560
+ * @param {number} [context.startTime] - Test start timestamp
1561
+ */
1562
+ setTestContext(context) {
1563
+ formatter.setTestContext(context);
1564
+ }
1565
+
1566
+ /**
1567
+ * Set up logging for the SDK
1568
+ * @private
1569
+ */
1570
+ _setupLogging() {
1571
+ // Set up markdown logger
1572
+ createMarkdownLogger(this.emitter);
1573
+
1574
+ // Set up basic event logging
1575
+ this.emitter.on("log:**", (message) => {
1576
+ const event = this.emitter.event;
1577
+ if (event === events.log.debug) return;
1578
+ if (this.loggingEnabled && message) {
1579
+ const prefixedMessage = this.testContext
1580
+ ? `[${this.testContext}] ${message}`
1581
+ : message;
1582
+ console.log(prefixedMessage);
1583
+ }
1584
+ });
1585
+
1586
+ this.emitter.on("error:**", (data) => {
1587
+ if (this.loggingEnabled) {
1588
+ const event = this.emitter.event;
1589
+ console.error(event, ":", data);
1590
+ }
1591
+ });
1592
+
1593
+ this.emitter.on("status", (message) => {
1594
+ if (this.loggingEnabled) {
1595
+ console.log(`- ${message}`);
1596
+ }
1597
+ });
1598
+
1599
+ // Handle redraw status for debugging scroll and other async operations
1600
+ this.emitter.on("redraw:status", (status) => {
1601
+ if (this.loggingEnabled) {
1602
+ console.log(
1603
+ `[redraw] screen:${status.redraw.text} network:${status.network.text} timeout:${status.timeout.text}`,
1604
+ );
1605
+ }
1606
+ });
1607
+
1608
+ this.emitter.on("redraw:complete", (info) => {
1609
+ if (this.loggingEnabled) {
1610
+ console.log(
1611
+ `[redraw complete] screen:${info.screenHasRedrawn} network:${info.networkSettled} timeout:${info.isTimeout} elapsed:${info.timeElapsed}ms`,
1612
+ );
1613
+ }
1614
+ });
1615
+
1616
+ // Handle show window events for sandbox visualization
1617
+ this.emitter.on("show-window", async (url) => {
1618
+ if (this.loggingEnabled) {
1619
+ console.log("");
1620
+ console.log("Live test execution:");
1621
+ if (this.config.CI) {
1622
+ // In CI mode, just print the view-only URL
1623
+ const u = new URL(url);
1624
+ const encodedData = u.searchParams.get("data");
1625
+ // Data is base64 encoded, not URL encoded
1626
+ const data = JSON.parse(
1627
+ Buffer.from(encodedData, "base64").toString(),
1628
+ );
1629
+ console.log(`${data.url}&view_only=true`);
1630
+ } else {
1631
+ // In local mode, print the URL and open it in the browser
1632
+ console.log(url);
1633
+ await this._openBrowser(url);
1634
+ }
1635
+ }
1636
+ });
1637
+ }
1638
+
1639
+ /**
1640
+ * Forward log message to sandbox for debugger display
1641
+ * @private
1642
+ * @param {string} message - Log message to forward
1643
+ */
1644
+ _forwardLogToSandbox(message) {
1645
+ try {
1646
+ // Only forward if sandbox is connected
1647
+ if (this.sandbox && this.sandbox.instanceSocketConnected) {
1648
+ // Don't send objects as they cause base64 encoding errors
1649
+ if (typeof message === "object") {
1650
+ return;
1651
+ }
1652
+
1653
+ // Add test context prefix if available
1654
+ const prefixedMessage = this.testContext
1655
+ ? `[${this.testContext}] ${message}`
1656
+ : message;
1657
+
1658
+ this.sandbox.send({
1659
+ type: "output",
1660
+ output: Buffer.from(prefixedMessage).toString("base64"),
1661
+ });
1662
+ }
1663
+ } catch {
1664
+ // Silently fail to avoid breaking the log flow
1665
+ // console.error("Error forwarding log to sandbox:", error);
1666
+ }
1667
+ }
1668
+
1669
+ /**
1670
+ * Open URL in default browser
1671
+ * @private
1672
+ * @param {string} url - URL to open
1673
+ */
1674
+ async _openBrowser(url) {
1675
+ try {
1676
+ // Use dynamic import for the 'open' package (ES module)
1677
+ const { default: open } = await import("open");
1678
+
1679
+ // Open the browser
1680
+ await open(url, {
1681
+ wait: false,
1682
+ });
1683
+ } catch (error) {
1684
+ const { events } = require("./agent/events.js");
1685
+ this.emitter.emit(
1686
+ events.log.log,
1687
+ `Failed to open browser automatically: ${error.message}`,
1688
+ );
1689
+ this.emitter.emit(events.log.log, `Please manually open: ${url}`);
1690
+ }
1691
+ }
1692
+
1693
+ /**
1694
+ * Initialize debugger server
1695
+ * @private
1696
+ */
1697
+ async _initializeDebugger() {
1698
+ // Import createDebuggerProcess at the module level if not already done
1699
+ const { createDebuggerProcess } = require("./agent/lib/debugger.js");
1700
+
1701
+ // Only initialize once
1702
+ if (!this.agent.debuggerUrl) {
1703
+ const debuggerProcess = await createDebuggerProcess(
1704
+ this.config,
1705
+ this.emitter,
1706
+ );
1707
+ this.agent.debuggerUrl = debuggerProcess.url || null;
1708
+ }
1709
+ }
1710
+
1711
+ // ====================================
1712
+ // Test Recording Methods
1713
+ // ====================================
1714
+
1715
+ /**
1716
+ * Create a new test run to track test execution
1717
+ *
1718
+ * @param {Object} options - Test run configuration
1719
+ * @param {string} options.runId - Unique identifier for this test run
1720
+ * @param {string} options.suiteName - Name of the test suite
1721
+ * @param {string} [options.platform] - Platform (windows/mac/linux)
1722
+ * @param {string} [options.sandboxId] - Sandbox ID (auto-detected from session if not provided)
1723
+ * @param {Object} [options.ci] - CI/CD metadata
1724
+ * @param {Object} [options.git] - Git metadata
1725
+ * @param {Object} [options.env] - Environment metadata
1726
+ * @returns {Promise<Object>} Created test run
1727
+ *
1728
+ * @example
1729
+ * const testRun = await client.createTestRun({
1730
+ * runId: 'unique-run-id',
1731
+ * suiteName: 'My Test Suite',
1732
+ * platform: 'windows',
1733
+ * git: {
1734
+ * repo: 'myorg/myrepo',
1735
+ * branch: 'main',
1736
+ * commit: 'abc123'
1737
+ * }
1738
+ * });
1739
+ */
1740
+ async createTestRun(options) {
1741
+ this._ensureConnected();
1742
+
1743
+ const { createSDK } = require("./agent/lib/sdk.js");
1744
+ const sdk = createSDK(
1745
+ this.emitter,
1746
+ this.config,
1747
+ this.agent.sessionInstance,
1748
+ );
1749
+ await sdk.auth();
1750
+
1751
+ const platform = options.platform || this.config.TD_PLATFORM || "windows";
1752
+
1753
+ // Auto-detect sandbox ID from the active sandbox if not provided
1754
+ const sandboxId = options.sandboxId || this.agent?.sandbox?.id || null;
1755
+
1756
+ // Get session ID from the agent's session instance
1757
+ const sessionId = this.agent?.sessionInstance?.get() || null;
1758
+
1759
+ const data = {
1760
+ runId: options.runId,
1761
+ suiteName: options.suiteName,
1762
+ platform,
1763
+ sandboxId: sandboxId,
1764
+ sessionId: sessionId,
1765
+ // CI/CD
1766
+ ciProvider: options.ci?.provider,
1767
+ ciRunId: options.ci?.runId,
1768
+ ciJobId: options.ci?.jobId,
1769
+ ciUrl: options.ci?.url,
1770
+ // Git
1771
+ repo: options.git?.repo,
1772
+ branch: options.git?.branch,
1773
+ commit: options.git?.commit,
1774
+ commitMessage: options.git?.commitMessage,
1775
+ author: options.git?.author,
1776
+ // Environment
1777
+ nodeVersion: options.env?.nodeVersion || process.version,
1778
+ testDriverVersion:
1779
+ options.env?.testDriverVersion || require("./package.json").version,
1780
+ vitestVersion: options.env?.vitestVersion,
1781
+ environment: options.env?.additional,
1782
+ };
1783
+
1784
+ const result = await sdk.req("/api/v1/testdriver/test-run-create", data);
1785
+ return result.data;
1786
+ }
1787
+
1788
+ /**
1789
+ * Complete a test run and update final statistics
1790
+ *
1791
+ * @param {Object} options - Test run completion data
1792
+ * @param {string} options.runId - Test run ID
1793
+ * @param {string} options.status - Final status (passed/failed/cancelled)
1794
+ * @param {number} [options.totalTests] - Total number of tests
1795
+ * @param {number} [options.passedTests] - Number of passed tests
1796
+ * @param {number} [options.failedTests] - Number of failed tests
1797
+ * @param {number} [options.skippedTests] - Number of skipped tests
1798
+ * @returns {Promise<Object>} Updated test run
1799
+ *
1800
+ * @example
1801
+ * await client.completeTestRun({
1802
+ * runId: 'unique-run-id',
1803
+ * status: 'passed',
1804
+ * totalTests: 10,
1805
+ * passedTests: 10,
1806
+ * failedTests: 0
1807
+ * });
1808
+ */
1809
+ async completeTestRun(options) {
1810
+ this._ensureConnected();
1811
+
1812
+ const { createSDK } = require("./agent/lib/sdk.js");
1813
+ const sdk = createSDK(
1814
+ this.emitter,
1815
+ this.config,
1816
+ this.agent.sessionInstance,
1817
+ );
1818
+ await sdk.auth();
1819
+
1820
+ const result = await sdk.req(
1821
+ "/api/v1/testdriver/test-run-complete",
1822
+ options,
1823
+ );
1824
+ return result.data;
1825
+ }
1826
+
1827
+ /**
1828
+ * Record a test case result
1829
+ *
1830
+ * @param {Object} options - Test case data
1831
+ * @param {string} options.runId - Test run ID
1832
+ * @param {string} options.testName - Name of the test
1833
+ * @param {string} options.testFile - Path to test file
1834
+ * @param {string} options.status - Test status (passed/failed/skipped/pending)
1835
+ * @param {string} [options.suiteName] - Test suite/describe block name
1836
+ * @param {number} [options.duration] - Test duration in ms
1837
+ * @param {string} [options.errorMessage] - Error message if failed
1838
+ * @param {string} [options.errorStack] - Error stack trace if failed
1839
+ * @param {string} [options.replayUrl] - Dashcam replay URL
1840
+ * @param {number} [options.replayStartTime] - Start time in replay
1841
+ * @param {number} [options.replayEndTime] - End time in replay
1842
+ * @returns {Promise<Object>} Created/updated test case
1843
+ *
1844
+ * @example
1845
+ * await client.recordTestCase({
1846
+ * runId: 'unique-run-id',
1847
+ * testName: 'should login successfully',
1848
+ * testFile: 'tests/login.test.js',
1849
+ * status: 'passed',
1850
+ * duration: 1500,
1851
+ * replayUrl: 'https://app.dashcam.io/replay/abc123'
1852
+ * });
1853
+ */
1854
+ async recordTestCase(options) {
1855
+ this._ensureConnected();
1856
+
1857
+ const { createSDK } = require("./agent/lib/sdk.js");
1858
+ const sdk = createSDK(
1859
+ this.emitter,
1860
+ this.config,
1861
+ this.agent.sessionInstance,
1862
+ );
1863
+ await sdk.auth();
1864
+
1865
+ const result = await sdk.req(
1866
+ "/api/v1/testdriver/test-case-create",
1867
+ options,
1868
+ );
1869
+ return result.data;
1870
+ }
1871
+
1872
+ // ====================================
1873
+ // AI Methods (Exploratory Loop)
1874
+ // ====================================
1875
+
1876
+ /**
1877
+ * Execute a natural language task using AI
1878
+ * This is the SDK equivalent of the CLI's exploratory loop
1879
+ *
1880
+ * @param {string} task - Natural language description of what to do
1881
+ * @param {Object} options - Execution options
1882
+ * @param {boolean} [options.validateAndLoop=false] - Whether to validate completion and retry if incomplete
1883
+ * @returns {Promise<string|void>} Final AI response if validateAndLoop is true
1884
+ *
1885
+ * @example
1886
+ * // Simple execution
1887
+ * await client.ai('Click the submit button');
1888
+ *
1889
+ * @example
1890
+ * // With validation loop
1891
+ * const result = await client.ai('Fill out the contact form', { validateAndLoop: true });
1892
+ * console.log(result); // AI's final assessment
1893
+ */
1894
+ async ai(task) {
1895
+ this._ensureConnected();
1896
+
1897
+ this.analytics.track("sdk.ai", { task });
1898
+
1899
+ // Use the agent's exploratoryLoop method directly
1900
+ return await this.agent.exploratoryLoop(task, false, true, false);
1901
+ }
1902
+ }
1903
+
1904
+ module.exports = TestDriverSDK;
1905
+ module.exports.Element = Element;
1906
+ module.exports.ElementNotFoundError = ElementNotFoundError;