@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.
@@ -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
- return true;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testdriverai/mcp",
3
- "version": "7.11.46",
3
+ "version": "7.11.47-test",
4
4
  "description": "Next generation autonomous AI agent for end-to-end testing of web & desktop",
5
5
  "main": "sdk.js",
6
6
  "types": "sdk.d.ts",
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: zoom === true ? 1 : zoom === false ? 0 : 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,