@testdriverai/mcp 7.11.46 → 7.11.47-test
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/agent/lib/commands.js +13 -1
- package/agent/lib/openrouter-key-error.test.mjs +146 -0
- package/agent/lib/sdk.js +67 -2
- package/package.json +1 -1
- package/sdk.js +45 -1
package/agent/lib/commands.js
CHANGED
|
@@ -156,6 +156,8 @@ const createCommands = (
|
|
|
156
156
|
* @param {number} [data.similarity] - Cache similarity score
|
|
157
157
|
* @param {string} [data.screenshotUrl] - S3 key for screenshot
|
|
158
158
|
* @param {boolean} [data.isSecret] - Whether interaction contains sensitive data
|
|
159
|
+
* @param {number} [data.zoom] - Zoom factor used by the find (0 = disabled)
|
|
160
|
+
* @param {boolean} [data.verify] - Whether AI verification was enabled for the find
|
|
159
161
|
*/
|
|
160
162
|
const trackInteraction = (data) => {
|
|
161
163
|
const sessionId = sessionInstance?.get();
|
|
@@ -180,6 +182,8 @@ const createCommands = (
|
|
|
180
182
|
reasoning: data.reasoning,
|
|
181
183
|
similarity: data.similarity,
|
|
182
184
|
screenshotUrl: data.screenshotUrl,
|
|
185
|
+
zoom: data.zoom,
|
|
186
|
+
verify: data.verify,
|
|
183
187
|
}).catch((err) => {
|
|
184
188
|
console.warn(`Failed to track ${data.interactionType} interaction:`, err.message);
|
|
185
189
|
});
|
|
@@ -614,9 +618,11 @@ const createCommands = (
|
|
|
614
618
|
reasoning: elementData.reasoning ?? null,
|
|
615
619
|
similarity: elementData.similarity ?? null,
|
|
616
620
|
screenshotUrl: elementData.screenshotUrl ?? null,
|
|
621
|
+
zoom: elementData.zoom ?? null,
|
|
622
|
+
verify: elementData.verify ?? null,
|
|
617
623
|
});
|
|
618
624
|
}
|
|
619
|
-
|
|
625
|
+
|
|
620
626
|
// Wait for redraw and track duration
|
|
621
627
|
const redrawStartTime = Date.now();
|
|
622
628
|
await redraw.wait(5000, redrawOptions);
|
|
@@ -662,6 +668,8 @@ const createCommands = (
|
|
|
662
668
|
confidence: elementData.confidence ?? null,
|
|
663
669
|
reasoning: elementData.reasoning ?? null,
|
|
664
670
|
similarity: elementData.similarity ?? null,
|
|
671
|
+
zoom: elementData.zoom ?? null,
|
|
672
|
+
verify: elementData.verify ?? null,
|
|
665
673
|
});
|
|
666
674
|
}
|
|
667
675
|
throw error;
|
|
@@ -730,6 +738,8 @@ const createCommands = (
|
|
|
730
738
|
reasoning: elementData.reasoning ?? null,
|
|
731
739
|
similarity: elementData.similarity ?? null,
|
|
732
740
|
screenshotUrl: elementData.screenshotUrl ?? null,
|
|
741
|
+
zoom: elementData.zoom ?? null,
|
|
742
|
+
verify: elementData.verify ?? null,
|
|
733
743
|
});
|
|
734
744
|
}
|
|
735
745
|
|
|
@@ -767,6 +777,8 @@ const createCommands = (
|
|
|
767
777
|
reasoning: elementData.reasoning ?? null,
|
|
768
778
|
similarity: elementData.similarity ?? null,
|
|
769
779
|
screenshotUrl: elementData.screenshotUrl ?? null,
|
|
780
|
+
zoom: elementData.zoom ?? null,
|
|
781
|
+
verify: elementData.verify ?? null,
|
|
770
782
|
});
|
|
771
783
|
}
|
|
772
784
|
throw error;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const { createSDK, withRetry } = require("./sdk.js");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A team's OpenRouter key can be revoked, run out of credit, or never be
|
|
10
|
+
* configured. The API reports that as a 400 carrying an OPENROUTER_* code.
|
|
11
|
+
* Two things have to hold for the user to ever see it: the request layer must
|
|
12
|
+
* not retry it into a two-minute stall, and it must arrive as a real error with
|
|
13
|
+
* `isConfigError` set — that flag is what stops find()/findAll() from folding it
|
|
14
|
+
* into "element not found" / an empty array.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const axiosError = (status, data) =>
|
|
18
|
+
Object.assign(new Error(`Request failed with status code ${status}`), {
|
|
19
|
+
response: { status, data },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const KEY_ERROR_BODY = {
|
|
23
|
+
error:
|
|
24
|
+
"Your team's OpenRouter API key has no credits remaining — HTTP 402. Add credits at https://openrouter.ai/credits.",
|
|
25
|
+
code: "OPENROUTER_KEY_ERROR",
|
|
26
|
+
details: { openrouterStatus: 402, source: "testdriver-locate" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe("OpenRouter key errors are not retried", () => {
|
|
30
|
+
it("fails a rejected key on the first attempt", async () => {
|
|
31
|
+
let attempts = 0;
|
|
32
|
+
await expect(
|
|
33
|
+
withRetry(async () => {
|
|
34
|
+
attempts++;
|
|
35
|
+
throw axiosError(400, KEY_ERROR_BODY);
|
|
36
|
+
}),
|
|
37
|
+
).rejects.toThrow();
|
|
38
|
+
expect(attempts).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("fails a missing key on the first attempt", async () => {
|
|
42
|
+
let attempts = 0;
|
|
43
|
+
await expect(
|
|
44
|
+
withRetry(async () => {
|
|
45
|
+
attempts++;
|
|
46
|
+
throw axiosError(400, {
|
|
47
|
+
error: "OpenRouter API key is required for self-hosted plans.",
|
|
48
|
+
code: "OPENROUTER_KEY_REQUIRED",
|
|
49
|
+
});
|
|
50
|
+
}),
|
|
51
|
+
).rejects.toThrow();
|
|
52
|
+
expect(attempts).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("still retries server errors and network failures", async () => {
|
|
56
|
+
let attempts = 0;
|
|
57
|
+
await expect(
|
|
58
|
+
withRetry(
|
|
59
|
+
async () => {
|
|
60
|
+
attempts++;
|
|
61
|
+
throw axiosError(500, {});
|
|
62
|
+
},
|
|
63
|
+
{ retryConfig: { maxRetries: 2, baseDelayMs: 1 } },
|
|
64
|
+
),
|
|
65
|
+
).rejects.toThrow();
|
|
66
|
+
expect(attempts).toBe(3);
|
|
67
|
+
|
|
68
|
+
attempts = 0;
|
|
69
|
+
await expect(
|
|
70
|
+
withRetry(
|
|
71
|
+
async () => {
|
|
72
|
+
attempts++;
|
|
73
|
+
throw Object.assign(new Error("socket hang up"), {
|
|
74
|
+
code: "ECONNRESET",
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
{ retryConfig: { maxRetries: 2, baseDelayMs: 1 } },
|
|
78
|
+
),
|
|
79
|
+
).rejects.toThrow();
|
|
80
|
+
expect(attempts).toBe(3);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("OpenRouter key errors reach the caller", () => {
|
|
85
|
+
/** Serve one 400 with the given body, then hand back its origin. */
|
|
86
|
+
const serve = async (body) => {
|
|
87
|
+
const server = http.createServer((req, res) => {
|
|
88
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
89
|
+
res.end(JSON.stringify(body));
|
|
90
|
+
});
|
|
91
|
+
await new Promise((resolve) => server.listen(0, resolve));
|
|
92
|
+
return {
|
|
93
|
+
origin: `http://127.0.0.1:${server.address().port}`,
|
|
94
|
+
close: () => new Promise((resolve) => server.close(resolve)),
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
it("throws the API's message with isConfigError set", async () => {
|
|
99
|
+
const { origin, close } = await serve(KEY_ERROR_BODY);
|
|
100
|
+
const emitted = [];
|
|
101
|
+
const { req } = createSDK(
|
|
102
|
+
{ emit: (event, payload) => emitted.push({ event, payload }) },
|
|
103
|
+
{ TD_API_ROOT: origin },
|
|
104
|
+
{ get: () => "session-1" },
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const error = await req("find", { element: "the login button" }).then(
|
|
109
|
+
() => null,
|
|
110
|
+
(e) => e,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(error).toBeTruthy();
|
|
114
|
+
expect(error.isConfigError).toBe(true);
|
|
115
|
+
expect(error.code).toBe("OPENROUTER_KEY_ERROR");
|
|
116
|
+
expect(error.message).toBe(KEY_ERROR_BODY.error);
|
|
117
|
+
expect(error.details).toEqual(KEY_ERROR_BODY.details);
|
|
118
|
+
|
|
119
|
+
// The message also has to reach whoever is listening to the emitter,
|
|
120
|
+
// not just the throw site.
|
|
121
|
+
expect(
|
|
122
|
+
emitted.some((e) => e.payload?.message === KEY_ERROR_BODY.error),
|
|
123
|
+
).toBe(true);
|
|
124
|
+
} finally {
|
|
125
|
+
await close();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("leaves other 400s on the existing path", async () => {
|
|
130
|
+
const { origin, close } = await serve({ error: "Invalid image data" });
|
|
131
|
+
const { req } = createSDK({ emit: () => {} }, { TD_API_ROOT: origin }, {
|
|
132
|
+
get: () => "session-1",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const error = await req("find", { element: "x" }).then(
|
|
137
|
+
() => null,
|
|
138
|
+
(e) => e,
|
|
139
|
+
);
|
|
140
|
+
expect(error).toBeTruthy();
|
|
141
|
+
expect(error.isConfigError).toBeUndefined();
|
|
142
|
+
} finally {
|
|
143
|
+
await close();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
package/agent/lib/sdk.js
CHANGED
|
@@ -30,6 +30,27 @@ const DEFAULT_RETRY_CONFIG = {
|
|
|
30
30
|
retryableStatusCodes: [429, 500, 502, 503, 504],
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* API error codes that mean "your configuration is wrong" rather than
|
|
35
|
+
* "something went wrong this time". These fail identically on every attempt, so
|
|
36
|
+
* retrying only delays the message the user needs to read.
|
|
37
|
+
*/
|
|
38
|
+
const CONFIG_ERROR_CODES = [
|
|
39
|
+
"OPENROUTER_KEY_ERROR",
|
|
40
|
+
"OPENROUTER_KEY_REQUIRED",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read the API's error code out of a response body, whichever field it used.
|
|
45
|
+
* @param {Error} error - The axios error
|
|
46
|
+
* @returns {string|null}
|
|
47
|
+
*/
|
|
48
|
+
function apiErrorCode(error) {
|
|
49
|
+
const data = error.response?.data;
|
|
50
|
+
if (!data || typeof data !== "object") return null;
|
|
51
|
+
return data.code || (typeof data.error === "string" ? data.error : null);
|
|
52
|
+
}
|
|
53
|
+
|
|
33
54
|
/**
|
|
34
55
|
* Determines if an error is retryable
|
|
35
56
|
* @param {Error} error - The axios error
|
|
@@ -37,7 +58,24 @@ const DEFAULT_RETRY_CONFIG = {
|
|
|
37
58
|
* @returns {boolean} Whether the request should be retried
|
|
38
59
|
*/
|
|
39
60
|
function isRetryableError(error, config = DEFAULT_RETRY_CONFIG) {
|
|
40
|
-
|
|
61
|
+
// A rejected OpenRouter key (or a missing one) is not going to start working
|
|
62
|
+
// on attempt two — surface it now instead of after ten backoffs.
|
|
63
|
+
if (CONFIG_ERROR_CODES.includes(apiErrorCode(error))) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A request the API rejected as malformed won't fix itself either.
|
|
68
|
+
if (error.response?.status === 400) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Network errors (no HTTP response)
|
|
73
|
+
if (!error.response) {
|
|
74
|
+
return !!error.code && config.retryableNetworkCodes.includes(error.code);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Retry only on explicitly retryable HTTP statuses.
|
|
78
|
+
return config.retryableStatusCodes.includes(error.response.status);
|
|
41
79
|
}
|
|
42
80
|
|
|
43
81
|
/**
|
|
@@ -566,8 +604,35 @@ const createSDK = (emitter, config, sessionInstance) => {
|
|
|
566
604
|
throw detailedError;
|
|
567
605
|
}
|
|
568
606
|
|
|
569
|
-
// Server errors (5xx) - API is down or having issues
|
|
570
607
|
const status = error.response?.status;
|
|
608
|
+
|
|
609
|
+
// Configuration errors (400 + a known code) — the API rejected the call
|
|
610
|
+
// for a reason the user can fix, most often an OpenRouter key that was
|
|
611
|
+
// revoked, ran out of credit, or was never configured. These used to
|
|
612
|
+
// arrive as a bare "Request failed with status code 400" and get folded
|
|
613
|
+
// into "element not found", so the real cause never reached the user.
|
|
614
|
+
const code = apiErrorCode(error);
|
|
615
|
+
if (status === 400 && CONFIG_ERROR_CODES.includes(code)) {
|
|
616
|
+
const data = error.response.data;
|
|
617
|
+
const configError = new Error(
|
|
618
|
+
data.error || data.message || "TestDriver rejected the request",
|
|
619
|
+
);
|
|
620
|
+
configError.code = code;
|
|
621
|
+
configError.isConfigError = true;
|
|
622
|
+
configError.details = data.details;
|
|
623
|
+
configError.originalError = error;
|
|
624
|
+
configError.path = path;
|
|
625
|
+
|
|
626
|
+
emitter.emit(events.error.sdk, {
|
|
627
|
+
message: configError.message,
|
|
628
|
+
code: configError.code,
|
|
629
|
+
fullError: error,
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
throw configError;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Server errors (5xx) - API is down or having issues
|
|
571
636
|
if (status >= 500) {
|
|
572
637
|
const serverError = new Error(
|
|
573
638
|
error.response?.data?.message ||
|
package/package.json
CHANGED
package/sdk.js
CHANGED
|
@@ -462,6 +462,9 @@ class Element {
|
|
|
462
462
|
const startTime = absoluteTimestamp;
|
|
463
463
|
let response = null;
|
|
464
464
|
let findError = null;
|
|
465
|
+
// Resolved find options, hoisted so they can be reported to interaction tracking
|
|
466
|
+
let resolvedZoom = 0;
|
|
467
|
+
let resolvedVerify = this.sdk.verifyDefault === true;
|
|
465
468
|
|
|
466
469
|
const debugMode =
|
|
467
470
|
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
@@ -568,6 +571,14 @@ class Element {
|
|
|
568
571
|
);
|
|
569
572
|
}
|
|
570
573
|
|
|
574
|
+
// Remember the resolved options so click()/hover() and interaction
|
|
575
|
+
// tracking can report what this find actually ran with
|
|
576
|
+
const requestedZoom = typeof options === "object" && options !== null ? options.zoom : undefined;
|
|
577
|
+
resolvedZoom = requestedZoom === true ? 1 : requestedZoom === false ? 0 : typeof requestedZoom === "number" ? requestedZoom : 0;
|
|
578
|
+
resolvedVerify = verify;
|
|
579
|
+
this._zoom = resolvedZoom;
|
|
580
|
+
this._verify = resolvedVerify;
|
|
581
|
+
|
|
571
582
|
response = await this.sdk.apiClient.req("find", {
|
|
572
583
|
session: this.sdk.getSessionId(),
|
|
573
584
|
element: description,
|
|
@@ -577,7 +588,7 @@ class Element {
|
|
|
577
588
|
cacheKey: cacheKey,
|
|
578
589
|
os: this.sdk.os,
|
|
579
590
|
resolution: this.sdk.resolution,
|
|
580
|
-
zoom:
|
|
591
|
+
zoom: resolvedZoom,
|
|
581
592
|
skipVerify: !verify,
|
|
582
593
|
confidence: minConfidence,
|
|
583
594
|
type: elementType,
|
|
@@ -612,6 +623,18 @@ class Element {
|
|
|
612
623
|
this.sdk.emitter.emit(events.log.log, notFoundMessage);
|
|
613
624
|
}
|
|
614
625
|
} catch (error) {
|
|
626
|
+
// A rejected or missing OpenRouter key breaks every find the same way.
|
|
627
|
+
// Reporting it as "element not found" hides the one thing the user needs
|
|
628
|
+
// to know, so it escapes instead of being folded into the not-found path.
|
|
629
|
+
if (error.isConfigError) {
|
|
630
|
+
const { events } = require("./agent/events.js");
|
|
631
|
+
this.sdk.emitter.emit(
|
|
632
|
+
events.log.log,
|
|
633
|
+
formatter.formatError("Cannot run find()", error),
|
|
634
|
+
);
|
|
635
|
+
throw error;
|
|
636
|
+
}
|
|
637
|
+
|
|
615
638
|
this._response = error.response
|
|
616
639
|
? this._sanitizeResponse(error.response)
|
|
617
640
|
: null;
|
|
@@ -648,12 +671,15 @@ class Element {
|
|
|
648
671
|
success: this._found,
|
|
649
672
|
error: findError,
|
|
650
673
|
cacheHit: findCacheHit,
|
|
674
|
+
coordinates: response?.coordinates ?? null,
|
|
651
675
|
selector: response?.selector,
|
|
652
676
|
selectorUsed: !!response?.selector,
|
|
653
677
|
confidence: response?.confidence ?? null,
|
|
654
678
|
reasoning: response?.reasoning ?? null,
|
|
655
679
|
similarity: response?.similarity ?? null,
|
|
656
680
|
screenshotUrl: response?.screenshotKey ?? null,
|
|
681
|
+
zoom: resolvedZoom,
|
|
682
|
+
verify: resolvedVerify,
|
|
657
683
|
})
|
|
658
684
|
.catch((err) => {
|
|
659
685
|
console.warn("Failed to track find interaction:", err.message);
|
|
@@ -1027,6 +1053,8 @@ class Element {
|
|
|
1027
1053
|
reasoning: this._response?.reasoning ?? null,
|
|
1028
1054
|
similarity: this._response?.similarity ?? null,
|
|
1029
1055
|
screenshotUrl: this._response?.screenshotKey ?? null,
|
|
1056
|
+
zoom: this._zoom ?? null,
|
|
1057
|
+
verify: this._verify ?? null,
|
|
1030
1058
|
};
|
|
1031
1059
|
|
|
1032
1060
|
if (action === "hover") {
|
|
@@ -1078,7 +1106,12 @@ class Element {
|
|
|
1078
1106
|
cacheHit: this._response?.cacheHit,
|
|
1079
1107
|
selectorUsed: !!this._response?.selector,
|
|
1080
1108
|
selector: this._response?.selector,
|
|
1109
|
+
confidence: this._response?.confidence ?? null,
|
|
1110
|
+
reasoning: this._response?.reasoning ?? null,
|
|
1111
|
+
similarity: this._response?.similarity ?? null,
|
|
1081
1112
|
screenshotUrl: this._response?.screenshotKey ?? null,
|
|
1113
|
+
zoom: this._zoom ?? null,
|
|
1114
|
+
verify: this._verify ?? null,
|
|
1082
1115
|
};
|
|
1083
1116
|
|
|
1084
1117
|
await this.commands.hover(
|
|
@@ -2638,6 +2671,17 @@ CAPTCHA_SOLVER_EOF`,
|
|
|
2638
2671
|
} catch (error) {
|
|
2639
2672
|
const duration = Date.now() - startTime;
|
|
2640
2673
|
|
|
2674
|
+
// A rejected or missing OpenRouter key isn't "zero elements matched" —
|
|
2675
|
+
// returning [] here would hide the only thing the user can act on.
|
|
2676
|
+
if (error.isConfigError) {
|
|
2677
|
+
this._lastPromiseSettled = true;
|
|
2678
|
+
this.emitter.emit(
|
|
2679
|
+
events.log.log,
|
|
2680
|
+
formatter.formatError("Cannot run findAll()", error),
|
|
2681
|
+
);
|
|
2682
|
+
throw error;
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2641
2685
|
// Single log at the end - error
|
|
2642
2686
|
const formattedMessage = formatter.formatElementsFound(
|
|
2643
2687
|
description,
|