@browserstack/mcp-server 2.2.2 → 2.2.3-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/dist/config.d.ts +4 -1
- package/dist/config.js +15 -2
- package/dist/lib/api.js +3 -1
- package/dist/lib/error.d.ts +10 -0
- package/dist/lib/error.js +64 -12
- package/dist/lib/regional-host.d.ts +40 -0
- package/dist/lib/regional-host.js +51 -0
- package/dist/lib/tm-base-url.js +5 -0
- package/dist/lib/utils.js +13 -2
- package/dist/tools/accessiblity-utils/accessibility-rag.js +2 -1
- package/dist/tools/accessiblity-utils/auth-config.js +4 -3
- package/dist/tools/accessiblity-utils/report-fetcher.js +3 -2
- package/dist/tools/accessiblity-utils/scanner.js +3 -2
- package/dist/tools/appautomate-utils/native-execution/appautomate.js +8 -7
- package/dist/tools/appautomate.js +2 -1
- package/dist/tools/automate-utils/fetch-screenshots.d.ts +6 -2
- package/dist/tools/automate-utils/fetch-screenshots.js +92 -20
- package/dist/tools/automate-utils/list-session-ids.d.ts +1 -1
- package/dist/tools/automate-utils/list-session-ids.js +7 -4
- package/dist/tools/automate-utils/resolve-hashed-build-id.d.ts +1 -1
- package/dist/tools/automate-utils/resolve-hashed-build-id.js +6 -6
- package/dist/tools/automate.js +2 -2
- package/dist/tools/build-insights.js +4 -2
- package/dist/tools/failurelogs-utils/app-automate.js +4 -3
- package/dist/tools/failurelogs-utils/automate.js +4 -3
- package/dist/tools/failurelogs-utils/video.js +3 -1
- package/dist/tools/rca-agent-utils/get-build-id.js +3 -1
- package/dist/tools/rca-agent-utils/get-failed-test-id.d.ts +1 -1
- package/dist/tools/rca-agent-utils/get-failed-test-id.js +2 -2
- package/dist/tools/rca-agent-utils/list-build-id.js +3 -1
- package/dist/tools/rca-agent-utils/rca-data.d.ts +1 -1
- package/dist/tools/rca-agent-utils/rca-data.js +2 -2
- package/dist/tools/rca-agent.js +3 -2
- package/dist/tools/selfheal-utils/fetch-test-code.js +2 -2
- package/dist/tools/selfheal-utils/selfheal.js +4 -3
- package/dist/tools/testmanagement-utils/update-testcase.js +5 -3
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
declare const GRR_REGIONS: readonly ["us", "eu", "in"];
|
|
2
|
+
export type GrrRegion = (typeof GRR_REGIONS)[number];
|
|
1
3
|
/**
|
|
2
4
|
* USE_OWN_LOCAL_BINARY_PROCESS:
|
|
3
5
|
* If true, the system will not start a new local binary process, but will use the user's own process.
|
|
@@ -26,6 +28,7 @@ export declare class Config {
|
|
|
26
28
|
* "deferred" | "elicit". Anything else is ignored.
|
|
27
29
|
*/
|
|
28
30
|
readonly ASK_BROWSERSTACK_RELAY_TRANSPORT: string | undefined;
|
|
31
|
+
readonly BROWSERSTACK_GRR_REGION: GrrRegion;
|
|
29
32
|
constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean, UPLOAD_BASE_DIR: string | undefined, O11Y_TFA_RCA_BASE_URL: string, BROWSERSTACK_AUTOMATION_BASE_URL: string, BROWSERSTACK_O11Y_UI_BASE_URL: string, ASK_BROWSERSTACK_ATLAS_URL: string | undefined, ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined,
|
|
30
33
|
/**
|
|
31
34
|
* Which relay transport to use, overriding what the deployment implies.
|
|
@@ -39,7 +42,7 @@ export declare class Config {
|
|
|
39
42
|
*
|
|
40
43
|
* "deferred" | "elicit". Anything else is ignored.
|
|
41
44
|
*/
|
|
42
|
-
ASK_BROWSERSTACK_RELAY_TRANSPORT: string | undefined);
|
|
45
|
+
ASK_BROWSERSTACK_RELAY_TRANSPORT: string | undefined, BROWSERSTACK_GRR_REGION?: GrrRegion);
|
|
43
46
|
}
|
|
44
47
|
declare const config: Config;
|
|
45
48
|
export default config;
|
package/dist/config.js
CHANGED
|
@@ -34,6 +34,17 @@ const DEFAULT_O11Y_TFA_RCA_BASE_URL = "https://api-automation.browserstack.com";
|
|
|
34
34
|
const DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL = "https://api-automation.browserstack.com";
|
|
35
35
|
// Overridable via BROWSERSTACK_O11Y_UI_BASE_URL to point at a staging UI.
|
|
36
36
|
const DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL = "https://automation.browserstack.com";
|
|
37
|
+
// BROWSERSTACK_GRR_REGION: "us" (default), "eu" or "in". Invalid values fail at startup.
|
|
38
|
+
const GRR_REGIONS = ["us", "eu", "in"];
|
|
39
|
+
function parseGrrRegion(raw) {
|
|
40
|
+
const value = (raw || "").trim().toLowerCase();
|
|
41
|
+
if (!value)
|
|
42
|
+
return "us";
|
|
43
|
+
if (GRR_REGIONS.includes(value)) {
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`Invalid BROWSERSTACK_GRR_REGION "${raw}". Use one of: ${GRR_REGIONS.join(", ")}.`);
|
|
47
|
+
}
|
|
37
48
|
/**
|
|
38
49
|
* USE_OWN_LOCAL_BINARY_PROCESS:
|
|
39
50
|
* If true, the system will not start a new local binary process, but will use the user's own process.
|
|
@@ -50,6 +61,7 @@ export class Config {
|
|
|
50
61
|
ASK_BROWSERSTACK_ATLAS_URL;
|
|
51
62
|
ASK_BROWSERSTACK_AUTH_TOKEN_URL;
|
|
52
63
|
ASK_BROWSERSTACK_RELAY_TRANSPORT;
|
|
64
|
+
BROWSERSTACK_GRR_REGION;
|
|
53
65
|
constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR, O11Y_TFA_RCA_BASE_URL, BROWSERSTACK_AUTOMATION_BASE_URL, BROWSERSTACK_O11Y_UI_BASE_URL,
|
|
54
66
|
// askBrowserStackAI's process-startup settings. Declared here rather than read from
|
|
55
67
|
// process.env inside src/tools/, per rules/tool-design.md — and so the remote wrapper,
|
|
@@ -71,7 +83,7 @@ export class Config {
|
|
|
71
83
|
*
|
|
72
84
|
* "deferred" | "elicit". Anything else is ignored.
|
|
73
85
|
*/
|
|
74
|
-
ASK_BROWSERSTACK_RELAY_TRANSPORT) {
|
|
86
|
+
ASK_BROWSERSTACK_RELAY_TRANSPORT, BROWSERSTACK_GRR_REGION = "us") {
|
|
75
87
|
this.DEV_MODE = DEV_MODE;
|
|
76
88
|
this.browserstackLocalOptions = browserstackLocalOptions;
|
|
77
89
|
this.USE_OWN_LOCAL_BINARY_PROCESS = USE_OWN_LOCAL_BINARY_PROCESS;
|
|
@@ -83,6 +95,7 @@ export class Config {
|
|
|
83
95
|
this.ASK_BROWSERSTACK_ATLAS_URL = ASK_BROWSERSTACK_ATLAS_URL;
|
|
84
96
|
this.ASK_BROWSERSTACK_AUTH_TOKEN_URL = ASK_BROWSERSTACK_AUTH_TOKEN_URL;
|
|
85
97
|
this.ASK_BROWSERSTACK_RELAY_TRANSPORT = ASK_BROWSERSTACK_RELAY_TRANSPORT;
|
|
98
|
+
this.BROWSERSTACK_GRR_REGION = BROWSERSTACK_GRR_REGION;
|
|
86
99
|
}
|
|
87
100
|
}
|
|
88
101
|
const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOptions, process.env.USE_OWN_LOCAL_BINARY_PROCESS === "true", process.env.REMOTE_MCP === "true", process.env.MCP_UPLOAD_BASE_DIR && process.env.MCP_UPLOAD_BASE_DIR.length > 0
|
|
@@ -103,5 +116,5 @@ const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOpti
|
|
|
103
116
|
process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL.trim().length > 0
|
|
104
117
|
? process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL
|
|
105
118
|
: undefined, (process.env.ASK_BROWSERSTACK_RELAY_TRANSPORT || "").trim().toLowerCase() ||
|
|
106
|
-
undefined);
|
|
119
|
+
undefined, parseGrrRegion(process.env.BROWSERSTACK_GRR_REGION));
|
|
107
120
|
export default config;
|
package/dist/lib/api.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { getBrowserStackAuth } from "./get-auth.js";
|
|
2
2
|
import { apiClient } from "./apiClient.js";
|
|
3
|
+
import { getRegionalHost } from "./regional-host.js";
|
|
3
4
|
export async function getLatestO11YBuildInfo(buildName, projectName, config) {
|
|
4
|
-
const
|
|
5
|
+
const host = getRegionalHost("observabilityApi");
|
|
6
|
+
const buildsUrl = `${host}/ext/v1/builds/latest?build_name=${encodeURIComponent(buildName)}&project_name=${encodeURIComponent(projectName)}`;
|
|
5
7
|
const authString = getBrowserStackAuth(config);
|
|
6
8
|
const auth = Buffer.from(authString).toString("base64");
|
|
7
9
|
const buildsResponse = await apiClient.get({
|
package/dist/lib/error.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Returns the most specific human-readable text for an error raised by an
|
|
4
|
+
* API call, capped at 500 characters. For an AxiosError with a response body
|
|
5
|
+
* it prefers the body's `message`, `error` or `errors` field, serialising
|
|
6
|
+
* objects, and uses the whole body when none of those fields exist. An empty,
|
|
7
|
+
* whitespace-only or HTML-document body falls back to the error's own message.
|
|
8
|
+
* @param err - The error object to describe
|
|
9
|
+
* @param defaultText - The fallback error message
|
|
10
|
+
*/
|
|
11
|
+
export declare function errorText(err: unknown, defaultText: string): string;
|
|
2
12
|
/**
|
|
3
13
|
* Formats an AxiosError into a CallToolResult with an appropriate message.
|
|
4
14
|
* @param err - The error object to format
|
package/dist/lib/error.js
CHANGED
|
@@ -1,23 +1,75 @@
|
|
|
1
1
|
import { AxiosError } from "axios";
|
|
2
|
+
const MAX_ERROR_TEXT = 500;
|
|
3
|
+
const BODY_FIELDS = ["message", "error", "errors"];
|
|
4
|
+
function clip(text) {
|
|
5
|
+
return text.length > MAX_ERROR_TEXT ? text.slice(0, MAX_ERROR_TEXT) : text;
|
|
6
|
+
}
|
|
7
|
+
const HTML_DOCUMENT = /^\s*<(!doctype|html)\b/i;
|
|
8
|
+
function stringify(value) {
|
|
9
|
+
if (typeof value === "string") {
|
|
10
|
+
return HTML_DOCUMENT.test(value) ? "" : value.trim();
|
|
11
|
+
}
|
|
12
|
+
if (value === undefined || value === null)
|
|
13
|
+
return "";
|
|
14
|
+
try {
|
|
15
|
+
const json = JSON.stringify(value);
|
|
16
|
+
return json === "{}" || json === "[]" ? "" : (json ?? "");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Text taken from an API error body: the first non-empty `message`, `error`
|
|
24
|
+
* or `errors` field when any of them is present, otherwise the whole body.
|
|
25
|
+
* Returns undefined when the body carries no usable text.
|
|
26
|
+
*/
|
|
27
|
+
function bodyText(data) {
|
|
28
|
+
if (typeof data !== "object" || data === null) {
|
|
29
|
+
const text = stringify(data);
|
|
30
|
+
return text ? clip(text) : undefined;
|
|
31
|
+
}
|
|
32
|
+
const record = data;
|
|
33
|
+
const named = BODY_FIELDS.filter((key) => key in record);
|
|
34
|
+
if (named.length > 0) {
|
|
35
|
+
for (const key of named) {
|
|
36
|
+
const text = stringify(record[key]);
|
|
37
|
+
if (text)
|
|
38
|
+
return clip(text);
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const text = stringify(record);
|
|
43
|
+
return text ? clip(text) : undefined;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns the most specific human-readable text for an error raised by an
|
|
47
|
+
* API call, capped at 500 characters. For an AxiosError with a response body
|
|
48
|
+
* it prefers the body's `message`, `error` or `errors` field, serialising
|
|
49
|
+
* objects, and uses the whole body when none of those fields exist. An empty,
|
|
50
|
+
* whitespace-only or HTML-document body falls back to the error's own message.
|
|
51
|
+
* @param err - The error object to describe
|
|
52
|
+
* @param defaultText - The fallback error message
|
|
53
|
+
*/
|
|
54
|
+
export function errorText(err, defaultText) {
|
|
55
|
+
if (err instanceof AxiosError) {
|
|
56
|
+
const text = bodyText(err.response?.data);
|
|
57
|
+
if (text)
|
|
58
|
+
return text;
|
|
59
|
+
return clip(err.message || defaultText);
|
|
60
|
+
}
|
|
61
|
+
if (err instanceof Error)
|
|
62
|
+
return clip(err.message || defaultText);
|
|
63
|
+
return defaultText;
|
|
64
|
+
}
|
|
2
65
|
/**
|
|
3
66
|
* Formats an AxiosError into a CallToolResult with an appropriate message.
|
|
4
67
|
* @param err - The error object to format
|
|
5
68
|
* @param defaultText - The fallback error message
|
|
6
69
|
*/
|
|
7
70
|
export function formatAxiosError(err, defaultText) {
|
|
8
|
-
let text = defaultText;
|
|
9
|
-
if (err instanceof AxiosError && err.response?.data) {
|
|
10
|
-
const message = err.response.data.message ||
|
|
11
|
-
err.response.data.error ||
|
|
12
|
-
err.message ||
|
|
13
|
-
defaultText;
|
|
14
|
-
text = message;
|
|
15
|
-
}
|
|
16
|
-
else if (err instanceof Error) {
|
|
17
|
-
text = err.message;
|
|
18
|
-
}
|
|
19
71
|
return {
|
|
20
|
-
content: [{ type: "text", text }],
|
|
72
|
+
content: [{ type: "text", text: errorText(err, defaultText) }],
|
|
21
73
|
isError: true,
|
|
22
74
|
};
|
|
23
75
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { GrrRegion } from "../config.js";
|
|
2
|
+
type Hosts = Record<GrrRegion, string>;
|
|
3
|
+
declare const HOSTS: {
|
|
4
|
+
readonly automateApi: Hosts;
|
|
5
|
+
readonly appAutomateApi: Hosts;
|
|
6
|
+
readonly appAutomateUpload: {
|
|
7
|
+
readonly us: "https://api-cloud.browserstack.com";
|
|
8
|
+
readonly eu: string;
|
|
9
|
+
readonly in: string;
|
|
10
|
+
};
|
|
11
|
+
readonly appAutomateHub: {
|
|
12
|
+
readonly us: "hub.browserstack.com";
|
|
13
|
+
readonly eu: "hub-eu-only.browserstack.com";
|
|
14
|
+
readonly in: "hub-aps-only.browserstack.com";
|
|
15
|
+
};
|
|
16
|
+
readonly accessibilityApi: {
|
|
17
|
+
readonly us: "https://api-accessibility.browserstack.com";
|
|
18
|
+
readonly eu: "https://api-accessibility-eu.browserstack.com";
|
|
19
|
+
readonly in: "https://api-accessibility-in.browserstack.com";
|
|
20
|
+
};
|
|
21
|
+
readonly accessibilityDashboard: {
|
|
22
|
+
readonly us: "https://accessibility.browserstack.com";
|
|
23
|
+
readonly eu: "https://accessibility-eu.browserstack.com";
|
|
24
|
+
readonly in: "https://accessibility-in.browserstack.com";
|
|
25
|
+
};
|
|
26
|
+
readonly observabilityApi: {
|
|
27
|
+
readonly us: "https://api-observability.browserstack.com";
|
|
28
|
+
readonly eu: "https://api-observability-eu.browserstack.com";
|
|
29
|
+
readonly in: "https://api-observability-in.browserstack.com";
|
|
30
|
+
};
|
|
31
|
+
readonly automationApi: {
|
|
32
|
+
readonly us: "https://api-automation.browserstack.com";
|
|
33
|
+
readonly eu: "https://api-automation-eu.browserstack.com";
|
|
34
|
+
readonly in: "https://api-automation-in.browserstack.com";
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
export type RegionalHostKind = keyof typeof HOSTS;
|
|
38
|
+
export declare function hostFor(kind: RegionalHostKind, region: GrrRegion): string;
|
|
39
|
+
export declare function getRegionalHost(kind: RegionalHostKind): string;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import appConfig from "../config.js";
|
|
2
|
+
const AUTOMATE_API = {
|
|
3
|
+
us: "https://api.browserstack.com",
|
|
4
|
+
eu: "https://api-eu-only.browserstack.com",
|
|
5
|
+
in: "https://api-aps-only.browserstack.com",
|
|
6
|
+
};
|
|
7
|
+
const HOSTS = {
|
|
8
|
+
automateApi: AUTOMATE_API,
|
|
9
|
+
appAutomateApi: AUTOMATE_API,
|
|
10
|
+
appAutomateUpload: {
|
|
11
|
+
us: "https://api-cloud.browserstack.com",
|
|
12
|
+
eu: AUTOMATE_API.eu,
|
|
13
|
+
in: AUTOMATE_API.in,
|
|
14
|
+
},
|
|
15
|
+
appAutomateHub: {
|
|
16
|
+
us: "hub.browserstack.com",
|
|
17
|
+
eu: "hub-eu-only.browserstack.com",
|
|
18
|
+
in: "hub-aps-only.browserstack.com",
|
|
19
|
+
},
|
|
20
|
+
accessibilityApi: {
|
|
21
|
+
us: "https://api-accessibility.browserstack.com",
|
|
22
|
+
eu: "https://api-accessibility-eu.browserstack.com",
|
|
23
|
+
in: "https://api-accessibility-in.browserstack.com",
|
|
24
|
+
},
|
|
25
|
+
accessibilityDashboard: {
|
|
26
|
+
us: "https://accessibility.browserstack.com",
|
|
27
|
+
eu: "https://accessibility-eu.browserstack.com",
|
|
28
|
+
in: "https://accessibility-in.browserstack.com",
|
|
29
|
+
},
|
|
30
|
+
observabilityApi: {
|
|
31
|
+
us: "https://api-observability.browserstack.com",
|
|
32
|
+
eu: "https://api-observability-eu.browserstack.com",
|
|
33
|
+
in: "https://api-observability-in.browserstack.com",
|
|
34
|
+
},
|
|
35
|
+
automationApi: {
|
|
36
|
+
us: "https://api-automation.browserstack.com",
|
|
37
|
+
eu: "https://api-automation-eu.browserstack.com",
|
|
38
|
+
in: "https://api-automation-in.browserstack.com",
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
export function hostFor(kind, region) {
|
|
42
|
+
return HOSTS[kind][region];
|
|
43
|
+
}
|
|
44
|
+
export function getRegionalHost(kind) {
|
|
45
|
+
if (kind === "automationApi" &&
|
|
46
|
+
appConfig.BROWSERSTACK_AUTOMATION_BASE_URL &&
|
|
47
|
+
appConfig.BROWSERSTACK_AUTOMATION_BASE_URL !== HOSTS.automationApi.us) {
|
|
48
|
+
return appConfig.BROWSERSTACK_AUTOMATION_BASE_URL;
|
|
49
|
+
}
|
|
50
|
+
return hostFor(kind, appConfig.BROWSERSTACK_GRR_REGION ?? "us");
|
|
51
|
+
}
|
package/dist/lib/tm-base-url.js
CHANGED
|
@@ -9,6 +9,11 @@ const TM_BASE_URLS = [
|
|
|
9
9
|
];
|
|
10
10
|
let cachedBaseUrl = null;
|
|
11
11
|
export async function getTMBaseURL(config) {
|
|
12
|
+
// Unset or "us" falls through to the probe below.
|
|
13
|
+
const grrRegion = appConfig.BROWSERSTACK_GRR_REGION;
|
|
14
|
+
if (grrRegion === "eu" || grrRegion === "in") {
|
|
15
|
+
return `https://test-management-${grrRegion}.browserstack.com`;
|
|
16
|
+
}
|
|
12
17
|
// Skip the module-level cache in remote (multi-tenant) mode: it is process-shared,
|
|
13
18
|
// so the first user's region would be served to every subsequent user — breaking
|
|
14
19
|
// requests for users on a different region's BrowserStack account.
|
package/dist/lib/utils.js
CHANGED
|
@@ -7,6 +7,9 @@ export function sanitizeUrlParam(param) {
|
|
|
7
7
|
return param.replace(/[;&|`$(){}[\]<>]/g, "");
|
|
8
8
|
}
|
|
9
9
|
const ONE_MB = 1048576;
|
|
10
|
+
// sharp's default (~268M px) lets one image decode to ~1 GB of RGBA; 25M px caps it near 100 MB.
|
|
11
|
+
const MAX_INPUT_PIXELS = 25_000_000;
|
|
12
|
+
const MAX_IMAGE_DIMENSION = 1920;
|
|
10
13
|
let sharpConfigured = false;
|
|
11
14
|
//Compresses a base64 image intelligently to keep it under 1 MB if needed.
|
|
12
15
|
export async function maybeCompressBase64(base64) {
|
|
@@ -25,11 +28,19 @@ export async function maybeCompressBase64(base64) {
|
|
|
25
28
|
sharp.concurrency(1);
|
|
26
29
|
sharpConfigured = true;
|
|
27
30
|
}
|
|
28
|
-
const
|
|
31
|
+
const image = globalConfig.REMOTE_MCP
|
|
32
|
+
? sharp(buffer, { limitInputPixels: MAX_INPUT_PIXELS }).resize({
|
|
33
|
+
width: MAX_IMAGE_DIMENSION,
|
|
34
|
+
height: MAX_IMAGE_DIMENSION,
|
|
35
|
+
fit: "inside",
|
|
36
|
+
withoutEnlargement: true,
|
|
37
|
+
})
|
|
38
|
+
: sharp(buffer);
|
|
39
|
+
const compressedBuffer = await image.png({ quality }).toBuffer();
|
|
29
40
|
return compressedBuffer.toString("base64");
|
|
30
41
|
}
|
|
31
42
|
catch (err) {
|
|
32
|
-
logger.warn("Image compression
|
|
43
|
+
logger.warn("Image compression failed; returning the uncompressed image. %s", err instanceof Error ? err.message : String(err));
|
|
33
44
|
return base64;
|
|
34
45
|
}
|
|
35
46
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { apiClient } from "../../lib/apiClient.js";
|
|
2
2
|
import { wrapUntrusted } from "../../lib/untrusted-content.js";
|
|
3
3
|
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
4
|
+
import { getRegionalHost } from "../../lib/regional-host.js";
|
|
4
5
|
export async function queryAccessibilityRAG(userQuery, config) {
|
|
5
|
-
const url = "
|
|
6
|
+
const url = `${getRegionalHost("accessibilityDashboard")}/api/tcg-proxy/search`;
|
|
6
7
|
const authString = getBrowserStackAuth(config);
|
|
7
8
|
const auth = Buffer.from(authString).toString("base64");
|
|
8
9
|
const response = await apiClient.post({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { apiClient } from "../../lib/apiClient.js";
|
|
2
2
|
import logger from "../../logger.js";
|
|
3
|
+
import { getRegionalHost } from "../../lib/regional-host.js";
|
|
3
4
|
/**
|
|
4
5
|
* Allowlist of non-sensitive fields safe to return to the LLM/tool caller.
|
|
5
6
|
* Everything else (site username/password, selectors, undeclared keys) is
|
|
@@ -52,7 +53,7 @@ export class AccessibilityAuthConfig {
|
|
|
52
53
|
};
|
|
53
54
|
try {
|
|
54
55
|
const response = await apiClient.post({
|
|
55
|
-
url: "
|
|
56
|
+
url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/auth_configs`,
|
|
56
57
|
headers: {
|
|
57
58
|
Authorization: "Basic " +
|
|
58
59
|
Buffer.from(`${this.auth.username}:${this.auth.password}`).toString("base64"),
|
|
@@ -91,7 +92,7 @@ export class AccessibilityAuthConfig {
|
|
|
91
92
|
};
|
|
92
93
|
try {
|
|
93
94
|
const response = await apiClient.post({
|
|
94
|
-
url: "
|
|
95
|
+
url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/auth_configs`,
|
|
95
96
|
headers: {
|
|
96
97
|
Authorization: "Basic " +
|
|
97
98
|
Buffer.from(`${this.auth.username}:${this.auth.password}`).toString("base64"),
|
|
@@ -120,7 +121,7 @@ export class AccessibilityAuthConfig {
|
|
|
120
121
|
try {
|
|
121
122
|
// No GET-by-id route exists; list and select.
|
|
122
123
|
const response = await apiClient.get({
|
|
123
|
-
url: "
|
|
124
|
+
url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/auth_configs`,
|
|
124
125
|
headers: {
|
|
125
126
|
Authorization: "Basic " +
|
|
126
127
|
Buffer.from(`${this.auth.username}:${this.auth.password}`).toString("base64"),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { apiClient } from "../../lib/apiClient.js";
|
|
2
|
+
import { getRegionalHost } from "../../lib/regional-host.js";
|
|
2
3
|
export class AccessibilityReportFetcher {
|
|
3
4
|
auth;
|
|
4
5
|
setAuth(auth) {
|
|
@@ -6,7 +7,7 @@ export class AccessibilityReportFetcher {
|
|
|
6
7
|
}
|
|
7
8
|
async getReportLink(scanId, scanRunId) {
|
|
8
9
|
// Initiate CSV link generation
|
|
9
|
-
const initUrl =
|
|
10
|
+
const initUrl = `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?scan_run_id=${scanRunId}`;
|
|
10
11
|
let basicAuthHeader = undefined;
|
|
11
12
|
if (this.auth) {
|
|
12
13
|
const { username, password } = this.auth;
|
|
@@ -23,7 +24,7 @@ export class AccessibilityReportFetcher {
|
|
|
23
24
|
}
|
|
24
25
|
const taskId = initData.data.task_id;
|
|
25
26
|
// Poll for the generated CSV link (task is async, may take a few seconds)
|
|
26
|
-
const reportUrl =
|
|
27
|
+
const reportUrl = `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?task_id=${encodeURIComponent(taskId)}`;
|
|
27
28
|
const maxAttempts = 3;
|
|
28
29
|
const pollIntervalMs = 2000;
|
|
29
30
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import logger from "../../logger.js";
|
|
4
4
|
import { isLocalURL, ensureLocalBinarySetup, killExistingBrowserStackLocalProcesses, } from "../../lib/local.js";
|
|
5
5
|
import config from "../../config.js";
|
|
6
|
+
import { getRegionalHost } from "../../lib/regional-host.js";
|
|
6
7
|
export class AccessibilityScanner {
|
|
7
8
|
auth;
|
|
8
9
|
setAuth(auth) {
|
|
@@ -64,7 +65,7 @@ export class AccessibilityScanner {
|
|
|
64
65
|
}
|
|
65
66
|
try {
|
|
66
67
|
const response = await apiClient.post({
|
|
67
|
-
url: "
|
|
68
|
+
url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/scans`,
|
|
68
69
|
headers: {
|
|
69
70
|
Authorization: "Basic " +
|
|
70
71
|
Buffer.from(`${this.auth.username}:${this.auth.password}`).toString("base64"),
|
|
@@ -92,7 +93,7 @@ export class AccessibilityScanner {
|
|
|
92
93
|
async pollStatus(scanId, scanRunId) {
|
|
93
94
|
try {
|
|
94
95
|
const response = await apiClient.get({
|
|
95
|
-
url:
|
|
96
|
+
url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/scans/${scanId}/scan_runs/${scanRunId}/status`,
|
|
96
97
|
headers: {
|
|
97
98
|
Authorization: "Basic " +
|
|
98
99
|
Buffer.from(`${this.auth?.username}:${this.auth?.password}`).toString("base64"),
|
|
@@ -5,6 +5,7 @@ import { customFuzzySearch } from "../../../lib/fuzzy.js";
|
|
|
5
5
|
import { getBrowserStackAuth } from "../../../lib/get-auth.js";
|
|
6
6
|
import { validateUploadPath, APP_BINARY_EXTENSIONS, MAX_APP_UPLOAD_BYTES, } from "../../../lib/upload-validator.js";
|
|
7
7
|
import appConfig from "../../../config.js";
|
|
8
|
+
import { getRegionalHost } from "../../../lib/regional-host.js";
|
|
8
9
|
/**
|
|
9
10
|
* Finds devices that exactly match the provided display name.
|
|
10
11
|
* Uses fuzzy search first, and then filters for exact case-insensitive match.
|
|
@@ -84,7 +85,7 @@ export async function uploadApp(appPath, username, password) {
|
|
|
84
85
|
const formData = new FormData();
|
|
85
86
|
formData.append("file", fs.createReadStream(safePath));
|
|
86
87
|
const response = await apiClient.post({
|
|
87
|
-
url: "
|
|
88
|
+
url: `${getRegionalHost("appAutomateUpload")}/app-automate/upload`,
|
|
88
89
|
headers: {
|
|
89
90
|
...formData.getHeaders(),
|
|
90
91
|
Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64"),
|
|
@@ -124,25 +125,25 @@ async function uploadFileToBrowserStack(filePath, endpoint, responseKey, config,
|
|
|
124
125
|
}
|
|
125
126
|
//Uploads an Android app (.apk or .aab) to BrowserStack Espresso endpoint and returns the app_url
|
|
126
127
|
export async function uploadEspressoApp(appPath, config) {
|
|
127
|
-
return uploadFileToBrowserStack(appPath, "
|
|
128
|
+
return uploadFileToBrowserStack(appPath, `${getRegionalHost("appAutomateUpload")}/app-automate/espresso/v2/app`, "app_url", config, { allowedExtensions: [".apk", ".aab"] });
|
|
128
129
|
}
|
|
129
130
|
//Uploads an Espresso test suite (.apk) to BrowserStack and returns the test_suite_url
|
|
130
131
|
export async function uploadEspressoTestSuite(testSuitePath, config) {
|
|
131
|
-
return uploadFileToBrowserStack(testSuitePath, "
|
|
132
|
+
return uploadFileToBrowserStack(testSuitePath, `${getRegionalHost("appAutomateUpload")}/app-automate/espresso/v2/test-suite`, "test_suite_url", config, { allowedExtensions: [".apk"] });
|
|
132
133
|
}
|
|
133
134
|
//Uploads an iOS app (.ipa) to BrowserStack XCUITest endpoint and returns the app_url
|
|
134
135
|
export async function uploadXcuiApp(appPath, config) {
|
|
135
|
-
return uploadFileToBrowserStack(appPath, "
|
|
136
|
+
return uploadFileToBrowserStack(appPath, `${getRegionalHost("appAutomateUpload")}/app-automate/xcuitest/v2/app`, "app_url", config, { allowedExtensions: [".ipa"] });
|
|
136
137
|
}
|
|
137
138
|
//Uploads an XCUITest test suite (.zip) to BrowserStack and returns the test_suite_url
|
|
138
139
|
export async function uploadXcuiTestSuite(testSuitePath, config) {
|
|
139
|
-
return uploadFileToBrowserStack(testSuitePath, "
|
|
140
|
+
return uploadFileToBrowserStack(testSuitePath, `${getRegionalHost("appAutomateUpload")}/app-automate/xcuitest/v2/test-suite`, "test_suite_url", config, { allowedExtensions: [".zip"] });
|
|
140
141
|
}
|
|
141
142
|
// Triggers an Espresso test run on BrowserStack and returns the build_id
|
|
142
143
|
export async function triggerEspressoBuild(app_url, test_suite_url, devices, project, config) {
|
|
143
144
|
const authHeader = "Basic " + Buffer.from(getBrowserStackAuth(config)).toString("base64");
|
|
144
145
|
const response = await apiClient.post({
|
|
145
|
-
url: "
|
|
146
|
+
url: `${getRegionalHost("appAutomateUpload")}/app-automate/espresso/v2/build`,
|
|
146
147
|
headers: {
|
|
147
148
|
Authorization: authHeader,
|
|
148
149
|
"Content-Type": "application/json",
|
|
@@ -166,7 +167,7 @@ export async function triggerXcuiBuild(app_url, test_suite_url, devices, project
|
|
|
166
167
|
password: config["browserstack-access-key"],
|
|
167
168
|
};
|
|
168
169
|
const response = await apiClient.post({
|
|
169
|
-
url: "
|
|
170
|
+
url: `${getRegionalHost("appAutomateUpload")}/app-automate/xcuitest/v2/build`,
|
|
170
171
|
headers: {
|
|
171
172
|
Authorization: "Basic " +
|
|
172
173
|
Buffer.from(`${auth.username}:${auth.password}`).toString("base64"),
|
|
@@ -12,6 +12,7 @@ import { Platform, } from "./appautomate-utils/native-execution/types.js";
|
|
|
12
12
|
import { getDevicesAndBrowsers, BrowserStackProducts, } from "../lib/device-cache.js";
|
|
13
13
|
import { findMatchingDevice, getDeviceVersions, resolveVersion, validateArgs, uploadApp, uploadEspressoApp, uploadEspressoTestSuite, triggerEspressoBuild, uploadXcuiApp, uploadXcuiTestSuite, triggerXcuiBuild, } from "./appautomate-utils/native-execution/appautomate.js";
|
|
14
14
|
import { RUN_APP_AUTOMATE_DESCRIPTION, RUN_APP_AUTOMATE_SCHEMA, } from "./appautomate-utils/native-execution/constants.js";
|
|
15
|
+
import { getRegionalHost } from "../lib/regional-host.js";
|
|
15
16
|
/**
|
|
16
17
|
* Launches an app on a selected BrowserStack device and takes a screenshot.
|
|
17
18
|
*/
|
|
@@ -63,7 +64,7 @@ async function takeAppScreenshot(args) {
|
|
|
63
64
|
try {
|
|
64
65
|
driver = await remote({
|
|
65
66
|
protocol: "https",
|
|
66
|
-
hostname: "
|
|
67
|
+
hostname: getRegionalHost("appAutomateHub"),
|
|
67
68
|
port: 443,
|
|
68
69
|
path: "/wd/hub",
|
|
69
70
|
capabilities,
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { SessionType } from "../../lib/constants.js";
|
|
2
2
|
import { BrowserStackConfig } from "../../lib/types.js";
|
|
3
|
-
export declare
|
|
3
|
+
export declare const MAX_SCREENSHOT_BYTES: number | undefined;
|
|
4
|
+
export declare function parseScreenshotUrls(text: string): string[];
|
|
5
|
+
export type Screenshot = {
|
|
4
6
|
url: string;
|
|
5
7
|
base64: string;
|
|
6
|
-
|
|
8
|
+
mimeType: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function fetchAutomationScreenshots(sessionId: string, sessionType: SessionType | undefined, config: BrowserStackConfig): Promise<Screenshot[]>;
|
|
@@ -3,10 +3,17 @@ import { SessionType } from "../../lib/constants.js";
|
|
|
3
3
|
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
4
4
|
import { apiClient } from "../../lib/apiClient.js";
|
|
5
5
|
import globalConfig from "../../config.js";
|
|
6
|
+
import { getRegionalHost } from "../../lib/regional-host.js";
|
|
7
|
+
import { MAX_LOG_BYTES } from "../failurelogs-utils/utils.js";
|
|
8
|
+
export const MAX_SCREENSHOT_BYTES = globalConfig.REMOTE_MCP
|
|
9
|
+
? 10 * 1024 * 1024
|
|
10
|
+
: undefined;
|
|
6
11
|
async function extractScreenshotUrls(sessionId, sessionType, config) {
|
|
7
12
|
const authString = getBrowserStackAuth(config);
|
|
8
13
|
const auth = Buffer.from(authString).toString("base64");
|
|
9
|
-
const
|
|
14
|
+
const isAutomate = sessionType === SessionType.Automate;
|
|
15
|
+
const host = getRegionalHost(isAutomate ? "automateApi" : "appAutomateApi");
|
|
16
|
+
const baseUrl = `${host}/${isAutomate ? "automate" : "app-automate"}`;
|
|
10
17
|
const url = `${baseUrl}/sessions/${sessionId}/logs`;
|
|
11
18
|
const response = await apiClient.get({
|
|
12
19
|
url,
|
|
@@ -14,47 +21,112 @@ async function extractScreenshotUrls(sessionId, sessionType, config) {
|
|
|
14
21
|
"Content-Type": "application/json",
|
|
15
22
|
Authorization: `Basic ${auth}`,
|
|
16
23
|
},
|
|
24
|
+
maxContentLength: MAX_LOG_BYTES,
|
|
17
25
|
raise_error: false,
|
|
18
26
|
});
|
|
27
|
+
if (response.status === 413) {
|
|
28
|
+
throw new Error("The logs for this session are too large (over 5 MB) to find screenshots here. Open the session in the BrowserStack dashboard to view them.");
|
|
29
|
+
}
|
|
19
30
|
await assertOkResponse(response, "Session");
|
|
20
31
|
const text = typeof response.data === "string"
|
|
21
32
|
? response.data
|
|
22
33
|
: JSON.stringify(response.data);
|
|
34
|
+
return parseScreenshotUrls(text);
|
|
35
|
+
}
|
|
36
|
+
const LOG_PREFIX = String.raw `^\d{4}-\d{2}-\d{2} \d{1,2}:\d{1,2}:\d{1,2}:\d{1,3} `;
|
|
37
|
+
const LINE_TYPE_PATTERN = new RegExp(`${LOG_PREFIX}([A-Z_]+) `);
|
|
38
|
+
const SCREENSHOT_REQUEST_PATTERN = new RegExp(String.raw `${LOG_PREFIX}REQUEST \[[^\]]+\] GET /session/[^/\s]+/screenshot(\s|$)`);
|
|
39
|
+
const RESPONSE_VALUE_PATTERN = new RegExp(String.raw `${LOG_PREFIX}RESPONSE \{"value"\s*:\s*"(https://[^"]+)"`);
|
|
40
|
+
const DEBUG_SCREENSHOT_PATTERN = new RegExp(String.raw `${LOG_PREFIX}DEBUG (https://\S+)$`);
|
|
41
|
+
const TRUSTED_HOST_PATTERN = /(^|\.)browserstack\.com$|\.s3([.-][a-z0-9-]+)*\.amazonaws\.com$|\.backblazeb2\.com$/;
|
|
42
|
+
function isTrustedScreenshotUrl(value) {
|
|
43
|
+
try {
|
|
44
|
+
const url = new URL(value);
|
|
45
|
+
return url.protocol === "https:" && TRUSTED_HOST_PATTERN.test(url.hostname);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function parseScreenshotUrls(text) {
|
|
23
52
|
const urls = [];
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
53
|
+
const lines = text.split("\n").map((line) => line.trimEnd());
|
|
54
|
+
for (let i = 0; i < lines.length; i++) {
|
|
55
|
+
const line = lines[i];
|
|
56
|
+
const debugMatch = line.match(DEBUG_SCREENSHOT_PATTERN);
|
|
57
|
+
if (debugMatch) {
|
|
58
|
+
urls.push(debugMatch[1]);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!SCREENSHOT_REQUEST_PATTERN.test(line)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
65
|
+
const lineType = lines[j].match(LINE_TYPE_PATTERN)?.[1];
|
|
66
|
+
if (lineType === "REQUEST") {
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
if (lineType === "RESPONSE") {
|
|
70
|
+
const valueMatch = lines[j].match(RESPONSE_VALUE_PATTERN);
|
|
71
|
+
if (valueMatch) {
|
|
72
|
+
urls.push(valueMatch[1]);
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
35
75
|
}
|
|
36
76
|
}
|
|
37
77
|
}
|
|
38
|
-
return urls;
|
|
78
|
+
return [...new Set(urls)].filter(isTrustedScreenshotUrl);
|
|
79
|
+
}
|
|
80
|
+
function detectImageMimeType(buffer) {
|
|
81
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
|
82
|
+
return "image/jpeg";
|
|
83
|
+
}
|
|
84
|
+
return "image/png";
|
|
39
85
|
}
|
|
86
|
+
// Returns null for a screenshot over MAX_SCREENSHOT_BYTES so the rest still load.
|
|
40
87
|
async function fetchAndCompress(url) {
|
|
41
|
-
const response = await apiClient.get({
|
|
88
|
+
const response = await apiClient.get({
|
|
89
|
+
url,
|
|
90
|
+
responseType: "arraybuffer",
|
|
91
|
+
maxContentLength: MAX_SCREENSHOT_BYTES,
|
|
92
|
+
raise_error: false,
|
|
93
|
+
});
|
|
94
|
+
if (response.status === 413) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(`Failed to download screenshot: ${response.status} ${response.statusText}`);
|
|
99
|
+
}
|
|
42
100
|
// Axios returns response.data as a Buffer for binary data
|
|
43
|
-
const
|
|
44
|
-
|
|
101
|
+
const buffer = Buffer.from(response.data);
|
|
102
|
+
const base64 = await maybeCompressBase64(buffer.toString("base64"));
|
|
103
|
+
// Detect AFTER compression: sharp re-encodes anything over 1 MB to PNG, so
|
|
104
|
+
// reading the original bytes would label a large JPEG with the wrong type.
|
|
105
|
+
return {
|
|
106
|
+
url,
|
|
107
|
+
base64,
|
|
108
|
+
mimeType: detectImageMimeType(Buffer.from(base64, "base64")),
|
|
109
|
+
};
|
|
45
110
|
}
|
|
46
111
|
//Converts screenshot URLs to base64 encoded images
|
|
47
112
|
async function convertUrlsToBase64(urls) {
|
|
113
|
+
let results;
|
|
48
114
|
// Hosted pods are memory-constrained: process one image at a time. Local runs
|
|
49
115
|
// keep the concurrent path.
|
|
50
116
|
if (globalConfig.REMOTE_MCP) {
|
|
51
|
-
|
|
117
|
+
results = [];
|
|
52
118
|
for (const url of urls) {
|
|
53
|
-
|
|
119
|
+
results.push(await fetchAndCompress(url));
|
|
54
120
|
}
|
|
55
|
-
return screenshots;
|
|
56
121
|
}
|
|
57
|
-
|
|
122
|
+
else {
|
|
123
|
+
results = await Promise.all(urls.map(fetchAndCompress));
|
|
124
|
+
}
|
|
125
|
+
const screenshots = results.filter((s) => s !== null);
|
|
126
|
+
if (urls.length > 0 && screenshots.length === 0) {
|
|
127
|
+
throw new Error("The screenshots in this session are too large (over 10 MB each) to fetch here. Open the session in the BrowserStack dashboard to view them.");
|
|
128
|
+
}
|
|
129
|
+
return screenshots;
|
|
58
130
|
}
|
|
59
131
|
//Fetches and converts screenshot URLs to base64 encoded images
|
|
60
132
|
export async function fetchAutomationScreenshots(sessionId, sessionType = SessionType.Automate, config) {
|
|
@@ -23,6 +23,6 @@ export interface SessionIdRecord {
|
|
|
23
23
|
browserUrl?: string;
|
|
24
24
|
videoUrl?: string;
|
|
25
25
|
}
|
|
26
|
-
export declare function sessionsListUrl(sessionType: SessionType, buildId: string): string;
|
|
26
|
+
export declare function sessionsListUrl(sessionType: SessionType, buildId: string, host?: string): string;
|
|
27
27
|
export declare function mapSessionRecords(payload: unknown, statusFilter?: string): SessionIdRecord[];
|
|
28
28
|
export declare function listSessionIds(args: ListSessionIdsArgs, config: BrowserStackConfig): Promise<SessionIdRecord[]>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SessionType } from "../../lib/constants.js";
|
|
2
2
|
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
3
3
|
import { apiClient } from "../../lib/apiClient.js";
|
|
4
|
+
import { getRegionalHost } from "../../lib/regional-host.js";
|
|
4
5
|
export const DEFAULT_SESSION_LIST_LIMIT = 10;
|
|
5
6
|
/** The REST session list returned 404: no Automate/App Automate build has this hashed id. */
|
|
6
7
|
export class UnknownBuildError extends Error {
|
|
@@ -9,13 +10,13 @@ export class UnknownBuildError extends Error {
|
|
|
9
10
|
this.name = "UnknownBuildError";
|
|
10
11
|
}
|
|
11
12
|
}
|
|
12
|
-
export function sessionsListUrl(sessionType, buildId) {
|
|
13
|
+
export function sessionsListUrl(sessionType, buildId, host) {
|
|
13
14
|
const encodedBuildId = encodeURIComponent(buildId);
|
|
14
15
|
switch (sessionType) {
|
|
15
16
|
case SessionType.Automate:
|
|
16
|
-
return
|
|
17
|
+
return `${host ?? "https://api.browserstack.com"}/automate/builds/${encodedBuildId}/sessions.json`;
|
|
17
18
|
case SessionType.AppAutomate:
|
|
18
|
-
return
|
|
19
|
+
return `${host ?? "https://api-cloud.browserstack.com"}/app-automate/builds/${encodedBuildId}/sessions.json`;
|
|
19
20
|
default: {
|
|
20
21
|
const _exhaustive = sessionType;
|
|
21
22
|
throw new Error(`Unsupported session type: ${_exhaustive}`);
|
|
@@ -66,7 +67,9 @@ export async function listSessionIds(args, config) {
|
|
|
66
67
|
params.offset = args.offset;
|
|
67
68
|
}
|
|
68
69
|
const response = await apiClient.get({
|
|
69
|
-
url: sessionsListUrl(args.sessionType, buildId
|
|
70
|
+
url: sessionsListUrl(args.sessionType, buildId, getRegionalHost(args.sessionType === SessionType.Automate
|
|
71
|
+
? "automateApi"
|
|
72
|
+
: "appAutomateUpload")),
|
|
70
73
|
headers: {
|
|
71
74
|
"Content-Type": "application/json",
|
|
72
75
|
Authorization: `Basic ${auth}`,
|
|
@@ -2,7 +2,7 @@ import { SessionType } from "../../lib/constants.js";
|
|
|
2
2
|
import { BrowserStackConfig } from "../../lib/types.js";
|
|
3
3
|
export declare function isObservabilityBuildUuid(id: string): boolean;
|
|
4
4
|
export declare function isHashedBuildId(id: string): boolean;
|
|
5
|
-
export declare function sessionDetailsUrl(sessionType: SessionType, sessionId: string): string;
|
|
5
|
+
export declare function sessionDetailsUrl(sessionType: SessionType, sessionId: string, host?: string): string;
|
|
6
6
|
/**
|
|
7
7
|
* Resolve the hashed build id that a session belongs to via the Automate /
|
|
8
8
|
* App Automate session detail endpoint. Returns undefined when the session
|
|
@@ -2,8 +2,8 @@ import { SessionType } from "../../lib/constants.js";
|
|
|
2
2
|
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
3
3
|
import { apiClient } from "../../lib/apiClient.js";
|
|
4
4
|
import logger from "../../logger.js";
|
|
5
|
-
import { getAutomationBaseUrl } from "../rca-agent-utils/constants.js";
|
|
6
5
|
import { extractTestIds } from "../rca-agent-utils/get-failed-test-id.js";
|
|
6
|
+
import { getRegionalHost } from "../../lib/regional-host.js";
|
|
7
7
|
// Observability (Test Reporting & Analytics) build ids are usually UUIDs but
|
|
8
8
|
// some are 40-char hex, the same shape as Automate / App Automate "hashed ids".
|
|
9
9
|
// The two are never interchangeable, and the observability build API does not
|
|
@@ -19,13 +19,13 @@ export function isObservabilityBuildUuid(id) {
|
|
|
19
19
|
export function isHashedBuildId(id) {
|
|
20
20
|
return HASHED_BUILD_ID_RE.test(id.trim());
|
|
21
21
|
}
|
|
22
|
-
export function sessionDetailsUrl(sessionType, sessionId) {
|
|
22
|
+
export function sessionDetailsUrl(sessionType, sessionId, host = "https://api.browserstack.com") {
|
|
23
23
|
const encoded = encodeURIComponent(sessionId);
|
|
24
24
|
switch (sessionType) {
|
|
25
25
|
case SessionType.Automate:
|
|
26
|
-
return
|
|
26
|
+
return `${host}/automate/sessions/${encoded}.json`;
|
|
27
27
|
case SessionType.AppAutomate:
|
|
28
|
-
return
|
|
28
|
+
return `${host}/app-automate/sessions/${encoded}.json`;
|
|
29
29
|
default: {
|
|
30
30
|
const _exhaustive = sessionType;
|
|
31
31
|
throw new Error(`Unsupported session type: ${_exhaustive}`);
|
|
@@ -41,7 +41,7 @@ export async function resolveBuildIdFromSession(sessionId, sessionType, config)
|
|
|
41
41
|
const authString = getBrowserStackAuth(config);
|
|
42
42
|
const auth = Buffer.from(authString).toString("base64");
|
|
43
43
|
const response = await apiClient.get({
|
|
44
|
-
url: sessionDetailsUrl(sessionType, sessionId),
|
|
44
|
+
url: sessionDetailsUrl(sessionType, sessionId, getRegionalHost(sessionType === SessionType.Automate ? "automateApi" : "appAutomateApi")),
|
|
45
45
|
headers: {
|
|
46
46
|
"Content-Type": "application/json",
|
|
47
47
|
Authorization: `Basic ${auth}`,
|
|
@@ -66,7 +66,7 @@ export async function resolveBuildIdFromSession(sessionId, sessionType, config)
|
|
|
66
66
|
export async function findSessionIdForObservabilityBuild(observabilityBuildId, config) {
|
|
67
67
|
const authString = getBrowserStackAuth(config);
|
|
68
68
|
const auth = Buffer.from(authString).toString("base64");
|
|
69
|
-
const baseUrl = `${
|
|
69
|
+
const baseUrl = `${getRegionalHost("automationApi")}/ext/v1/builds/${encodeURIComponent(observabilityBuildId)}/testRuns`;
|
|
70
70
|
let nextPage;
|
|
71
71
|
for (let page = 0; page < MAX_TEST_RUN_PAGES; page++) {
|
|
72
72
|
const response = await apiClient.get({
|
package/dist/tools/automate.js
CHANGED
|
@@ -14,7 +14,7 @@ export async function fetchAutomationScreenshotsTool(args, config) {
|
|
|
14
14
|
content: [
|
|
15
15
|
{
|
|
16
16
|
type: "text",
|
|
17
|
-
text: "No screenshots found in the session
|
|
17
|
+
text: "No screenshots found in the session logs. Run with browserstack.debug: true to capture visual-log screenshots.",
|
|
18
18
|
},
|
|
19
19
|
],
|
|
20
20
|
isError: true,
|
|
@@ -23,7 +23,7 @@ export async function fetchAutomationScreenshotsTool(args, config) {
|
|
|
23
23
|
const results = screenshots.map((screenshot, index) => ({
|
|
24
24
|
type: "image",
|
|
25
25
|
data: screenshot.base64,
|
|
26
|
-
mimeType:
|
|
26
|
+
mimeType: screenshot.mimeType,
|
|
27
27
|
_meta: { url: screenshot.url, index: index + 1 },
|
|
28
28
|
}));
|
|
29
29
|
return {
|
|
@@ -3,11 +3,13 @@ import logger from "../logger.js";
|
|
|
3
3
|
import { fetchFromBrowserStackAPI, handleMCPError } from "../lib/utils.js";
|
|
4
4
|
import { trackMCP } from "../lib/instrumentation.js";
|
|
5
5
|
import { resolveHashedBuildId } from "./automate-utils/resolve-hashed-build-id.js";
|
|
6
|
+
import { getRegionalHost } from "../lib/regional-host.js";
|
|
6
7
|
// Tool function that fetches build insights from two APIs
|
|
7
8
|
export async function fetchBuildInsightsTool(args, config) {
|
|
8
9
|
try {
|
|
9
|
-
const
|
|
10
|
-
const
|
|
10
|
+
const host = getRegionalHost("automationApi");
|
|
11
|
+
const buildUrl = `${host}/ext/v1/builds/${args.buildId}`;
|
|
12
|
+
const qualityGateUrl = `${host}/ext/v1/quality-gates/${args.buildId}`;
|
|
11
13
|
// Quality gate data is optional — a failure there should not block build insights
|
|
12
14
|
const [buildData, qualityData] = await Promise.all([
|
|
13
15
|
fetchFromBrowserStackAPI(buildUrl, config),
|
|
@@ -2,9 +2,10 @@ import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
|
2
2
|
import { filterLinesByKeywords, MAX_LOG_BYTES, validateLogResponse, } from "./utils.js";
|
|
3
3
|
import { apiClient } from "../../lib/apiClient.js";
|
|
4
4
|
import { wrapUntrusted } from "../../lib/untrusted-content.js";
|
|
5
|
+
import { getRegionalHost } from "../../lib/regional-host.js";
|
|
5
6
|
// DEVICE LOGS
|
|
6
7
|
export async function retrieveDeviceLogs(sessionId, buildId, config) {
|
|
7
|
-
const url =
|
|
8
|
+
const url = `${getRegionalHost("appAutomateApi")}/app-automate/builds/${buildId}/sessions/${sessionId}/deviceLogs`;
|
|
8
9
|
const authString = getBrowserStackAuth(config);
|
|
9
10
|
const auth = Buffer.from(authString).toString("base64");
|
|
10
11
|
const response = await apiClient.get({
|
|
@@ -29,7 +30,7 @@ export async function retrieveDeviceLogs(sessionId, buildId, config) {
|
|
|
29
30
|
}
|
|
30
31
|
// APPIUM LOGS
|
|
31
32
|
export async function retrieveAppiumLogs(sessionId, buildId, config) {
|
|
32
|
-
const url =
|
|
33
|
+
const url = `${getRegionalHost("appAutomateApi")}/app-automate/builds/${buildId}/sessions/${sessionId}/appiumlogs`;
|
|
33
34
|
const authString = getBrowserStackAuth(config);
|
|
34
35
|
const auth = Buffer.from(authString).toString("base64");
|
|
35
36
|
const response = await apiClient.get({
|
|
@@ -54,7 +55,7 @@ export async function retrieveAppiumLogs(sessionId, buildId, config) {
|
|
|
54
55
|
}
|
|
55
56
|
// CRASH LOGS
|
|
56
57
|
export async function retrieveCrashLogs(sessionId, buildId, config) {
|
|
57
|
-
const url =
|
|
58
|
+
const url = `${getRegionalHost("appAutomateApi")}/app-automate/builds/${buildId}/sessions/${sessionId}/crashlogs`;
|
|
58
59
|
const authString = getBrowserStackAuth(config);
|
|
59
60
|
const auth = Buffer.from(authString).toString("base64");
|
|
60
61
|
const response = await apiClient.get({
|
|
@@ -2,9 +2,10 @@ import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
|
2
2
|
import { wrapUntrusted } from "../../lib/untrusted-content.js";
|
|
3
3
|
import { filterLinesByKeywords, MAX_LOG_BYTES, validateLogResponse, } from "./utils.js";
|
|
4
4
|
import { apiClient } from "../../lib/apiClient.js";
|
|
5
|
+
import { getRegionalHost } from "../../lib/regional-host.js";
|
|
5
6
|
// NETWORK LOGS
|
|
6
7
|
export async function retrieveNetworkFailures(sessionId, config) {
|
|
7
|
-
const url =
|
|
8
|
+
const url = `${getRegionalHost("automateApi")}/automate/sessions/${sessionId}/networklogs`;
|
|
8
9
|
const authString = getBrowserStackAuth(config);
|
|
9
10
|
const auth = Buffer.from(authString).toString("base64");
|
|
10
11
|
const response = await apiClient.get({
|
|
@@ -43,7 +44,7 @@ export async function retrieveNetworkFailures(sessionId, config) {
|
|
|
43
44
|
}
|
|
44
45
|
// SESSION LOGS
|
|
45
46
|
export async function retrieveSessionFailures(sessionId, config) {
|
|
46
|
-
const url =
|
|
47
|
+
const url = `${getRegionalHost("automateApi")}/automate/sessions/${sessionId}/logs`;
|
|
47
48
|
const authString = getBrowserStackAuth(config);
|
|
48
49
|
const auth = Buffer.from(authString).toString("base64");
|
|
49
50
|
const response = await apiClient.get({
|
|
@@ -68,7 +69,7 @@ export async function retrieveSessionFailures(sessionId, config) {
|
|
|
68
69
|
}
|
|
69
70
|
// CONSOLE LOGS
|
|
70
71
|
export async function retrieveConsoleFailures(sessionId, config) {
|
|
71
|
-
const url =
|
|
72
|
+
const url = `${getRegionalHost("automateApi")}/automate/sessions/${sessionId}/consolelogs`;
|
|
72
73
|
const authString = getBrowserStackAuth(config);
|
|
73
74
|
const auth = Buffer.from(authString).toString("base64");
|
|
74
75
|
const response = await apiClient.get({
|
|
@@ -2,9 +2,11 @@ import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
|
2
2
|
import { apiClient } from "../../lib/apiClient.js";
|
|
3
3
|
import { SessionType } from "../../lib/constants.js";
|
|
4
4
|
import { validateLogResponse } from "./utils.js";
|
|
5
|
+
import { getRegionalHost } from "../../lib/regional-host.js";
|
|
5
6
|
export async function retrieveSessionVideo(sessionId, sessionType, config) {
|
|
6
7
|
const product = sessionType === SessionType.AppAutomate ? "app-automate" : "automate";
|
|
7
|
-
const
|
|
8
|
+
const host = getRegionalHost(sessionType === SessionType.AppAutomate ? "appAutomateApi" : "automateApi");
|
|
9
|
+
const url = `${host}/${product}/sessions/${encodeURIComponent(sessionId)}.json`;
|
|
8
10
|
const authString = getBrowserStackAuth(config);
|
|
9
11
|
const auth = Buffer.from(authString).toString("base64");
|
|
10
12
|
const response = await apiClient.get({
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { getRegionalHost } from "../../lib/regional-host.js";
|
|
1
2
|
export async function getBuildId(projectName, buildName, username, accessKey) {
|
|
2
|
-
const
|
|
3
|
+
const host = getRegionalHost("automationApi");
|
|
4
|
+
const url = new URL(`${host}/ext/v1/builds/latest`);
|
|
3
5
|
url.searchParams.append("project_name", projectName);
|
|
4
6
|
url.searchParams.append("build_name", buildName);
|
|
5
7
|
url.searchParams.append("user_name", username);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { TestStatus, FailedTestInfo, TestDetails } from "./types.js";
|
|
2
|
-
export declare function getTestIds(buildId: string, authString: string, status?: TestStatus, includeFailureDetail?: boolean): Promise<FailedTestInfo[]>;
|
|
2
|
+
export declare function getTestIds(buildId: string, authString: string, status?: TestStatus, includeFailureDetail?: boolean, host?: string): Promise<FailedTestInfo[]>;
|
|
3
3
|
export declare function extractTestIds(hierarchy: TestDetails[], status?: TestStatus, includeFailureDetail?: boolean): FailedTestInfo[];
|
|
4
4
|
export declare const extractFailedTestIds: typeof extractTestIds;
|
|
@@ -5,9 +5,9 @@ import { TestStatus, } from "./types.js";
|
|
|
5
5
|
const ERROR_SUMMARY_MAX = 200;
|
|
6
6
|
// Safety bound on pagination; hitting it is logged, never silent.
|
|
7
7
|
const MAX_PAGES = 100;
|
|
8
|
-
export async function getTestIds(buildId, authString, status, includeFailureDetail = false) {
|
|
8
|
+
export async function getTestIds(buildId, authString, status, includeFailureDetail = false, host = getAutomationBaseUrl()) {
|
|
9
9
|
// No `status` → returns ALL tests; a `status` narrows the query.
|
|
10
|
-
const baseUrl = `${
|
|
10
|
+
const baseUrl = `${host}/ext/v1/builds/${buildId}/testRuns`;
|
|
11
11
|
let url = status ? `${baseUrl}?test_statuses=${status}` : baseUrl;
|
|
12
12
|
let allTests = [];
|
|
13
13
|
let requestNumber = 0;
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { apiClient } from "../../lib/apiClient.js";
|
|
2
|
+
import { getRegionalHost } from "../../lib/regional-host.js";
|
|
2
3
|
/**
|
|
3
4
|
* Returns the latest build for a project + build name across all users (not just the caller's).
|
|
4
5
|
*/
|
|
5
6
|
export async function listBuildId(projectName, buildName, username, accessKey) {
|
|
6
7
|
const authHeader = "Basic " + Buffer.from(`${username}:${accessKey}`).toString("base64");
|
|
8
|
+
const host = getRegionalHost("automationApi");
|
|
7
9
|
const response = await apiClient.get({
|
|
8
|
-
url:
|
|
10
|
+
url: `${host}/ext/v1/builds/latest`,
|
|
9
11
|
headers: {
|
|
10
12
|
Authorization: authHeader,
|
|
11
13
|
"Content-Type": "application/json",
|
|
@@ -5,5 +5,5 @@ interface ScanProgressContext {
|
|
|
5
5
|
progressToken?: string | number;
|
|
6
6
|
};
|
|
7
7
|
}
|
|
8
|
-
export declare function getRCAData(testIds: number[], authString: string, context?: ScanProgressContext): Promise<RCAResponse>;
|
|
8
|
+
export declare function getRCAData(testIds: number[], authString: string, context?: ScanProgressContext, host?: string): Promise<RCAResponse>;
|
|
9
9
|
export {};
|
|
@@ -177,11 +177,11 @@ async function pollRCAResults(testCases, headers, baseUrl, context, pollInterval
|
|
|
177
177
|
}
|
|
178
178
|
return { testCases };
|
|
179
179
|
}
|
|
180
|
-
export async function getRCAData(testIds, authString, context) {
|
|
180
|
+
export async function getRCAData(testIds, authString, context, host = "https://api-observability.browserstack.com") {
|
|
181
181
|
const pollInterval = 5000;
|
|
182
182
|
const timeout = 40000;
|
|
183
183
|
const initialDelay = 20000;
|
|
184
|
-
const baseUrl =
|
|
184
|
+
const baseUrl = `${host}/ext/v1/testRun/{testId}/testRca`;
|
|
185
185
|
const headers = {
|
|
186
186
|
Authorization: `Basic ${Buffer.from(authString).toString("base64")}`,
|
|
187
187
|
"Content-Type": "application/json",
|
package/dist/tools/rca-agent.js
CHANGED
|
@@ -9,6 +9,7 @@ import { handleMCPError } from "../lib/utils.js";
|
|
|
9
9
|
import { trackMCP } from "../index.js";
|
|
10
10
|
import { NEEDS_BUILD_ID, NEEDS_TEST_IDS } from "./tool-handoff.js";
|
|
11
11
|
import { FETCH_RCA_PARAMS, GET_BUILD_ID_PARAMS, LIST_TEST_IDS_PARAMS, } from "./rca-agent-utils/constants.js";
|
|
12
|
+
import { getRegionalHost } from "../lib/regional-host.js";
|
|
12
13
|
// Tool function to fetch build ID
|
|
13
14
|
export async function getBuildIdTool(args, config) {
|
|
14
15
|
try {
|
|
@@ -75,7 +76,7 @@ export async function fetchRCADataTool(args, config) {
|
|
|
75
76
|
const authString = getBrowserStackAuth(config);
|
|
76
77
|
// Limit to first 3 test IDs for performance
|
|
77
78
|
const testIds = args.testId;
|
|
78
|
-
const rcaData = await getRCAData(testIds, authString);
|
|
79
|
+
const rcaData = await getRCAData(testIds, authString, undefined, getRegionalHost("observabilityApi"));
|
|
79
80
|
const formattedData = formatRCAData(rcaData);
|
|
80
81
|
return {
|
|
81
82
|
content: [
|
|
@@ -105,7 +106,7 @@ export async function listTestIdsTool(args, config) {
|
|
|
105
106
|
const { buildId, status, includeFailureDetail } = args;
|
|
106
107
|
const authString = getBrowserStackAuth(config);
|
|
107
108
|
// Get test IDs
|
|
108
|
-
const testIds = await getTestIds(buildId, authString, status, includeFailureDetail);
|
|
109
|
+
const testIds = await getTestIds(buildId, authString, status, includeFailureDetail, getRegionalHost("automationApi"));
|
|
109
110
|
return {
|
|
110
111
|
content: [
|
|
111
112
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
2
2
|
import { apiClient } from "../../lib/apiClient.js";
|
|
3
3
|
import logger from "../../logger.js";
|
|
4
|
-
|
|
4
|
+
import { getRegionalHost } from "../../lib/regional-host.js";
|
|
5
5
|
/**
|
|
6
6
|
* Normalizes the testCode response. The Observability API used to return a
|
|
7
7
|
* bare array of TestCodeEntry, but now wraps it as
|
|
@@ -43,7 +43,7 @@ export async function fetchTestCodeBySession(sessionId, config) {
|
|
|
43
43
|
}
|
|
44
44
|
const authString = getBrowserStackAuth(config);
|
|
45
45
|
const auth = Buffer.from(authString).toString("base64");
|
|
46
|
-
const url = `${
|
|
46
|
+
const url = `${getRegionalHost("automationApi")}/ext/v1/sessions/${encodeURIComponent(sessionId)}/testCode`;
|
|
47
47
|
try {
|
|
48
48
|
const response = await apiClient.get({
|
|
49
49
|
url,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { assertOkResponse } from "../../lib/utils.js";
|
|
2
2
|
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
3
3
|
import { apiClient } from "../../lib/apiClient.js";
|
|
4
|
-
|
|
4
|
+
import { getRegionalHost } from "../../lib/regional-host.js";
|
|
5
5
|
/**
|
|
6
6
|
* Fetches the self-healing report for a build via the build-scoped API.
|
|
7
7
|
*
|
|
@@ -19,7 +19,7 @@ export async function fetchSelfHealingReportByBuild(buildUuid, config) {
|
|
|
19
19
|
const authString = getBrowserStackAuth(config);
|
|
20
20
|
const auth = Buffer.from(authString).toString("base64");
|
|
21
21
|
const presignedResp = await apiClient.get({
|
|
22
|
-
url: `${
|
|
22
|
+
url: `${getRegionalHost("automationApi")}/ext/v1/builds/${encodeURIComponent(buildUuid)}/selfHealingReport`,
|
|
23
23
|
headers: {
|
|
24
24
|
"Content-Type": "application/json",
|
|
25
25
|
Authorization: `Basic ${auth}`,
|
|
@@ -67,7 +67,8 @@ export async function getSelfHealSelectors(sessionId, config, sessionType = "aut
|
|
|
67
67
|
const authString = getBrowserStackAuth(config);
|
|
68
68
|
const auth = Buffer.from(authString).toString("base64");
|
|
69
69
|
const productPath = sessionType === "app-automate" ? "app-automate" : "automate";
|
|
70
|
-
const
|
|
70
|
+
const host = getRegionalHost(sessionType === "app-automate" ? "appAutomateApi" : "automateApi");
|
|
71
|
+
const url = `${host}/${productPath}/sessions/${sessionId}/logs`;
|
|
71
72
|
const response = await apiClient.get({
|
|
72
73
|
url,
|
|
73
74
|
headers: {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { apiClient } from "../../lib/apiClient.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { errorText } 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,12 +218,14 @@ Error details: ${JSON.stringify(err.response?.data || err.message)}`,
|
|
|
218
218
|
isError: true,
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
|
-
const
|
|
221
|
+
const status = err.response?.status;
|
|
222
|
+
const detail = errorText(err, "unknown error");
|
|
223
|
+
const hint = status === 401 ? " Check the BrowserStack credentials in use." : "";
|
|
222
224
|
return {
|
|
223
225
|
content: [
|
|
224
226
|
{
|
|
225
227
|
type: "text",
|
|
226
|
-
text: `Failed to update test case
|
|
228
|
+
text: `Failed to update test case${status ? ` (HTTP ${status})` : ""}: ${detail}.${hint}`,
|
|
227
229
|
},
|
|
228
230
|
],
|
|
229
231
|
isError: true,
|