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.
- package/.github/workflows/acceptance-linux.yml +75 -0
- package/.github/workflows/acceptance-sdk-tests.yml +133 -0
- package/.vscode/settings.json +5 -1
- package/MIGRATION.md +389 -0
- package/PLUGIN_MIGRATION.md +222 -0
- package/PROMPT_CACHE.md +200 -0
- package/SDK_LOGGING.md +222 -0
- package/SDK_MIGRATION.md +474 -0
- package/SDK_README.md +1122 -0
- package/{testdriver → _testdriver}/acceptance/drag-and-drop.yaml +2 -2
- package/{testdriver → _testdriver}/acceptance/snippets/login.yaml +1 -1
- package/_testdriver/examples/desktop/lifecycle/prerun.yaml +0 -0
- package/{testdriver → _testdriver}/examples/web/lifecycle/prerun.yaml +6 -1
- package/{testdriver → _testdriver}/lifecycle/postrun.yaml +3 -2
- package/_testdriver/lifecycle/prerun.yaml +15 -0
- package/{testdriver → _testdriver}/lifecycle/provision.yaml +7 -2
- package/agent/index.js +258 -68
- package/agent/interface.js +15 -0
- package/agent/lib/cache.js +142 -0
- package/agent/lib/commander.js +1 -39
- package/agent/lib/commands.js +143 -188
- package/agent/lib/redraw.js +6 -3
- package/agent/lib/sandbox.js +19 -5
- package/agent/lib/sdk.js +1 -0
- package/agent/lib/system.js +0 -3
- package/agent/lib/validation.js +1 -7
- package/debug-locate-response.js +82 -0
- package/debug-screenshot-1763401388589.png +0 -0
- package/debugger/index.html +15 -4
- package/docs/ARCHITECTURE.md +424 -0
- package/docs/AWESOME_LOGS_QUICK_REF.md +100 -0
- package/docs/QUICK_START_TEST_RECORDING.md +215 -0
- package/docs/SDK_AWESOME_LOGS.md +468 -0
- package/docs/TEST_RECORDING.md +388 -0
- package/docs/docs.json +232 -152
- package/docs/sdk-browser-rendering.md +167 -0
- package/docs/v6/getting-started/self-hosting.mdx +407 -0
- package/docs/{guide → v6/guide}/dashcam.mdx +1 -1
- package/docs/{guide → v6/guide}/environment-variables.mdx +4 -5
- package/docs/{guide → v6/guide}/lifecycle.mdx +1 -1
- package/docs/v6/overview/comparison.mdx +101 -0
- package/docs/v7/README.md +135 -0
- package/docs/v7/api/ai.mdx +205 -0
- package/docs/v7/api/assert.mdx +285 -0
- package/docs/v7/api/assertions.mdx +403 -0
- package/docs/v7/api/click.mdx +287 -0
- package/docs/v7/api/client.mdx +322 -0
- package/docs/v7/api/elements.mdx +479 -0
- package/docs/v7/api/exec.mdx +346 -0
- package/docs/v7/api/find.mdx +316 -0
- package/docs/v7/api/focusApplication.mdx +294 -0
- package/docs/v7/api/hover.mdx +279 -0
- package/docs/v7/api/pressKeys.mdx +349 -0
- package/docs/v7/api/sandbox.mdx +404 -0
- package/docs/v7/api/scroll.mdx +300 -0
- package/docs/v7/api/type.mdx +314 -0
- package/docs/v7/commands/assert.mdx +45 -0
- package/docs/v7/commands/exec.mdx +282 -0
- package/docs/v7/commands/focus-application.mdx +44 -0
- package/docs/v7/commands/hover-image.mdx +69 -0
- package/docs/v7/commands/hover-text.mdx +47 -0
- package/docs/v7/commands/if.mdx +53 -0
- package/docs/v7/commands/match-image.mdx +67 -0
- package/docs/v7/commands/press-keys.mdx +87 -0
- package/docs/v7/commands/remember.mdx +49 -0
- package/docs/v7/commands/run.mdx +44 -0
- package/docs/v7/commands/scroll-until-image.mdx +66 -0
- package/docs/v7/commands/scroll-until-text.mdx +60 -0
- package/docs/v7/commands/scroll.mdx +69 -0
- package/docs/v7/commands/type.mdx +45 -0
- package/docs/v7/commands/wait-for-image.mdx +54 -0
- package/docs/v7/commands/wait-for-text.mdx +48 -0
- package/docs/v7/commands/wait.mdx +45 -0
- package/docs/v7/getting-started/quickstart.mdx +199 -0
- package/docs/v7/guides/migration.mdx +562 -0
- package/docs/{getting-started → v7/guides}/self-hosting.mdx +11 -12
- package/docs/v7/playwright.mdx +342 -0
- package/eslint.config.js +19 -1
- package/examples/run-tests-with-recording.sh +70 -0
- package/examples/screenshot-example.js +63 -0
- package/examples/sdk-awesome-logs-demo.js +177 -0
- package/examples/sdk-cache-thresholds.js +96 -0
- package/examples/sdk-element-properties.js +155 -0
- package/examples/sdk-simple-example.js +65 -0
- package/examples/test-recording-example.test.js +166 -0
- package/interfaces/cli/lib/base.js +10 -4
- package/interfaces/logger.js +2 -1
- package/interfaces/shared-test-state.mjs +69 -0
- package/interfaces/vitest-plugin.mjs +744 -0
- package/mcp-server/AI_GUIDELINES.md +57 -0
- package/package.json +18 -5
- package/schema.json +8 -29
- package/scripts/view-test-results.mjs +96 -0
- package/sdk-log-formatter.js +714 -0
- package/sdk.d.ts +735 -0
- package/sdk.js +1906 -0
- package/{.github/workflows/self-hosted.yml → self-hosted.yml} +13 -4
- package/setup/aws/cloudformation.yaml +9 -2
- package/test/mcp-example-test.yaml +27 -0
- package/test-find-api.js +73 -0
- package/test-prompt-cache.js +96 -0
- package/test-sandbox-render.js +28 -0
- package/test-sdk-methods.js +15 -0
- package/test-sdk-refactor.js +53 -0
- package/test-stack-trace.mjs +57 -0
- package/testdriver/acceptance-sdk/QUICK_REFERENCE.md +61 -0
- package/testdriver/acceptance-sdk/README.md +128 -0
- package/testdriver/acceptance-sdk/TEST_REPORTING.md +245 -0
- package/testdriver/acceptance-sdk/assert.test.mjs +44 -0
- package/testdriver/acceptance-sdk/drag-and-drop.test.mjs +70 -0
- package/testdriver/acceptance-sdk/element-not-found.test.mjs +38 -0
- package/testdriver/acceptance-sdk/exec-js.test.mjs +55 -0
- package/testdriver/acceptance-sdk/exec-output.test.mjs +71 -0
- package/testdriver/acceptance-sdk/exec-pwsh.test.mjs +69 -0
- package/testdriver/acceptance-sdk/focus-window.test.mjs +48 -0
- package/testdriver/acceptance-sdk/formatted-logging.test.mjs +41 -0
- package/testdriver/acceptance-sdk/hover-image.test.mjs +43 -0
- package/testdriver/acceptance-sdk/hover-text-with-description.test.mjs +50 -0
- package/testdriver/acceptance-sdk/hover-text.test.mjs +41 -0
- package/testdriver/acceptance-sdk/match-image.test.mjs +48 -0
- package/testdriver/acceptance-sdk/press-keys.test.mjs +64 -0
- package/testdriver/acceptance-sdk/prompt.test.mjs +45 -0
- package/testdriver/acceptance-sdk/scroll-keyboard.test.mjs +52 -0
- package/testdriver/acceptance-sdk/scroll-until-image.test.mjs +51 -0
- package/testdriver/acceptance-sdk/scroll-until-text.test.mjs +42 -0
- package/testdriver/acceptance-sdk/scroll.test.mjs +50 -0
- package/testdriver/acceptance-sdk/setup/globalTeardown.mjs +11 -0
- package/testdriver/acceptance-sdk/setup/lifecycleHelpers.mjs +239 -0
- package/testdriver/acceptance-sdk/setup/testHelpers.mjs +648 -0
- package/testdriver/acceptance-sdk/setup/vitestSetup.mjs +40 -0
- package/testdriver/acceptance-sdk/type-checking-demo.js +49 -0
- package/testdriver/acceptance-sdk/type.test.mjs +84 -0
- package/verify-element-api.js +89 -0
- package/verify-types.js +0 -0
- package/vitest.config.example.js +19 -0
- package/vitest.config.mjs +65 -0
- package/vitest.config.mjs.bak +44 -0
- package/.github/workflows/acceptance-v6.yml +0 -169
- package/docs/overview/comparison.mdx +0 -82
- package/testdriver/lifecycle/prerun.yaml +0 -17
- /package/{testdriver/examples/desktop/lifecycle/prerun.yaml → .env.example} +0 -0
- /package/{testdriver → _testdriver}/acceptance/assert.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/dashcam.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/embed.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/exec-js.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/exec-output.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/exec-shell.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/focus-window.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/hover-image.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/hover-text-with-description.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/hover-text.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/if-else.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/match-image.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/press-keys.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/prompt.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/remember.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/screenshots/cart.png +0 -0
- /package/{testdriver → _testdriver}/acceptance/scroll-keyboard.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/scroll-until-image.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/scroll-until-text.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/scroll.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/snippets/match-cart.yaml +0 -0
- /package/{testdriver → _testdriver}/acceptance/type.yaml +0 -0
- /package/{testdriver → _testdriver}/behavior/failure.yaml +0 -0
- /package/{testdriver → _testdriver}/behavior/hover-text.yaml +0 -0
- /package/{testdriver → _testdriver}/behavior/lifecycle/postrun.yaml +0 -0
- /package/{testdriver → _testdriver}/behavior/lifecycle/prerun.yaml +0 -0
- /package/{testdriver → _testdriver}/behavior/lifecycle/provision.yaml +0 -0
- /package/{testdriver → _testdriver}/behavior/secrets.yaml +0 -0
- /package/{testdriver → _testdriver}/edge-cases/dashcam-chrome.yaml +0 -0
- /package/{testdriver → _testdriver}/edge-cases/exec-pwsh-multiline.yaml +0 -0
- /package/{testdriver → _testdriver}/edge-cases/js-exception.yaml +0 -0
- /package/{testdriver → _testdriver}/edge-cases/js-promise.yaml +0 -0
- /package/{testdriver → _testdriver}/edge-cases/lifecycle/postrun.yaml +0 -0
- /package/{testdriver → _testdriver}/edge-cases/prompt-in-middle.yaml +0 -0
- /package/{testdriver → _testdriver}/edge-cases/prompt-nested.yaml +0 -0
- /package/{testdriver → _testdriver}/edge-cases/success-test.yaml +0 -0
- /package/{testdriver → _testdriver}/examples/android/example.yaml +0 -0
- /package/{testdriver → _testdriver}/examples/android/lifecycle/postrun.yaml +0 -0
- /package/{testdriver → _testdriver}/examples/android/lifecycle/provision.yaml +0 -0
- /package/{testdriver → _testdriver}/examples/android/readme.md +0 -0
- /package/{testdriver → _testdriver}/examples/chrome-extension/lifecycle/provision.yaml +0 -0
- /package/{testdriver → _testdriver}/examples/desktop/lifecycle/provision.yaml +0 -0
- /package/{testdriver → _testdriver}/examples/vscode-extension/lifecycle/provision.yaml +0 -0
- /package/{testdriver → _testdriver}/examples/web/lifecycle/postrun.yaml +0 -0
- /package/docs/{account → v6/account}/dashboard.mdx +0 -0
- /package/docs/{account → v6/account}/enterprise.mdx +0 -0
- /package/docs/{account → v6/account}/pricing.mdx +0 -0
- /package/docs/{account → v6/account}/projects.mdx +0 -0
- /package/docs/{account → v6/account}/team.mdx +0 -0
- /package/docs/{action → v6/action}/ami.mdx +0 -0
- /package/docs/{action → v6/action}/performance.mdx +0 -0
- /package/docs/{action → v6/action}/secrets.mdx +0 -0
- /package/docs/{apps → v6/apps}/chrome-extensions.mdx +0 -0
- /package/docs/{apps → v6/apps}/desktop-apps.mdx +0 -0
- /package/docs/{apps → v6/apps}/mobile-apps.mdx +0 -0
- /package/docs/{apps → v6/apps}/static-websites.mdx +0 -0
- /package/docs/{apps → v6/apps}/tauri-apps.mdx +0 -0
- /package/docs/{bugs → v6/bugs}/jira.mdx +0 -0
- /package/docs/{cli → v6/cli}/overview.mdx +0 -0
- /package/docs/{commands → v6/commands}/assert.mdx +0 -0
- /package/docs/{commands → v6/commands}/exec.mdx +0 -0
- /package/docs/{commands → v6/commands}/focus-application.mdx +0 -0
- /package/docs/{commands → v6/commands}/hover-image.mdx +0 -0
- /package/docs/{commands → v6/commands}/hover-text.mdx +0 -0
- /package/docs/{commands → v6/commands}/if.mdx +0 -0
- /package/docs/{commands → v6/commands}/match-image.mdx +0 -0
- /package/docs/{commands → v6/commands}/press-keys.mdx +0 -0
- /package/docs/{commands → v6/commands}/remember.mdx +0 -0
- /package/docs/{commands → v6/commands}/run.mdx +0 -0
- /package/docs/{commands → v6/commands}/scroll-until-image.mdx +0 -0
- /package/docs/{commands → v6/commands}/scroll-until-text.mdx +0 -0
- /package/docs/{commands → v6/commands}/scroll.mdx +0 -0
- /package/docs/{commands → v6/commands}/type.mdx +0 -0
- /package/docs/{commands → v6/commands}/wait-for-image.mdx +0 -0
- /package/docs/{commands → v6/commands}/wait-for-text.mdx +0 -0
- /package/docs/{commands → v6/commands}/wait.mdx +0 -0
- /package/docs/{exporting → v6/exporting}/junit.mdx +0 -0
- /package/docs/{exporting → v6/exporting}/playwright.mdx +0 -0
- /package/docs/{features → v6/features}/auto-healing.mdx +0 -0
- /package/docs/{features → v6/features}/generation.mdx +0 -0
- /package/docs/{features → v6/features}/parallel-testing.mdx +0 -0
- /package/docs/{features → v6/features}/reusable-snippets.mdx +0 -0
- /package/docs/{features → v6/features}/selectorless.mdx +0 -0
- /package/docs/{features → v6/features}/visual-assertions.mdx +0 -0
- /package/docs/{getting-started → v6/getting-started}/ci.mdx +0 -0
- /package/docs/{getting-started → v6/getting-started}/cli.mdx +0 -0
- /package/docs/{getting-started → v6/getting-started}/editing.mdx +0 -0
- /package/docs/{getting-started → v6/getting-started}/playwright.mdx +0 -0
- /package/docs/{getting-started → v6/getting-started}/running.mdx +0 -0
- /package/docs/{getting-started → v6/getting-started}/vscode.mdx +0 -0
- /package/docs/{guide → v6/guide}/assertions.mdx +0 -0
- /package/docs/{guide → v6/guide}/authentication.mdx +0 -0
- /package/docs/{guide → v6/guide}/code.mdx +0 -0
- /package/docs/{guide → v6/guide}/locating.mdx +0 -0
- /package/docs/{guide → v6/guide}/protips.mdx +0 -0
- /package/docs/{guide → v6/guide}/variables.mdx +0 -0
- /package/docs/{guide → v6/guide}/waiting.mdx +0 -0
- /package/docs/{importing → v6/importing}/csv.mdx +0 -0
- /package/docs/{importing → v6/importing}/gherkin.mdx +0 -0
- /package/docs/{importing → v6/importing}/jira.mdx +0 -0
- /package/docs/{importing → v6/importing}/testrail.mdx +0 -0
- /package/docs/{integrations → v6/integrations}/electron.mdx +0 -0
- /package/docs/{integrations → v6/integrations}/netlify.mdx +0 -0
- /package/docs/{integrations → v6/integrations}/vercel.mdx +0 -0
- /package/docs/{interactive → v6/interactive}/explore.mdx +0 -0
- /package/docs/{interactive → v6/interactive}/run.mdx +0 -0
- /package/docs/{interactive → v6/interactive}/save.mdx +0 -0
- /package/docs/{overview → v6/overview}/faq.mdx +0 -0
- /package/docs/{overview → v6/overview}/performance.mdx +0 -0
- /package/docs/{overview → v6/overview}/quickstart.mdx +0 -0
- /package/docs/{overview → v6/overview}/what-is-testdriver.mdx +0 -0
- /package/docs/{scenarios → v6/scenarios}/ai-chatbot.mdx +0 -0
- /package/docs/{scenarios → v6/scenarios}/cookie-banner.mdx +0 -0
- /package/docs/{scenarios → v6/scenarios}/file-upload.mdx +0 -0
- /package/docs/{scenarios → v6/scenarios}/form-filling.mdx +0 -0
- /package/docs/{scenarios → v6/scenarios}/log-in.mdx +0 -0
- /package/docs/{scenarios → v6/scenarios}/pdf-generation.mdx +0 -0
- /package/docs/{scenarios → v6/scenarios}/spell-check.mdx +0 -0
- /package/docs/{security → v6/security}/action.mdx +0 -0
- /package/docs/{security → v6/security}/agent.mdx +0 -0
- /package/docs/{security → v6/security}/platform.mdx +0 -0
- /package/docs/{tutorials → v6/tutorials}/advanced-test.mdx +0 -0
- /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;
|