@browserstack/mcp-server 1.5.0-beta.21 → 1.5.0-beta.23
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/README.md +32 -48
- package/capability/loadtesting.capability-index.json +1876 -0
- package/capability/tm.capability-index.json +50104 -0
- package/dist/config.d.ts +1 -4
- package/dist/config.js +2 -23
- package/dist/index.d.ts +1 -2
- package/dist/index.js +3 -7
- package/dist/lib/constants.d.ts +0 -3
- package/dist/lib/constants.js +0 -3
- package/dist/lib/instrumentation.d.ts +2 -14
- package/dist/lib/instrumentation.js +29 -104
- package/dist/lib/upload-validator.d.ts +2 -3
- package/dist/lib/upload-validator.js +15 -18
- package/dist/lib/utils.js +3 -10
- package/dist/lib/version-resolver.d.ts +5 -0
- package/dist/lib/version-resolver.js +2 -8
- package/dist/logger.js +10 -1
- package/dist/server-factory.js +6 -10
- package/dist/tools/accessibility.js +5 -9
- package/dist/tools/accessiblity-utils/accessibility-rag.js +1 -3
- package/dist/tools/appautomate-utils/native-execution/constants.js +2 -4
- package/dist/tools/appautomate.js +1 -3
- package/dist/tools/applive.js +1 -1
- package/dist/tools/automate.d.ts +0 -7
- package/dist/tools/automate.js +1 -104
- package/dist/tools/bstack-sdk.js +2 -2
- package/dist/tools/build-insights.js +2 -41
- package/dist/tools/capability-registry/bind.d.ts +29 -0
- package/dist/tools/capability-registry/bind.js +329 -0
- package/dist/tools/capability-registry/config.d.ts +62 -0
- package/dist/tools/capability-registry/config.js +218 -0
- package/dist/tools/capability-registry/discovery.d.ts +44 -0
- package/dist/tools/capability-registry/discovery.js +99 -0
- package/dist/tools/capability-registry/egress.d.ts +44 -0
- package/dist/tools/capability-registry/egress.js +174 -0
- package/dist/tools/capability-registry/index-loader.d.ts +133 -0
- package/dist/tools/capability-registry/index-loader.js +404 -0
- package/dist/tools/capability-registry/register.d.ts +48 -0
- package/dist/tools/capability-registry/register.js +604 -0
- package/dist/tools/capability-registry/resolve.d.ts +38 -0
- package/dist/tools/capability-registry/resolve.js +45 -0
- package/dist/tools/capability-registry/search.d.ts +170 -0
- package/dist/tools/capability-registry/search.js +709 -0
- package/dist/tools/capability-registry/types.d.ts +282 -0
- package/dist/tools/capability-registry/types.js +33 -0
- package/dist/tools/failurelogs-utils/app-automate.js +3 -4
- package/dist/tools/failurelogs-utils/automate.js +4 -5
- package/dist/tools/get-failure-logs.js +11 -30
- package/dist/tools/observability.js +1 -3
- package/dist/tools/percy-sdk.js +9 -9
- package/dist/tools/rca-agent-utils/format-rca.js +4 -5
- package/dist/tools/rca-agent-utils/get-failed-test-id.js +0 -12
- package/dist/tools/rca-agent-utils/types.d.ts +0 -1
- package/dist/tools/rca-agent.js +2 -5
- package/dist/tools/review-agent.js +1 -2
- package/dist/tools/sdk-utils/common/constants.d.ts +1 -1
- package/dist/tools/sdk-utils/common/constants.js +1 -2
- package/dist/tools/selfheal.js +3 -6
- package/dist/tools/testmanagement-utils/TCG-utils/api.js +7 -26
- package/dist/tools/testmanagement-utils/TCG-utils/config.d.ts +0 -2
- package/dist/tools/testmanagement-utils/TCG-utils/config.js +0 -5
- package/dist/tools/testmanagement-utils/testcase-from-file.js +1 -2
- package/dist/tools/testmanagement-utils/upload-file.js +2 -16
- package/dist/tools/testmanagement.js +17 -39
- package/package.json +11 -5
- package/dist/lib/node-version-notice.d.ts +0 -1
- package/dist/lib/node-version-notice.js +0 -9
- package/dist/lib/tool-latency.d.ts +0 -4
- package/dist/lib/tool-latency.js +0 -16
- package/dist/lib/untrusted-content.d.ts +0 -15
- package/dist/lib/untrusted-content.js +0 -24
- package/dist/tools/ask-browserstack/central-oauth.d.ts +0 -120
- package/dist/tools/ask-browserstack/central-oauth.js +0 -277
- package/dist/tools/ask-browserstack/config.d.ts +0 -102
- package/dist/tools/ask-browserstack/config.js +0 -140
- package/dist/tools/ask-browserstack/egress.d.ts +0 -34
- package/dist/tools/ask-browserstack/egress.js +0 -31
- package/dist/tools/ask-browserstack/register.d.ts +0 -61
- package/dist/tools/ask-browserstack/register.js +0 -416
- package/dist/tools/ask-browserstack/relay.d.ts +0 -201
- package/dist/tools/ask-browserstack/relay.js +0 -577
- package/dist/tools/ask-browserstack/stream.d.ts +0 -116
- package/dist/tools/ask-browserstack/stream.js +0 -236
- package/dist/tools/ask-browserstack/types.d.ts +0 -196
- package/dist/tools/ask-browserstack/types.js +0 -14
- package/dist/tools/automate-utils/list-session-ids.d.ts +0 -28
- package/dist/tools/automate-utils/list-session-ids.js +0 -87
- package/dist/tools/automate-utils/resolve-hashed-build-id.d.ts +0 -30
- package/dist/tools/automate-utils/resolve-hashed-build-id.js +0 -124
- package/dist/tools/failurelogs-utils/resolve-app-build-id.d.ts +0 -2
- package/dist/tools/failurelogs-utils/resolve-app-build-id.js +0 -5
- package/dist/tools/failurelogs-utils/video.d.ts +0 -3
- package/dist/tools/failurelogs-utils/video.js +0 -25
- package/dist/tools/tool-handoff.d.ts +0 -66
- package/dist/tools/tool-handoff.js +0 -78
package/dist/tools/automate.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { fetchAutomationScreenshots } from "./automate-utils/fetch-screenshots.js";
|
|
3
|
-
import { DEFAULT_SESSION_LIST_LIMIT, listSessionIds, UnknownBuildError, } from "./automate-utils/list-session-ids.js";
|
|
4
|
-
import { isObservabilityBuildUuid, resolveHashedBuildId, } from "./automate-utils/resolve-hashed-build-id.js";
|
|
5
3
|
import { SessionType } from "../lib/constants.js";
|
|
6
4
|
import { trackMCP } from "../lib/instrumentation.js";
|
|
7
5
|
import logger from "../logger.js";
|
|
@@ -50,63 +48,10 @@ export async function fetchAutomationScreenshotsTool(args, config) {
|
|
|
50
48
|
};
|
|
51
49
|
}
|
|
52
50
|
}
|
|
53
|
-
export async function listSessionIdsTool(args, config) {
|
|
54
|
-
try {
|
|
55
|
-
// Accept the observability build id too. Observability ids are usually
|
|
56
|
-
// UUIDs but can also be 40-char hex like Automate hashed ids, so shape
|
|
57
|
-
// alone is not enough: try the REST list first and resolve on a miss.
|
|
58
|
-
const inputId = args.buildId.trim();
|
|
59
|
-
let buildId = inputId;
|
|
60
|
-
let resolvedNote;
|
|
61
|
-
const resolve = async () => {
|
|
62
|
-
const resolved = await resolveHashedBuildId(inputId, config, args.sessionType);
|
|
63
|
-
buildId = resolved.hashedBuildId;
|
|
64
|
-
resolvedNote = `Resolved observability build ${inputId} to hashed build id ${buildId}.`;
|
|
65
|
-
};
|
|
66
|
-
let sessions;
|
|
67
|
-
if (isObservabilityBuildUuid(inputId)) {
|
|
68
|
-
await resolve();
|
|
69
|
-
sessions = await listSessionIds({ ...args, buildId }, config);
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
try {
|
|
73
|
-
sessions = await listSessionIds({ ...args, buildId }, config);
|
|
74
|
-
}
|
|
75
|
-
catch (error) {
|
|
76
|
-
if (!(error instanceof UnknownBuildError))
|
|
77
|
-
throw error;
|
|
78
|
-
try {
|
|
79
|
-
await resolve();
|
|
80
|
-
}
|
|
81
|
-
catch (resolveError) {
|
|
82
|
-
logger.debug("listSessions: id is neither a known hashed build nor a resolvable observability build", resolveError);
|
|
83
|
-
throw error;
|
|
84
|
-
}
|
|
85
|
-
sessions = await listSessionIds({ ...args, buildId }, config);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
const content = [
|
|
89
|
-
{
|
|
90
|
-
type: "text",
|
|
91
|
-
text: sessions.length === 0
|
|
92
|
-
? "No sessions found for this hashed build ID."
|
|
93
|
-
: JSON.stringify(sessions, null, 2),
|
|
94
|
-
},
|
|
95
|
-
];
|
|
96
|
-
if (resolvedNote) {
|
|
97
|
-
content.push({ type: "text", text: resolvedNote });
|
|
98
|
-
}
|
|
99
|
-
return { content };
|
|
100
|
-
}
|
|
101
|
-
catch (error) {
|
|
102
|
-
logger.error("Error listing session IDs", error);
|
|
103
|
-
throw error;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
51
|
//Registers the fetchAutomationScreenshots tool with the MCP server
|
|
107
52
|
export default function addAutomationTools(server, config) {
|
|
108
53
|
const tools = {};
|
|
109
|
-
tools.fetchAutomationScreenshots = server.tool("fetchAutomationScreenshots", "Fetch screenshots
|
|
54
|
+
tools.fetchAutomationScreenshots = server.tool("fetchAutomationScreenshots", "Fetch and process screenshots from a BrowserStack Automate session", {
|
|
110
55
|
sessionId: z
|
|
111
56
|
.string()
|
|
112
57
|
.describe("The BrowserStack session ID to fetch screenshots from"),
|
|
@@ -138,53 +83,5 @@ export default function addAutomationTools(server, config) {
|
|
|
138
83
|
};
|
|
139
84
|
}
|
|
140
85
|
});
|
|
141
|
-
tools.listSessions = server.tool("listSessions", "List sessions for a hashed Automate/App Automate build: session IDs, status, OS, browser/device, dashboard URL.", {
|
|
142
|
-
sessionType: z
|
|
143
|
-
.enum([SessionType.Automate, SessionType.AppAutomate])
|
|
144
|
-
.describe("Type of BrowserStack session"),
|
|
145
|
-
buildId: z
|
|
146
|
-
.string()
|
|
147
|
-
.describe("Hashed build id from the dashboard, or the observability build id from getBuildId."),
|
|
148
|
-
limit: z
|
|
149
|
-
.number()
|
|
150
|
-
.int()
|
|
151
|
-
.positive()
|
|
152
|
-
.optional()
|
|
153
|
-
.describe(`Max sessions to return. Defaults to ${DEFAULT_SESSION_LIST_LIMIT}.`),
|
|
154
|
-
offset: z
|
|
155
|
-
.number()
|
|
156
|
-
.int()
|
|
157
|
-
.min(0)
|
|
158
|
-
.optional()
|
|
159
|
-
.describe("Pagination offset for the REST session list."),
|
|
160
|
-
status: z
|
|
161
|
-
.string()
|
|
162
|
-
.optional()
|
|
163
|
-
.describe("Optional session status filter (e.g. done, running, error). Applied client-side."),
|
|
164
|
-
}, {
|
|
165
|
-
title: "List Sessions",
|
|
166
|
-
readOnlyHint: true,
|
|
167
|
-
openWorldHint: false,
|
|
168
|
-
destructiveHint: false,
|
|
169
|
-
idempotentHint: true,
|
|
170
|
-
}, async (args) => {
|
|
171
|
-
try {
|
|
172
|
-
trackMCP("listSessions", server.server.getClientVersion(), undefined, config);
|
|
173
|
-
return await listSessionIdsTool(args, config);
|
|
174
|
-
}
|
|
175
|
-
catch (error) {
|
|
176
|
-
trackMCP("listSessions", server.server.getClientVersion(), error, config);
|
|
177
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
178
|
-
return {
|
|
179
|
-
content: [
|
|
180
|
-
{
|
|
181
|
-
type: "text",
|
|
182
|
-
text: `Error listing session IDs: ${errorMessage}`,
|
|
183
|
-
},
|
|
184
|
-
],
|
|
185
|
-
isError: true,
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
86
|
return tools;
|
|
190
87
|
}
|
package/dist/tools/bstack-sdk.js
CHANGED
|
@@ -13,11 +13,11 @@ export function registerRunBrowserStackTestsTool(server, config) {
|
|
|
13
13
|
idempotentHint: true,
|
|
14
14
|
}, async (args) => {
|
|
15
15
|
try {
|
|
16
|
-
trackMCP("
|
|
16
|
+
trackMCP("runTestsOnBrowserStack", server.server.getClientVersion(), config);
|
|
17
17
|
return await runTestsOnBrowserStackHandler(args, config);
|
|
18
18
|
}
|
|
19
19
|
catch (error) {
|
|
20
|
-
return handleMCPError("
|
|
20
|
+
return handleMCPError("runTestsOnBrowserStack", server, config, error);
|
|
21
21
|
}
|
|
22
22
|
});
|
|
23
23
|
return tools;
|
|
@@ -2,7 +2,6 @@ import { z } from "zod";
|
|
|
2
2
|
import logger from "../logger.js";
|
|
3
3
|
import { fetchFromBrowserStackAPI, handleMCPError } from "../lib/utils.js";
|
|
4
4
|
import { trackMCP } from "../lib/instrumentation.js";
|
|
5
|
-
import { resolveHashedBuildId } from "./automate-utils/resolve-hashed-build-id.js";
|
|
6
5
|
// Tool function that fetches build insights from two APIs
|
|
7
6
|
export async function fetchBuildInsightsTool(args, config) {
|
|
8
7
|
try {
|
|
@@ -16,7 +15,6 @@ export async function fetchBuildInsightsTool(args, config) {
|
|
|
16
15
|
return null;
|
|
17
16
|
}),
|
|
18
17
|
]);
|
|
19
|
-
const { hashed_id, session_type } = await resolveInsightsHashedId(args.buildId, buildData, config);
|
|
20
18
|
// Select useful fields for users
|
|
21
19
|
const insights = {
|
|
22
20
|
name: buildData.name,
|
|
@@ -35,8 +33,6 @@ export async function fetchBuildInsightsTool(args, config) {
|
|
|
35
33
|
commit_sha: buildData.vcs_info?.sha,
|
|
36
34
|
vcs_name: buildData.vcs_info?.name,
|
|
37
35
|
quality_gate_result: qualityData?.quality_gate_result,
|
|
38
|
-
...(hashed_id ? { hashed_id } : {}),
|
|
39
|
-
...(session_type ? { session_type } : {}),
|
|
40
36
|
};
|
|
41
37
|
const qualityProfiles = qualityData?.quality_profiles?.map((profile) => ({
|
|
42
38
|
name: profile.name,
|
|
@@ -60,45 +56,10 @@ export async function fetchBuildInsightsTool(args, config) {
|
|
|
60
56
|
throw error;
|
|
61
57
|
}
|
|
62
58
|
}
|
|
63
|
-
/**
|
|
64
|
-
* The observability build payload does not carry the Automate hashed build id
|
|
65
|
-
* today. Prefer it if the API ever adds one; otherwise resolve it through any
|
|
66
|
-
* session of the build (two deterministic REST calls). Never blocks insights.
|
|
67
|
-
*/
|
|
68
|
-
async function resolveInsightsHashedId(observabilityBuildId, buildData, config) {
|
|
69
|
-
const direct = extractHashedBuildId(buildData);
|
|
70
|
-
if (direct) {
|
|
71
|
-
return { hashed_id: direct };
|
|
72
|
-
}
|
|
73
|
-
try {
|
|
74
|
-
const resolved = await resolveHashedBuildId(observabilityBuildId, config);
|
|
75
|
-
return {
|
|
76
|
-
hashed_id: resolved.hashedBuildId,
|
|
77
|
-
session_type: resolved.sessionType,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
catch (error) {
|
|
81
|
-
logger.warn("Could not resolve hashed build id for build insights", error);
|
|
82
|
-
return {};
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
function extractHashedBuildId(buildData) {
|
|
86
|
-
const candidates = [
|
|
87
|
-
buildData?.hashed_id,
|
|
88
|
-
buildData?.automate_hashed_id,
|
|
89
|
-
buildData?.hashedId,
|
|
90
|
-
];
|
|
91
|
-
for (const candidate of candidates) {
|
|
92
|
-
if (typeof candidate === "string" &&
|
|
93
|
-
/^[a-z0-9]{40}$/i.test(candidate.trim()))
|
|
94
|
-
return candidate.trim();
|
|
95
|
-
}
|
|
96
|
-
return undefined;
|
|
97
|
-
}
|
|
98
59
|
// Registers the fetchBuildInsights tool with the MCP server
|
|
99
60
|
export default function addBuildInsightsTools(server, config) {
|
|
100
61
|
const tools = {};
|
|
101
|
-
tools.fetchBuildInsights = server.tool("fetchBuildInsights", "
|
|
62
|
+
tools.fetchBuildInsights = server.tool("fetchBuildInsights", "Fetches insights about a BrowserStack build by combining build details and quality gate results.", {
|
|
102
63
|
buildId: z.string().describe("The build UUID of the BrowserStack build"),
|
|
103
64
|
}, {
|
|
104
65
|
title: "Fetch Build Insights",
|
|
@@ -108,7 +69,7 @@ export default function addBuildInsightsTools(server, config) {
|
|
|
108
69
|
idempotentHint: true,
|
|
109
70
|
}, async (args) => {
|
|
110
71
|
try {
|
|
111
|
-
trackMCP("fetchBuildInsights", server.server.getClientVersion(),
|
|
72
|
+
trackMCP("fetchBuildInsights", server.server.getClientVersion(), config);
|
|
112
73
|
return await fetchBuildInsightsTool(args, config);
|
|
113
74
|
}
|
|
114
75
|
catch (error) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn grouped caller arguments into a path, a query and a body.
|
|
3
|
+
*
|
|
4
|
+
* Arguments arrive GROUPED — {path_params, query, body} — because spec parameter names
|
|
5
|
+
* collide across locations: four tm operations declare one name in two places (`bulk-move`
|
|
6
|
+
* has `folder_id` as both a path parameter and a body field). A flat map cannot say which
|
|
7
|
+
* one is meant, which is exactly why the Python side used to rename body fields `body_*`.
|
|
8
|
+
* Grouping removes the collision AND the rename, so a caller sends the spec's own names.
|
|
9
|
+
*/
|
|
10
|
+
import { Capability, WireParam } from "./types.js";
|
|
11
|
+
export interface GroupedArguments {
|
|
12
|
+
path_params?: Record<string, unknown>;
|
|
13
|
+
query?: Record<string, unknown>;
|
|
14
|
+
body?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
export interface BoundRequest {
|
|
17
|
+
path: string;
|
|
18
|
+
query: Record<string, unknown>;
|
|
19
|
+
body?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Check one argument against its declared schema, raising a caller-safe error.
|
|
23
|
+
*
|
|
24
|
+
* Type checking is also the injection defence for path parameters: most of tm's 278 path
|
|
25
|
+
* parameters are `type: integer`, so a traversal attempt like `../../admin-v2` fails here
|
|
26
|
+
* rather than being encoded into a URL.
|
|
27
|
+
*/
|
|
28
|
+
export declare function coerce(value: unknown, param: WireParam): unknown;
|
|
29
|
+
export declare function bind(capability: Capability, args: GroupedArguments): BoundRequest;
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn grouped caller arguments into a path, a query and a body.
|
|
3
|
+
*
|
|
4
|
+
* Arguments arrive GROUPED — {path_params, query, body} — because spec parameter names
|
|
5
|
+
* collide across locations: four tm operations declare one name in two places (`bulk-move`
|
|
6
|
+
* has `folder_id` as both a path parameter and a body field). A flat map cannot say which
|
|
7
|
+
* one is meant, which is exactly why the Python side used to rename body fields `body_*`.
|
|
8
|
+
* Grouping removes the collision AND the rename, so a caller sends the spec's own names.
|
|
9
|
+
*/
|
|
10
|
+
import { InvocationError } from "./index-loader.js";
|
|
11
|
+
/**
|
|
12
|
+
* String formats worth ENFORCING, as opposed to merely publishing.
|
|
13
|
+
*
|
|
14
|
+
* A validator that rejects a correct value is worse than no validator: the caller cannot
|
|
15
|
+
* route around it, and the request that would have worked never leaves. So this covers only
|
|
16
|
+
* the formats where "wrong" is unambiguous and a legitimate value cannot trip it.
|
|
17
|
+
*
|
|
18
|
+
* tm declares eight formats. `date`, `date-time` and `uuid` are here. `email` and `uri` are
|
|
19
|
+
* deliberately NOT — every compact regex for either rejects addresses and URLs that servers
|
|
20
|
+
* accept, and being wrong in that direction blocks real work. `int64`, `float` and `binary`
|
|
21
|
+
* say what a number or blob is, which `type` already covers.
|
|
22
|
+
*/
|
|
23
|
+
const FORMATS = {
|
|
24
|
+
date: {
|
|
25
|
+
// A real calendar date, so 2026-02-31 fails rather than being reformatted.
|
|
26
|
+
test: (v) => /^\d{4}-\d{2}-\d{2}$/.test(v) &&
|
|
27
|
+
!Number.isNaN(Date.parse(`${v}T00:00:00Z`)) &&
|
|
28
|
+
new Date(`${v}T00:00:00Z`).toISOString().startsWith(v),
|
|
29
|
+
want: "a date (YYYY-MM-DD)",
|
|
30
|
+
},
|
|
31
|
+
"date-time": {
|
|
32
|
+
// Permissive on purpose: offset or Z, optional fractional seconds. Tightening this
|
|
33
|
+
// buys nothing and starts rejecting timestamps the product would have taken.
|
|
34
|
+
test: (v) => /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:?\d{2})?$/.test(v) && !Number.isNaN(Date.parse(v)),
|
|
35
|
+
want: "a date-time (e.g. 2026-01-31T09:30:00Z)",
|
|
36
|
+
},
|
|
37
|
+
uuid: {
|
|
38
|
+
test: (v) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v),
|
|
39
|
+
want: "a UUID",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* A declared `pattern`, compiled once, or undefined if it will not compile here.
|
|
44
|
+
*
|
|
45
|
+
* `u` because tm's patterns use `\p{L}`/`\p{N}`, which are a syntax error without it. A
|
|
46
|
+
* pattern this engine cannot compile is SKIPPED rather than thrown: the index is generated
|
|
47
|
+
* from someone else's spec, and refusing to invoke a working endpoint because its regex
|
|
48
|
+
* dialect differs would be our bug landing on the caller.
|
|
49
|
+
*/
|
|
50
|
+
const patternCache = new Map();
|
|
51
|
+
function compiled(pattern) {
|
|
52
|
+
if (!patternCache.has(pattern)) {
|
|
53
|
+
let regex;
|
|
54
|
+
try {
|
|
55
|
+
regex = new RegExp(pattern, "u");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
try {
|
|
59
|
+
regex = new RegExp(pattern);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
regex = undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
patternCache.set(pattern, regex);
|
|
66
|
+
}
|
|
67
|
+
return patternCache.get(pattern);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Every constraint the parameter declares, checked against the COERCED value.
|
|
71
|
+
*
|
|
72
|
+
* Runs inside `bind`, which register.ts calls dry before the write-consent gate — so a
|
|
73
|
+
* value that breaks a constraint is refused before the user is asked to approve anything,
|
|
74
|
+
* and nothing reaches egress.
|
|
75
|
+
*
|
|
76
|
+
* `label` carries the parent name for a nested field, so the message names `test_case.name`
|
|
77
|
+
* rather than a bare `name` the caller would have to go looking for.
|
|
78
|
+
*/
|
|
79
|
+
function checkConstraints(value, param, label = param.name) {
|
|
80
|
+
const fail = (what) => {
|
|
81
|
+
throw new InvocationError(`'${label}' ${what}`);
|
|
82
|
+
};
|
|
83
|
+
if (typeof value === "number") {
|
|
84
|
+
if (param.minimum !== undefined && value < param.minimum)
|
|
85
|
+
fail(`must be at least ${param.minimum}`);
|
|
86
|
+
if (param.maximum !== undefined && value > param.maximum)
|
|
87
|
+
fail(`must be at most ${param.maximum}`);
|
|
88
|
+
if (param.multipleOf !== undefined && param.multipleOf > 0) {
|
|
89
|
+
// Scale BOTH to integers before the modulo: 0.3 % 0.1 is 0.09999… in binary floating
|
|
90
|
+
// point and would reject a value the product accepts. The scale has to cover the
|
|
91
|
+
// value's decimals as well as the step's — scaling by the step alone rounds 0.25 to
|
|
92
|
+
// 3 against a step of 1, which then divides evenly and lets a bad value through.
|
|
93
|
+
const scale = 10 ** Math.max(decimals(param.multipleOf), decimals(value));
|
|
94
|
+
if (Math.round(value * scale) % Math.round(param.multipleOf * scale) !==
|
|
95
|
+
0)
|
|
96
|
+
fail(`must be a multiple of ${param.multipleOf}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (typeof value === "string") {
|
|
100
|
+
if (param.minLength !== undefined && value.length < param.minLength)
|
|
101
|
+
fail(`must be at least ${param.minLength} character(s)`);
|
|
102
|
+
if (param.maxLength !== undefined && value.length > param.maxLength)
|
|
103
|
+
fail(`must be at most ${param.maxLength} character(s)`);
|
|
104
|
+
if (param.pattern) {
|
|
105
|
+
const regex = compiled(param.pattern);
|
|
106
|
+
if (regex && !regex.test(value))
|
|
107
|
+
fail(`must match ${param.pattern}`);
|
|
108
|
+
}
|
|
109
|
+
const format = param.format ? FORMATS[param.format] : undefined;
|
|
110
|
+
if (format && !format.test(value))
|
|
111
|
+
fail(`must be ${format.want}`);
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(value)) {
|
|
114
|
+
if (param.minItems !== undefined && value.length < param.minItems)
|
|
115
|
+
fail(`must have at least ${param.minItems} item(s)`);
|
|
116
|
+
if (param.maxItems !== undefined && value.length > param.maxItems)
|
|
117
|
+
fail(`must have at most ${param.maxItems} item(s)`);
|
|
118
|
+
if (param.uniqueItems) {
|
|
119
|
+
const seen = new Set(value.map((item) => JSON.stringify(item)));
|
|
120
|
+
if (seen.size !== value.length)
|
|
121
|
+
fail("must not contain duplicate items");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Decimal places, for scaling `multipleOf` out of floating point. */
|
|
126
|
+
function decimals(n) {
|
|
127
|
+
const text = String(n);
|
|
128
|
+
const dot = text.indexOf(".");
|
|
129
|
+
return dot < 0 ? 0 : text.length - dot - 1;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The declared fields one level inside an object, or inside each item of an array.
|
|
133
|
+
*
|
|
134
|
+
* Only what `fields` names is checked, and an undeclared key is left alone — `fields` is a
|
|
135
|
+
* projection of the shape, not a closed contract, so rejecting the rest would refuse valid
|
|
136
|
+
* bodies. That is the opposite of the top level, where the full parameter list IS known and
|
|
137
|
+
* an unknown name is an error.
|
|
138
|
+
*/
|
|
139
|
+
function checkFields(value, param) {
|
|
140
|
+
if (!param.fields?.length)
|
|
141
|
+
return;
|
|
142
|
+
const items = Array.isArray(value) ? value : [value];
|
|
143
|
+
for (const item of items) {
|
|
144
|
+
if (typeof item !== "object" || item === null || Array.isArray(item))
|
|
145
|
+
continue;
|
|
146
|
+
const record = item;
|
|
147
|
+
for (const field of param.fields) {
|
|
148
|
+
if (!(field.name in record)) {
|
|
149
|
+
if (field.required)
|
|
150
|
+
throw new InvocationError(`missing required parameter(s): ${param.name}.${field.name}`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const label = `${param.name}.${field.name}`;
|
|
154
|
+
const coerced = coerceType(record[field.name], field, label);
|
|
155
|
+
checkConstraints(coerced, field, label);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Check one argument against its declared schema, raising a caller-safe error.
|
|
161
|
+
*
|
|
162
|
+
* Type checking is also the injection defence for path parameters: most of tm's 278 path
|
|
163
|
+
* parameters are `type: integer`, so a traversal attempt like `../../admin-v2` fails here
|
|
164
|
+
* rather than being encoded into a URL.
|
|
165
|
+
*/
|
|
166
|
+
export function coerce(value, param) {
|
|
167
|
+
const coerced = coerceType(value, param);
|
|
168
|
+
checkConstraints(coerced, param);
|
|
169
|
+
checkFields(coerced, param);
|
|
170
|
+
return coerced;
|
|
171
|
+
}
|
|
172
|
+
function coerceType(value, param, label = param.name) {
|
|
173
|
+
const expected = param.type;
|
|
174
|
+
if (expected === "object" || expected === "array") {
|
|
175
|
+
// An opaque body object is passed through as given: the spec does not describe its
|
|
176
|
+
// fields, so validating or reshaping it would mean inventing a contract.
|
|
177
|
+
if (expected === "object" &&
|
|
178
|
+
(typeof value !== "object" || value === null || Array.isArray(value))) {
|
|
179
|
+
throw new InvocationError(`'${label}' must be an object`);
|
|
180
|
+
}
|
|
181
|
+
if (expected === "array" && !Array.isArray(value)) {
|
|
182
|
+
throw new InvocationError(`'${label}' must be a list`);
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
if (expected === "integer" || expected === "number") {
|
|
187
|
+
const parsed = Number(String(value).trim());
|
|
188
|
+
if (!Number.isFinite(parsed)) {
|
|
189
|
+
throw new InvocationError(`'${label}' must be a number`);
|
|
190
|
+
}
|
|
191
|
+
return expected === "integer" ? Math.trunc(parsed) : parsed;
|
|
192
|
+
}
|
|
193
|
+
if (expected === "boolean") {
|
|
194
|
+
if (typeof value === "boolean")
|
|
195
|
+
return value;
|
|
196
|
+
const text = String(value).trim().toLowerCase();
|
|
197
|
+
if (["true", "1", "yes"].includes(text))
|
|
198
|
+
return true;
|
|
199
|
+
if (["false", "0", "no"].includes(text))
|
|
200
|
+
return false;
|
|
201
|
+
throw new InvocationError(`'${label}' must be true or false`);
|
|
202
|
+
}
|
|
203
|
+
// NO DECLARED TYPE: pass the value through as given.
|
|
204
|
+
//
|
|
205
|
+
// The fallthrough below is String(value), which is right for a declared string and
|
|
206
|
+
// silently destructive for anything the spec left untyped. create_report_v2 declares
|
|
207
|
+
// `mail_to` and `report_filters` with no type and the API wants objects for both, so a
|
|
208
|
+
// correct payload was serialised to the literal "[object Object]" and refused:
|
|
209
|
+
//
|
|
210
|
+
// The property '#/mail_to' of type string did not match the following type: object
|
|
211
|
+
//
|
|
212
|
+
// The caller sent the right thing, this layer broke it, and the server's message blames
|
|
213
|
+
// the caller for a string it never wrote. An untyped parameter means the spec declines to
|
|
214
|
+
// say what shape it is — which is a reason to leave it alone, not to guess "string".
|
|
215
|
+
const untyped = !expected;
|
|
216
|
+
if (untyped && (typeof value === "object" || typeof value === "boolean")) {
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
const text = String(value);
|
|
220
|
+
if (param.values && param.values.length > 0) {
|
|
221
|
+
const allowed = param.values.map((v) => String(v));
|
|
222
|
+
if (!allowed.includes(text)) {
|
|
223
|
+
throw new InvocationError(`'${label}' must be one of: ${allowed.join(", ")}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return text;
|
|
227
|
+
}
|
|
228
|
+
/** Place a value at a JSON-pointer-ish path, creating the objects on the way. */
|
|
229
|
+
function place(root, pointer, value) {
|
|
230
|
+
const segments = pointer.split("/").filter((segment) => segment !== "");
|
|
231
|
+
let cursor = root;
|
|
232
|
+
for (const segment of segments.slice(0, -1)) {
|
|
233
|
+
const next = cursor[segment];
|
|
234
|
+
if (typeof next !== "object" || next === null || Array.isArray(next)) {
|
|
235
|
+
cursor[segment] = {};
|
|
236
|
+
}
|
|
237
|
+
cursor = cursor[segment];
|
|
238
|
+
}
|
|
239
|
+
cursor[segments[segments.length - 1]] = value;
|
|
240
|
+
}
|
|
241
|
+
const GROUPS = [
|
|
242
|
+
{ group: "path_params", declared: "path_params" },
|
|
243
|
+
{ group: "query", declared: "query" },
|
|
244
|
+
{ group: "body", declared: "body" },
|
|
245
|
+
];
|
|
246
|
+
export function bind(capability, args) {
|
|
247
|
+
let path = capability.path;
|
|
248
|
+
const query = {};
|
|
249
|
+
const body = {};
|
|
250
|
+
for (const { group, declared } of GROUPS) {
|
|
251
|
+
const supplied = args[group] || {};
|
|
252
|
+
if (typeof supplied !== "object" ||
|
|
253
|
+
supplied === null ||
|
|
254
|
+
Array.isArray(supplied)) {
|
|
255
|
+
throw new InvocationError(`${group} must be an object of name -> value`);
|
|
256
|
+
}
|
|
257
|
+
const params = capability[declared] || [];
|
|
258
|
+
const byName = new Map(params.map((param) => [param.name, param]));
|
|
259
|
+
// Unknown arguments are an error rather than being dropped: silently ignoring a
|
|
260
|
+
// misspelled filter would return a larger result set that looks like a correct answer.
|
|
261
|
+
const unknown = Object.keys(supplied).filter((name) => !byName.has(name));
|
|
262
|
+
if (unknown.length > 0) {
|
|
263
|
+
// A CALLER WHO SENT THE WRAPPER WE BUILD DESERVES TO BE TOLD SO.
|
|
264
|
+
//
|
|
265
|
+
// Most of these endpoints want a nested body — Rails `params.require(:test_case)` —
|
|
266
|
+
// and the fields declare that as `json_path: /test_case/name`, so this layer
|
|
267
|
+
// assembles the wrapper itself and the caller sends the fields flat. A caller who
|
|
268
|
+
// wraps it by hand therefore sends a key that is never a declared param, and the
|
|
269
|
+
// bare message reads as "that field does not exist" when the truth is the opposite:
|
|
270
|
+
// it exists, and building it is our job.
|
|
271
|
+
//
|
|
272
|
+
// Worth special-casing because the guidance itself has been telling callers to wrap:
|
|
273
|
+
// 25 tm capabilities carried a line describing the WIRE format to a caller who never
|
|
274
|
+
// writes the wire. Those lines are being corrected, but an agent working from an
|
|
275
|
+
// older artifact, or reasoning from the Rails convention, lands here either way.
|
|
276
|
+
const wrappers = new Set(params
|
|
277
|
+
.map((param) => param.json_path)
|
|
278
|
+
.filter((path) => Boolean(path))
|
|
279
|
+
.map((path) => path.replace(/^\//, "").split("/")[0])
|
|
280
|
+
.filter((segment) => segment && !byName.has(segment)));
|
|
281
|
+
const sentAWrapper = unknown.filter((name) => wrappers.has(name));
|
|
282
|
+
throw new InvocationError(`unknown ${group}: ${unknown.sort().join(", ")}. accepted: ` +
|
|
283
|
+
`${[...byName.keys()].sort().join(", ") || "none"}` +
|
|
284
|
+
(sentAWrapper.length > 0
|
|
285
|
+
? `. ${sentAWrapper.sort().join(" and ")} ` +
|
|
286
|
+
`${sentAWrapper.length > 1 ? "are wrappers" : "is a wrapper"} this ` +
|
|
287
|
+
`surface builds for you from each field's json_path — send the fields ` +
|
|
288
|
+
`directly instead of nesting them`
|
|
289
|
+
: ""));
|
|
290
|
+
}
|
|
291
|
+
for (const [name, raw] of Object.entries(supplied)) {
|
|
292
|
+
const param = byName.get(name);
|
|
293
|
+
const value = coerce(raw, param);
|
|
294
|
+
if (group === "path_params") {
|
|
295
|
+
// Encode with nothing exempt: a `/` inside a path value would otherwise rewrite the
|
|
296
|
+
// route. Schema checking already stops this for integer ids; this covers strings.
|
|
297
|
+
path = path.replaceAll(`{${name}}`, encodeURIComponent(String(value)));
|
|
298
|
+
}
|
|
299
|
+
else if (group === "body") {
|
|
300
|
+
place(body, param.json_path || `/${name}`, value);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
query[name] = value;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// `required` is enforced for BODY as well as path. It was path-only on the Python side at
|
|
308
|
+
// first, so a missing required body field passed silently and the product answered with a
|
|
309
|
+
// 4xx that read like the caller's fault.
|
|
310
|
+
const missing = [];
|
|
311
|
+
for (const { group, declared } of GROUPS) {
|
|
312
|
+
if (group === "query")
|
|
313
|
+
continue;
|
|
314
|
+
const supplied = args[group] || {};
|
|
315
|
+
for (const param of capability[declared] ||
|
|
316
|
+
[]) {
|
|
317
|
+
if (param.required && !(param.name in supplied))
|
|
318
|
+
missing.push(param.name);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (missing.length > 0) {
|
|
322
|
+
throw new InvocationError(`missing required parameter(s): ${missing.sort().join(", ")}`);
|
|
323
|
+
}
|
|
324
|
+
const leftover = path.match(/\{[a-z_]+\}/gi);
|
|
325
|
+
if (leftover) {
|
|
326
|
+
throw new InvocationError(`path placeholder(s) not supplied: ${leftover.join(", ")}`);
|
|
327
|
+
}
|
|
328
|
+
return { path, query, body: Object.keys(body).length > 0 ? body : undefined };
|
|
329
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the index comes from, and where each product lives.
|
|
3
|
+
*
|
|
4
|
+
* A product may DECLARE its host in its own index file (`<product>.base_url`, authored in
|
|
5
|
+
* the harness's product.yaml and carried through by the export). That is the default, not
|
|
6
|
+
* the last word: config overrides it, and for a region-sharded product account discovery
|
|
7
|
+
* outranks it — see `resolveBaseUrl`. The same artifact therefore still ships to every
|
|
8
|
+
* environment, because anything environment- or account-specific is resolved here.
|
|
9
|
+
*/
|
|
10
|
+
import { BrowserStackConfig } from "../../lib/types.js";
|
|
11
|
+
import { HostSource } from "./discovery.js";
|
|
12
|
+
import { Transport } from "./egress.js";
|
|
13
|
+
/**
|
|
14
|
+
* The environment this DEPLOYMENT points at, e.g. "preprod".
|
|
15
|
+
*
|
|
16
|
+
* Process-level on purpose, and the distinction from region matters: an environment is a
|
|
17
|
+
* property of the deployment (this instance talks to preprod), whereas a REGION is a
|
|
18
|
+
* property of the account (this user's data lives in EU). That is why region discovery is
|
|
19
|
+
* per request and never cached under REMOTE_MCP, while the environment is read once here.
|
|
20
|
+
*/
|
|
21
|
+
export declare function selectedEnvironment(): string;
|
|
22
|
+
/** True when this product's host is account-specific and must be discovered per call. */
|
|
23
|
+
export declare function isRegionSharded(product: string, source?: HostSource): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a product's host.
|
|
26
|
+
*
|
|
27
|
+
* Atlas resolves: an explicit per-session override, then the host for the session's
|
|
28
|
+
* environment (`harness.extra_environments[env][product]`), then the profile's own
|
|
29
|
+
* `base_url`. The same rungs, in the same order, with one addition:
|
|
30
|
+
*
|
|
31
|
+
* 1. CAPABILITY_REGISTRY_BASE_URL_<PRODUCT> explicit, environment-agnostic
|
|
32
|
+
* 2. CAPABILITY_REGISTRY_BASE_URL_<PRODUCT>_<ENV> this environment's host
|
|
33
|
+
* 3. CAPABILITY_REGISTRY_BASE_URLS {product:{env:url}} the same, as one map
|
|
34
|
+
* 4. account discovery — the index's `base_urls`, probed
|
|
35
|
+
* 5. the single host declared in the index, `base_url`
|
|
36
|
+
* 6. refuse, by name
|
|
37
|
+
*
|
|
38
|
+
* DISCOVERY OUTRANKS THE DECLARED HOST, which is where this departs from Atlas. A declared
|
|
39
|
+
* host is one fixed origin, so for a region-sharded product it would send every account
|
|
40
|
+
* outside the default region to the wrong host — a failure that only shows up on those
|
|
41
|
+
* accounts, and so passes any test run from inside the default one. Discovery answers with
|
|
42
|
+
* the region the account is actually on; `base_url` is the fallback for when the probe
|
|
43
|
+
* cannot answer at all.
|
|
44
|
+
*
|
|
45
|
+
* Refusing rather than guessing is deliberate: a guessed host fails as a DNS error or a 404
|
|
46
|
+
* that reads like the caller's problem, when it is our missing configuration.
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveBaseUrl(product: string, config: BrowserStackConfig, source?: HostSource, transport?: Transport): Promise<string>;
|
|
49
|
+
/**
|
|
50
|
+
* Locate the artifact(s).
|
|
51
|
+
*
|
|
52
|
+
* ONE FILE PER PRODUCT, so this returns a list. Explicit env wins; otherwise look for a
|
|
53
|
+
* `capability/` directory beside the compiled module and at the package root, then fall
|
|
54
|
+
* back to a single `capability-index.json` for the pre-release layout. The package-root
|
|
55
|
+
* candidates exist because `tsc` compiles TS and does not copy JSON into `dist`.
|
|
56
|
+
*
|
|
57
|
+
* Returns an empty list when nothing is found — the caller logs that and registers no
|
|
58
|
+
* tools, rather than throwing.
|
|
59
|
+
*/
|
|
60
|
+
export declare function indexPaths(): string[];
|
|
61
|
+
/** Off by default is wrong for a shipped feature, but a kill switch is not. */
|
|
62
|
+
export declare function isEnabled(): boolean;
|