@browserstack/mcp-server 2.2.3 → 2.3.0-beta.2
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/capability/appautomate.capability-index.json +3311 -0
- package/capability/automate.capability-index.json +2067 -0
- package/dist/lib/error.d.ts +0 -10
- package/dist/lib/error.js +12 -64
- package/dist/lib/utils.js +2 -13
- package/dist/tools/automate-utils/fetch-screenshots.d.ts +2 -6
- package/dist/tools/automate-utils/fetch-screenshots.js +19 -88
- package/dist/tools/automate.js +2 -2
- package/dist/tools/capability-registry/bind.js +56 -0
- package/dist/tools/capability-registry/config.d.ts +1 -25
- package/dist/tools/capability-registry/config.js +40 -1
- package/dist/tools/capability-registry/discovery.d.ts +1 -1
- package/dist/tools/capability-registry/egress.js +21 -3
- package/dist/tools/capability-registry/index-loader.d.ts +33 -7
- package/dist/tools/capability-registry/index-loader.js +99 -26
- package/dist/tools/capability-registry/register.d.ts +1 -1
- package/dist/tools/capability-registry/register.js +21 -5
- package/dist/tools/capability-registry/resolve.js +42 -1
- package/dist/tools/capability-registry/types.d.ts +86 -0
- package/dist/tools/testmanagement-utils/update-testcase.js +3 -5
- package/package.json +1 -1
|
@@ -6,12 +6,23 @@
|
|
|
6
6
|
* lets searchCapability rank across products from N single-product files.
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
|
+
import appConfig from "../../config.js";
|
|
9
10
|
import { basename, dirname } from "node:path";
|
|
10
11
|
import { ENVELOPE_KEYS, SUPPORTED_SCHEMA_VERSION, } from "./types.js";
|
|
11
12
|
export class IndexError extends Error {
|
|
12
13
|
}
|
|
13
14
|
/** Thrown to the caller as a tool error, so the wording is caller-facing. */
|
|
14
15
|
export class InvocationError extends Error {
|
|
16
|
+
/**
|
|
17
|
+
* Set when the handle exists in the artifact but this surface withholds it.
|
|
18
|
+
*
|
|
19
|
+
* The MESSAGE deliberately does not say so — a caller is told the capability does not
|
|
20
|
+
* exist, because "withheld" invites a retry, a search for synonyms, or a question to the
|
|
21
|
+
* user about enabling it, none of which can succeed here. The flag keeps the distinction
|
|
22
|
+
* for telemetry, where knowing how often a withheld handle is reached for is how we learn
|
|
23
|
+
* whether withholding it was the right call.
|
|
24
|
+
*/
|
|
25
|
+
withheld;
|
|
15
26
|
}
|
|
16
27
|
/** The file inside a product subdirectory, for the earlier nested layout. */
|
|
17
28
|
export const INDEX_FILE = "index.json";
|
|
@@ -43,9 +54,59 @@ export function endpointKey(method, path) {
|
|
|
43
54
|
* FAILS CLOSED: any truthy `disabled` hides it, not just `=== true`. The flag exists to
|
|
44
55
|
* suppress something that cannot work, so an unexpected shape must hide it rather than be
|
|
45
56
|
* ignored — the opposite default would turn a typo into a silently re-enabled capability.
|
|
57
|
+
*
|
|
58
|
+
* `local_only` withholds on a remote server only, for a capability that reads the caller's
|
|
59
|
+
* own machine: there, the path it would take resolves against the operator's disk instead.
|
|
60
|
+
* Read at call time rather than captured, because the remote server builds its config per
|
|
61
|
+
* session and a value captured at module load would outlive the deployment it described.
|
|
46
62
|
*/
|
|
47
63
|
export function isDisabled(capability) {
|
|
48
|
-
|
|
64
|
+
if (capability.disabled)
|
|
65
|
+
return true;
|
|
66
|
+
return Boolean(capability.local_only) && Boolean(appConfig.REMOTE_MCP);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Is this capability published for reference only on this surface?
|
|
70
|
+
*
|
|
71
|
+
* `local_only` on a remote server. The capability is real and the caller may well be asking
|
|
72
|
+
* for exactly it, so hiding it leaves the agent concluding no such route exists. Publishing
|
|
73
|
+
* it without the means to call it, and with a note saying what to do instead, answers the
|
|
74
|
+
* question it was asked.
|
|
75
|
+
*/
|
|
76
|
+
export function isReferenceOnly(capability) {
|
|
77
|
+
if (capability.disabled)
|
|
78
|
+
return false;
|
|
79
|
+
return Boolean(capability.local_only) && Boolean(appConfig.REMOTE_MCP);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The reference form: what it is and what to do instead, and nothing to call it with.
|
|
83
|
+
*
|
|
84
|
+
* The parameters and response shapes go because publishing them invites the attempt — a
|
|
85
|
+
* model handed a body schema will fill it in. `intent` stays, because that is what the
|
|
86
|
+
* search matched on, and `mode` stays so a reader can still see what kind of operation it
|
|
87
|
+
* would have been.
|
|
88
|
+
*/
|
|
89
|
+
function asReference(capability) {
|
|
90
|
+
const reference = { ...capability };
|
|
91
|
+
for (const field of [
|
|
92
|
+
"path_params",
|
|
93
|
+
"query",
|
|
94
|
+
"body",
|
|
95
|
+
"responses",
|
|
96
|
+
"returns",
|
|
97
|
+
"paginated",
|
|
98
|
+
"max_page_size",
|
|
99
|
+
"encoding",
|
|
100
|
+
]) {
|
|
101
|
+
delete reference[field];
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
...reference,
|
|
105
|
+
invocable: false,
|
|
106
|
+
guidance: capability.unavailable_note
|
|
107
|
+
? [capability.unavailable_note]
|
|
108
|
+
: ["This capability cannot be called on this server."],
|
|
109
|
+
};
|
|
49
110
|
}
|
|
50
111
|
function isPlainObject(value) {
|
|
51
112
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -181,6 +242,16 @@ export class CapabilityRegistry {
|
|
|
181
242
|
// between a disabled name and a live one is still a broken artifact, and skipping
|
|
182
243
|
// the disabled half would mean re-enabling the flag could fail the load later,
|
|
183
244
|
// somewhere with less context than here.
|
|
245
|
+
if (isReferenceOnly(capability)) {
|
|
246
|
+
// PUBLISHED, BUT NOT CALLABLE. It keeps its name and intent so the search that
|
|
247
|
+
// should find it does, and loses everything a caller would need to invoke it.
|
|
248
|
+
const reference = asReference(capability);
|
|
249
|
+
enabled.push(reference);
|
|
250
|
+
lookup.set(endpointKey(capability.method, capability.path), reference);
|
|
251
|
+
if (capability.name)
|
|
252
|
+
names.set(capability.name, reference);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
184
255
|
if (isDisabled(capability)) {
|
|
185
256
|
withheld.set(endpointKey(capability.method, capability.path), capability);
|
|
186
257
|
if (capability.name)
|
|
@@ -219,26 +290,25 @@ export class CapabilityRegistry {
|
|
|
219
290
|
this.index = { ...index, products };
|
|
220
291
|
}
|
|
221
292
|
/**
|
|
222
|
-
*
|
|
293
|
+
* Does this build withhold the handle?
|
|
223
294
|
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
295
|
+
* WHAT THE CALLER IS TOLD IS THAT IT DOES NOT EXIST, which is why this returns a boolean
|
|
296
|
+
* rather than a message. Saying "withheld" describes the artifact rather than the
|
|
297
|
+
* surface, and every action it invites — retry, hunt for a synonym, ask the user to turn
|
|
298
|
+
* it on — fails here. A capability absent from search, describe and every listing should
|
|
299
|
+
* read the same way at invocation as it does everywhere else.
|
|
227
300
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
301
|
+
* The caller raises its own not-found error and marks it `withheld`, so the distinction
|
|
302
|
+
* survives in telemetry without reaching the agent.
|
|
230
303
|
*/
|
|
231
|
-
|
|
304
|
+
isWithheld(handle, product) {
|
|
232
305
|
for (const [owner, withheld] of this.withheld) {
|
|
233
306
|
if (product && owner !== product)
|
|
234
307
|
continue;
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
return (`capability_disabled: ${handle} is disabled on this surface. This is ` +
|
|
238
|
-
`deliberate — it is withheld by the ${owner} index, not missing from it — so ` +
|
|
239
|
-
`retrying will not help.`);
|
|
308
|
+
if (withheld.has(handle))
|
|
309
|
+
return true;
|
|
240
310
|
}
|
|
241
|
-
return
|
|
311
|
+
return false;
|
|
242
312
|
}
|
|
243
313
|
/** How many capabilities each product withholds. Startup logging only. */
|
|
244
314
|
disabledCounts() {
|
|
@@ -326,13 +396,13 @@ export class CapabilityRegistry {
|
|
|
326
396
|
matches.push({ product: owner, capability });
|
|
327
397
|
}
|
|
328
398
|
if (matches.length === 0) {
|
|
329
|
-
// WITHHELD
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
399
|
+
// A WITHHELD HANDLE ANSWERS THE SAME WAY AS AN ABSENT ONE. Search, describe and the
|
|
400
|
+
// listings already behave as though it does not exist, and invocation is no place to
|
|
401
|
+
// contradict them: "withheld" is a fact about the artifact, not about this surface,
|
|
402
|
+
// and it invites a retry, a hunt for a synonym, or a question to the user about
|
|
403
|
+
// enabling something that cannot be enabled here. The error carries `withheld` so the
|
|
404
|
+
// attempt is still countable in telemetry.
|
|
405
|
+
const withheldHere = this.isWithheld(name, product);
|
|
336
406
|
// Say whether names are published at all for the product asked about: "no such name"
|
|
337
407
|
// and "this product does not name its capabilities" need different fixes.
|
|
338
408
|
const unnamed = [...this.byName]
|
|
@@ -342,7 +412,10 @@ export class CapabilityRegistry {
|
|
|
342
412
|
? ` ${unnamed.sort().join(", ")} ${unnamed.length > 1 ? "publish" : "publishes"} ` +
|
|
343
413
|
`no capability names yet — call those by \`method\` and \`path\` instead.`
|
|
344
414
|
: " Search again — names come from searchCapability and describeEntity.";
|
|
345
|
-
|
|
415
|
+
const error = new InvocationError(`unknown_capability: ${name}.${hint}`);
|
|
416
|
+
if (withheldHere)
|
|
417
|
+
error.withheld = true;
|
|
418
|
+
throw error;
|
|
346
419
|
}
|
|
347
420
|
if (matches.length > 1 && !product) {
|
|
348
421
|
const owners = matches
|
|
@@ -371,11 +444,11 @@ export class CapabilityRegistry {
|
|
|
371
444
|
matches.push({ product: name, capability });
|
|
372
445
|
}
|
|
373
446
|
if (matches.length === 0) {
|
|
374
|
-
const
|
|
375
|
-
if (off)
|
|
376
|
-
throw new InvocationError(off);
|
|
377
|
-
throw new InvocationError(`unknown_endpoint: ${key}. Search again — send \`method\` and \`path\` exactly as ` +
|
|
447
|
+
const error = new InvocationError(`unknown_endpoint: ${key}. Search again — send \`method\` and \`path\` exactly as ` +
|
|
378
448
|
`searchCapability returned them, placeholders included.`);
|
|
449
|
+
if (this.isWithheld(key, product))
|
|
450
|
+
error.withheld = true;
|
|
451
|
+
throw error;
|
|
379
452
|
}
|
|
380
453
|
if (matches.length > 1 && !product) {
|
|
381
454
|
const owners = matches
|
|
@@ -32,7 +32,7 @@ export interface RegistryDeps {
|
|
|
32
32
|
* Per-product base URL. Never baked into the artifact — it is environment AND account
|
|
33
33
|
* specific: tm is region-sharded, so this is resolved per call, not once at startup.
|
|
34
34
|
*/
|
|
35
|
-
baseUrlFor: (product: string) => Promise<string>;
|
|
35
|
+
baseUrlFor: (product: string, host?: string) => Promise<string>;
|
|
36
36
|
credentialsFor: () => Credentials;
|
|
37
37
|
transport?: Transport;
|
|
38
38
|
}
|
|
@@ -81,7 +81,7 @@ export function addCapabilityRegistryToolsFromConfig(server, config) {
|
|
|
81
81
|
registry,
|
|
82
82
|
// The whole product bundle, not just its host: a region-sharded product declares
|
|
83
83
|
// several candidates and the probe needs an endpoint from its own capabilities.
|
|
84
|
-
baseUrlFor: (product) => resolveBaseUrl(product, config, registry.index.products[product]),
|
|
84
|
+
baseUrlFor: (product, host) => resolveBaseUrl(product, config, registry.index.products[product], undefined, host),
|
|
85
85
|
// Read per call, not captured: the remote server rebuilds config per session, so a
|
|
86
86
|
// captured credential would outlive the session it belongs to.
|
|
87
87
|
credentialsFor: () => ({
|
|
@@ -147,7 +147,12 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
147
147
|
* below pin each branch against the message its source actually produces; if you reword
|
|
148
148
|
* a refusal, one of them will tell you.
|
|
149
149
|
*/
|
|
150
|
-
const invocationReason = (message) => {
|
|
150
|
+
const invocationReason = (message, error) => {
|
|
151
|
+
// A WITHHELD HANDLE IS REPORTED TO THE CALLER AS NOT FOUND, so the message cannot be
|
|
152
|
+
// what distinguishes it. The flag on the error can, which keeps the refusal countable
|
|
153
|
+
// without the wording admitting the capability exists.
|
|
154
|
+
if (error instanceof InvocationError && error.withheld)
|
|
155
|
+
return "capability_disabled";
|
|
151
156
|
if (message.startsWith("unknown_capability:"))
|
|
152
157
|
return "unknown_capability";
|
|
153
158
|
if (message.startsWith("unknown_endpoint:"))
|
|
@@ -812,7 +817,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
812
817
|
}
|
|
813
818
|
catch (error) {
|
|
814
819
|
if (error instanceof InvocationError)
|
|
815
|
-
return refuse("describeCapability", invocationReason(error.message), error.message, undefined, identity);
|
|
820
|
+
return refuse("describeCapability", invocationReason(error.message, error), error.message, undefined, identity);
|
|
816
821
|
logger.error("describeCapability failed: %s", error instanceof Error ? error.message : String(error));
|
|
817
822
|
return refuse("describeCapability", "unexpected_error", "that capability could not be described", undefined, identity);
|
|
818
823
|
}
|
|
@@ -910,6 +915,17 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
910
915
|
query: input.query,
|
|
911
916
|
body: input.body,
|
|
912
917
|
};
|
|
918
|
+
if (capability.invocable === false) {
|
|
919
|
+
// THE ONE PLACE describe AND invoke DIVERGE, deliberately. Everywhere else a name
|
|
920
|
+
// that describes is a name that invokes, and breaking that is its own bug class —
|
|
921
|
+
// but a reference-only capability exists precisely to be read and not called. The
|
|
922
|
+
// refusal carries the instruction, because the note is the whole point of it
|
|
923
|
+
// still being published.
|
|
924
|
+
return refuse("invokeCapability", "capability_not_invocable", `${handle} cannot be called on this server. ` +
|
|
925
|
+
(capability.guidance?.[0] ??
|
|
926
|
+
"Describe it for what to do instead.") +
|
|
927
|
+
" Follow that instead of retrying.", undefined, identity);
|
|
928
|
+
}
|
|
913
929
|
if (capability.mode === "destructive") {
|
|
914
930
|
// Refused before binding, so consent is never sought for something that cannot run.
|
|
915
931
|
return refuse("invokeCapability", "destructive_blocked", `${handle} is a destructive operation and is not available through this surface`, undefined, identity);
|
|
@@ -932,7 +948,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
932
948
|
return refuse("invokeCapability", "change_summary_missing", "change_summary is required: state what will change", undefined, identity);
|
|
933
949
|
}
|
|
934
950
|
}
|
|
935
|
-
const result = await invoke(capability, args, await deps.baseUrlFor(product), deps.credentialsFor(), transport, registry.index.products[product]?.auth, product);
|
|
951
|
+
const result = await invoke(capability, args, await deps.baseUrlFor(product, capability.host), deps.credentialsFor(), transport, registry.index.products[product]?.auth, product);
|
|
936
952
|
// The one row for a completed invoke. invoke() returns a 4xx/5xx rather than
|
|
937
953
|
// throwing, so `success` keeps its tool-level meaning and these two fields carry
|
|
938
954
|
// whether the product call worked. Status 0 means it could not be reached.
|
|
@@ -946,7 +962,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
946
962
|
}
|
|
947
963
|
catch (error) {
|
|
948
964
|
if (error instanceof InvocationError)
|
|
949
|
-
return refuse("invokeCapability", invocationReason(error.message), error.message, undefined, identity);
|
|
965
|
+
return refuse("invokeCapability", invocationReason(error.message, error), error.message, undefined, identity);
|
|
950
966
|
logger.error("invokeCapability failed: %s", error instanceof Error ? error.message : String(error));
|
|
951
967
|
return refuse("invokeCapability", "unexpected_error", "that capability could not be invoked", undefined, identity);
|
|
952
968
|
}
|
|
@@ -16,6 +16,8 @@ import { invalidateToken } from "../../lib/central-oauth.js";
|
|
|
16
16
|
import { bind } from "./bind.js";
|
|
17
17
|
import { authHeaders } from "./egress.js";
|
|
18
18
|
import { resolveScope, resolveTokenUrl } from "./oauth.js";
|
|
19
|
+
import { openAsBlob } from "node:fs";
|
|
20
|
+
import { basename } from "node:path";
|
|
19
21
|
import { InvocationError } from "./index-loader.js";
|
|
20
22
|
function hasNextPage(body) {
|
|
21
23
|
if (typeof body !== "object" || body === null || Array.isArray(body))
|
|
@@ -63,6 +65,30 @@ function assertTransportSafe(baseUrl) {
|
|
|
63
65
|
`carries your BrowserStack access key, so the base URL must be https (or a local ` +
|
|
64
66
|
`host for development). Check CAPABILITY_REGISTRY_BASE_URL_* for a typo.`);
|
|
65
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Turn a bound body into a FormData, streaming any `file` parameter from disk.
|
|
70
|
+
*
|
|
71
|
+
* `bind` has already canonicalised and checked the path, so what arrives here is a file
|
|
72
|
+
* this server is configured to read. The blob is opened rather than buffered: an app build
|
|
73
|
+
* runs to gigabytes and reading one into memory to post it is how a server falls over.
|
|
74
|
+
*/
|
|
75
|
+
async function multipartBody(capability, body) {
|
|
76
|
+
const form = new FormData();
|
|
77
|
+
for (const param of capability.body || []) {
|
|
78
|
+
const value = body?.[param.name];
|
|
79
|
+
if (value === undefined || value === null)
|
|
80
|
+
continue;
|
|
81
|
+
if (param.type === "file") {
|
|
82
|
+
const path = String(value);
|
|
83
|
+
const blob = await openAsBlob(path);
|
|
84
|
+
form.append(param.name, blob, basename(path));
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
form.append(param.name, String(value));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return form;
|
|
91
|
+
}
|
|
66
92
|
export async function invoke(capability, args, baseUrl, credentials, transport, auth, product) {
|
|
67
93
|
if (!baseUrl)
|
|
68
94
|
throw new InvocationError("no base URL is configured for that product");
|
|
@@ -71,7 +97,22 @@ export async function invoke(capability, args, baseUrl, credentials, transport,
|
|
|
71
97
|
// Before binding would be wrong and after sending is too late: a scheme that mints a
|
|
72
98
|
// token must not spend a round trip on a call that `bind` was going to reject anyway.
|
|
73
99
|
const url = `${baseUrl.replace(/\/$/, "")}${bound.path}`;
|
|
74
|
-
const
|
|
100
|
+
const multipart = capability.encoding === "multipart"
|
|
101
|
+
? await multipartBody(capability, bound.body)
|
|
102
|
+
: undefined;
|
|
103
|
+
const send = async () => {
|
|
104
|
+
const headers = await authHeaders(credentials, auth, product);
|
|
105
|
+
if (multipart) {
|
|
106
|
+
// fetch writes its own Content-Type with the boundary. Leaving the JSON one on sends
|
|
107
|
+
// a body the product cannot parse, under a header that says it should be able to.
|
|
108
|
+
delete headers["Content-Type"];
|
|
109
|
+
}
|
|
110
|
+
const payload = multipart ??
|
|
111
|
+
(bound.body === undefined && capability.send_empty_body
|
|
112
|
+
? {}
|
|
113
|
+
: bound.body);
|
|
114
|
+
return transport(capability.method, url, headers, bound.query, payload);
|
|
115
|
+
};
|
|
75
116
|
let response = await send();
|
|
76
117
|
/**
|
|
77
118
|
* One replay, for a minted token the product rejected.
|
|
@@ -116,6 +116,23 @@ export interface Capability {
|
|
|
116
116
|
name?: string;
|
|
117
117
|
method: string;
|
|
118
118
|
path: string;
|
|
119
|
+
/**
|
|
120
|
+
* Which of the product's named `hosts` serves this one capability, when it is not the
|
|
121
|
+
* product's own `base_url`.
|
|
122
|
+
*
|
|
123
|
+
* A product is normally one host. App Automate is not: the account-level API (devices,
|
|
124
|
+
* app builds, plan, session records) answers on one origin and the Appium hub that
|
|
125
|
+
* drives the device answers on another, and neither serves the other's paths. Splitting
|
|
126
|
+
* them into two products would split one entity too — the session a hub capability
|
|
127
|
+
* creates is the session the account-level API reports on — and a caller holding the id
|
|
128
|
+
* would have to know it had changed products to use it.
|
|
129
|
+
*
|
|
130
|
+
* Unset means the product's own host, so every existing index is unaffected. A name that
|
|
131
|
+
* `hosts` does not define is refused by name at invocation rather than quietly falling
|
|
132
|
+
* back, because the fallback would send the call to a host that cannot serve the path and
|
|
133
|
+
* return a 404 that reads like the caller's mistake.
|
|
134
|
+
*/
|
|
135
|
+
host?: string;
|
|
119
136
|
mode: Mode;
|
|
120
137
|
entity: string;
|
|
121
138
|
path_params?: WireParam[];
|
|
@@ -155,6 +172,63 @@ export interface Capability {
|
|
|
155
172
|
* suppression must not be ignored for arriving in an unexpected shape.
|
|
156
173
|
*/
|
|
157
174
|
disabled?: boolean;
|
|
175
|
+
/**
|
|
176
|
+
* Withheld when the server is not running on the caller's own machine.
|
|
177
|
+
*
|
|
178
|
+
* Some capabilities only mean anything when the person and the process share a computer:
|
|
179
|
+
* one that reads a file the caller names is the clear case. On a remote server that path
|
|
180
|
+
* resolves against the OPERATOR's disk, so honouring it would read one user's files on
|
|
181
|
+
* another's behalf — the capability does not become unreliable there, it becomes wrong.
|
|
182
|
+
*
|
|
183
|
+
* Distinct from `disabled`, which withholds a capability everywhere. This one is a claim
|
|
184
|
+
* about where it can run, so the same artifact serves both deployments: a local server
|
|
185
|
+
* publishes it and a remote one does not.
|
|
186
|
+
*
|
|
187
|
+
* FAILS CLOSED in the same way, and takes the same `capability_disabled` path at
|
|
188
|
+
* invocation, so a caller holding the handle is told the absence is deliberate rather
|
|
189
|
+
* than hunting for a synonym.
|
|
190
|
+
*/
|
|
191
|
+
local_only?: boolean;
|
|
192
|
+
/**
|
|
193
|
+
* What to do instead, on a surface where this capability cannot run.
|
|
194
|
+
*
|
|
195
|
+
* A `local_only` capability is NOT hidden on a remote server: the query that should find
|
|
196
|
+
* it still finds it, because an agent asked to upload a file from the user's machine
|
|
197
|
+
* needs to learn that the route exists and what replaces it here. What it loses is the
|
|
198
|
+
* means to call it — the parameters and response shapes are stripped at load, and
|
|
199
|
+
* invocation is refused — so the note is the whole of what remains.
|
|
200
|
+
*
|
|
201
|
+
* Write it as the instruction the caller should follow, not as an apology.
|
|
202
|
+
*/
|
|
203
|
+
unavailable_note?: string;
|
|
204
|
+
/**
|
|
205
|
+
* FALSE when this build publishes the capability for reference but will not call it.
|
|
206
|
+
*
|
|
207
|
+
* Set by the loader, never authored: it is the surface's answer, not the artifact's.
|
|
208
|
+
*/
|
|
209
|
+
invocable?: boolean;
|
|
210
|
+
/**
|
|
211
|
+
* How the request body is encoded. Absent means JSON, which is every capability today.
|
|
212
|
+
*
|
|
213
|
+
* `multipart` exists for endpoints that take the bytes of a file rather than a reference
|
|
214
|
+
* to one. A capability declaring it must also declare a `file` parameter, and should be
|
|
215
|
+
* `local_only`: the path names the caller's own machine.
|
|
216
|
+
*/
|
|
217
|
+
encoding?: "multipart";
|
|
218
|
+
/**
|
|
219
|
+
* Send `{}` rather than no body at all, for an endpoint that takes no parameters.
|
|
220
|
+
*
|
|
221
|
+
* Normally a capability with nothing to send sends nothing, which is right for a REST API
|
|
222
|
+
* and wrong for W3C WebDriver: Chrome answers 400 "invalid argument" to a parameterless
|
|
223
|
+
* POST with the body omitted and 200 to the same call carrying `{}`. Appium tolerates the
|
|
224
|
+
* omission, which is why the identical capability needs this on Automate and not on App
|
|
225
|
+
* Automate.
|
|
226
|
+
*
|
|
227
|
+
* Opt-in per capability rather than a rule for every bodyless POST, because 27 of those
|
|
228
|
+
* exist across the shipped indexes and changing what reaches their products is not
|
|
229
|
+
* something this can verify.
|
|
230
|
+
*/
|
|
231
|
+
send_empty_body?: boolean;
|
|
158
232
|
paginated?: boolean;
|
|
159
233
|
/** The largest page the operation declares. */
|
|
160
234
|
max_page_size?: number;
|
|
@@ -313,6 +387,18 @@ export interface ProductIndex {
|
|
|
313
387
|
* (see `discovery.probePath`); declare it when the derived choice would be wrong.
|
|
314
388
|
*/
|
|
315
389
|
probe_path?: string;
|
|
390
|
+
/**
|
|
391
|
+
* Additional hosts this product is served from, by name, for capabilities that declare
|
|
392
|
+
* `host`.
|
|
393
|
+
*
|
|
394
|
+
* DISTINCT FROM `base_urls`, which is a list of CANDIDATES for one host — probed in turn
|
|
395
|
+
* so a region-sharded product lands on the account's own region. These are several hosts
|
|
396
|
+
* that all serve the product at once, each addressing a different part of it, and the
|
|
397
|
+
* capability says which it needs rather than the server discovering it.
|
|
398
|
+
*
|
|
399
|
+
* `base_url` remains the default for every capability that names no host.
|
|
400
|
+
*/
|
|
401
|
+
hosts?: Record<string, string>;
|
|
316
402
|
capabilities: Capability[];
|
|
317
403
|
entities: Record<string, EntityDoc>;
|
|
318
404
|
paging?: Record<string, PagingRule>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { apiClient } from "../../lib/apiClient.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { formatAxiosError } from "../../lib/error.js";
|
|
4
4
|
import { fetchFormFields, normalizeDefaultFields as normalizeDefaultFieldsFromForm, projectIdentifierToId, } from "./TCG-utils/api.js";
|
|
5
5
|
import { getTMBaseURL } from "../../lib/tm-base-url.js";
|
|
6
6
|
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
@@ -218,14 +218,12 @@ Error details: ${JSON.stringify(err.response?.data || err.message)}`,
|
|
|
218
218
|
isError: true,
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
|
-
const
|
|
222
|
-
const detail = errorText(err, "unknown error");
|
|
223
|
-
const hint = status === 401 ? " Check the BrowserStack credentials in use." : "";
|
|
221
|
+
const errorMessage = formatAxiosError(err, "Failed to update test case");
|
|
224
222
|
return {
|
|
225
223
|
content: [
|
|
226
224
|
{
|
|
227
225
|
type: "text",
|
|
228
|
-
text: `Failed to update test case${
|
|
226
|
+
text: `Failed to update test case: ${errorMessage}. Please verify your credentials and try again.`,
|
|
229
227
|
},
|
|
230
228
|
],
|
|
231
229
|
isError: true,
|