@browserstack/mcp-server 1.2.16 → 1.2.17
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 +2 -1
- package/dist/config.js +6 -2
- package/dist/lib/tm-base-url.js +10 -2
- package/dist/lib/upload-validator.d.ts +23 -0
- package/dist/lib/upload-validator.js +94 -0
- package/dist/tools/accessibility.js +1 -1
- package/dist/tools/appautomate-utils/native-execution/appautomate.d.ts +1 -1
- package/dist/tools/appautomate-utils/native-execution/appautomate.js +23 -21
- package/dist/tools/appautomate.js +1 -1
- package/dist/tools/applive-utils/upload-app.js +8 -4
- package/dist/tools/percy-sdk.js +11 -20
- package/dist/tools/run-percy-scan.d.ts +1 -2
- package/dist/tools/run-percy-scan.js +9 -13
- package/dist/tools/sdk-utils/bstack/commands.js +4 -4
- package/dist/tools/sdk-utils/handler.js +2 -8
- package/dist/tools/sdk-utils/percy-automate/handler.d.ts +1 -1
- package/dist/tools/sdk-utils/percy-automate/handler.js +2 -2
- package/dist/tools/sdk-utils/percy-web/handler.d.ts +1 -1
- package/dist/tools/sdk-utils/percy-web/handler.js +7 -4
- package/dist/tools/testmanagement-utils/update-testcase.js +1 -1
- package/dist/tools/testmanagement-utils/upload-file.js +10 -13
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ export declare class Config {
|
|
|
7
7
|
readonly browserstackLocalOptions: Record<string, any>;
|
|
8
8
|
readonly USE_OWN_LOCAL_BINARY_PROCESS: boolean;
|
|
9
9
|
readonly REMOTE_MCP: boolean;
|
|
10
|
-
|
|
10
|
+
readonly UPLOAD_BASE_DIR: string | undefined;
|
|
11
|
+
constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean, UPLOAD_BASE_DIR: string | undefined);
|
|
11
12
|
}
|
|
12
13
|
declare const config: Config;
|
|
13
14
|
export default config;
|
package/dist/config.js
CHANGED
|
@@ -37,12 +37,16 @@ export class Config {
|
|
|
37
37
|
browserstackLocalOptions;
|
|
38
38
|
USE_OWN_LOCAL_BINARY_PROCESS;
|
|
39
39
|
REMOTE_MCP;
|
|
40
|
-
|
|
40
|
+
UPLOAD_BASE_DIR;
|
|
41
|
+
constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR) {
|
|
41
42
|
this.DEV_MODE = DEV_MODE;
|
|
42
43
|
this.browserstackLocalOptions = browserstackLocalOptions;
|
|
43
44
|
this.USE_OWN_LOCAL_BINARY_PROCESS = USE_OWN_LOCAL_BINARY_PROCESS;
|
|
44
45
|
this.REMOTE_MCP = REMOTE_MCP;
|
|
46
|
+
this.UPLOAD_BASE_DIR = UPLOAD_BASE_DIR;
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
|
-
const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOptions, process.env.USE_OWN_LOCAL_BINARY_PROCESS === "true", process.env.REMOTE_MCP === "true"
|
|
49
|
+
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
|
|
50
|
+
? process.env.MCP_UPLOAD_BASE_DIR
|
|
51
|
+
: undefined);
|
|
48
52
|
export default config;
|
package/dist/lib/tm-base-url.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { apiClient } from "./apiClient.js";
|
|
2
2
|
import logger from "../logger.js";
|
|
3
3
|
import { getBrowserStackAuth } from "./get-auth.js";
|
|
4
|
+
import appConfig from "../config.js";
|
|
4
5
|
const TM_BASE_URLS = [
|
|
5
6
|
"https://test-management.browserstack.com",
|
|
6
7
|
"https://test-management-eu.browserstack.com",
|
|
@@ -8,7 +9,10 @@ const TM_BASE_URLS = [
|
|
|
8
9
|
];
|
|
9
10
|
let cachedBaseUrl = null;
|
|
10
11
|
export async function getTMBaseURL(config) {
|
|
11
|
-
|
|
12
|
+
// Skip the module-level cache in remote (multi-tenant) mode: it is process-shared,
|
|
13
|
+
// so the first user's region would be served to every subsequent user — breaking
|
|
14
|
+
// requests for users on a different region's BrowserStack account.
|
|
15
|
+
if (!appConfig.REMOTE_MCP && cachedBaseUrl) {
|
|
12
16
|
logger.debug(`Using cached TM base URL: ${cachedBaseUrl}`);
|
|
13
17
|
return cachedBaseUrl;
|
|
14
18
|
}
|
|
@@ -24,7 +28,11 @@ export async function getTMBaseURL(config) {
|
|
|
24
28
|
raise_error: false,
|
|
25
29
|
});
|
|
26
30
|
if (res.ok) {
|
|
27
|
-
|
|
31
|
+
// Only populate the cache in single-tenant (stdio) mode; in remote mode
|
|
32
|
+
// the cache must stay empty so each user discovers their own region.
|
|
33
|
+
if (!appConfig.REMOTE_MCP) {
|
|
34
|
+
cachedBaseUrl = baseUrl;
|
|
35
|
+
}
|
|
28
36
|
logger.info(`Selected TM base URL: ${baseUrl}`);
|
|
29
37
|
return baseUrl;
|
|
30
38
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface UploadValidationOptions {
|
|
2
|
+
allowedExtensions: readonly string[];
|
|
3
|
+
maxSizeBytes: number;
|
|
4
|
+
allowedBaseDir?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Canonicalizes and validates a user-supplied upload path. Returns the resolved
|
|
8
|
+
* absolute path that callers should stream from. Throws on any rule violation.
|
|
9
|
+
*
|
|
10
|
+
* Rules enforced:
|
|
11
|
+
* - Path resolves (via realpath) to an existing regular file
|
|
12
|
+
* - File size is within `maxSizeBytes`
|
|
13
|
+
* - File extension is in `allowedExtensions` (case-insensitive)
|
|
14
|
+
* - No path segment is a hidden dir/file (starts with `.`); blocks ~/.ssh,
|
|
15
|
+
* ~/.aws, .env, etc. even after symlink resolution
|
|
16
|
+
* - If `allowedBaseDir` is set, the canonical path must live inside it
|
|
17
|
+
*/
|
|
18
|
+
export declare function validateUploadPath(filePath: string, options: UploadValidationOptions): string;
|
|
19
|
+
export declare const APP_BINARY_EXTENSIONS: readonly [".apk", ".aab", ".ipa", ".app", ".zip"];
|
|
20
|
+
export declare const TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS: readonly [".pdf", ".txt", ".md", ".doc", ".docx", ".png", ".jpg", ".jpeg", ".gif", ".csv", ".xls", ".xlsx", ".json", ".html", ".zip"];
|
|
21
|
+
export declare const ONE_MB: number;
|
|
22
|
+
export declare const MAX_APP_UPLOAD_BYTES: number;
|
|
23
|
+
export declare const MAX_ATTACHMENT_UPLOAD_BYTES: number;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
/**
|
|
4
|
+
* Canonicalizes and validates a user-supplied upload path. Returns the resolved
|
|
5
|
+
* absolute path that callers should stream from. Throws on any rule violation.
|
|
6
|
+
*
|
|
7
|
+
* Rules enforced:
|
|
8
|
+
* - Path resolves (via realpath) to an existing regular file
|
|
9
|
+
* - File size is within `maxSizeBytes`
|
|
10
|
+
* - File extension is in `allowedExtensions` (case-insensitive)
|
|
11
|
+
* - No path segment is a hidden dir/file (starts with `.`); blocks ~/.ssh,
|
|
12
|
+
* ~/.aws, .env, etc. even after symlink resolution
|
|
13
|
+
* - If `allowedBaseDir` is set, the canonical path must live inside it
|
|
14
|
+
*/
|
|
15
|
+
export function validateUploadPath(filePath, options) {
|
|
16
|
+
if (typeof filePath !== "string" || filePath.trim().length === 0) {
|
|
17
|
+
throw new Error("Upload rejected: file path is empty.");
|
|
18
|
+
}
|
|
19
|
+
let canonical;
|
|
20
|
+
try {
|
|
21
|
+
canonical = fs.realpathSync(path.resolve(filePath));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error(`File not found at path: ${filePath}`);
|
|
25
|
+
}
|
|
26
|
+
let stats;
|
|
27
|
+
try {
|
|
28
|
+
stats = fs.statSync(canonical);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error(`File not found at path: ${filePath}`);
|
|
32
|
+
}
|
|
33
|
+
if (!stats.isFile()) {
|
|
34
|
+
throw new Error(`Upload rejected: path does not point to a regular file: ${filePath}`);
|
|
35
|
+
}
|
|
36
|
+
if (stats.size > options.maxSizeBytes) {
|
|
37
|
+
const maxMb = Math.round(options.maxSizeBytes / (1024 * 1024));
|
|
38
|
+
throw new Error(`Upload rejected: file exceeds maximum allowed size of ${maxMb} MB.`);
|
|
39
|
+
}
|
|
40
|
+
const segments = canonical.split(path.sep).filter((s) => s.length > 0);
|
|
41
|
+
for (const seg of segments) {
|
|
42
|
+
if (seg.startsWith(".") && seg !== "." && seg !== "..") {
|
|
43
|
+
throw new Error(`Upload rejected: path traverses a hidden directory or file ("${seg}"). Move the file to a non-hidden location or set MCP_UPLOAD_BASE_DIR.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const ext = path.extname(canonical).toLowerCase();
|
|
47
|
+
const allowed = options.allowedExtensions.map((e) => e.toLowerCase());
|
|
48
|
+
if (!allowed.includes(ext)) {
|
|
49
|
+
throw new Error(`Upload rejected: file extension "${ext || "(none)"}" is not in the allowed list (${allowed.join(", ")}).`);
|
|
50
|
+
}
|
|
51
|
+
if (options.allowedBaseDir) {
|
|
52
|
+
let baseCanonical;
|
|
53
|
+
try {
|
|
54
|
+
baseCanonical = fs.realpathSync(path.resolve(options.allowedBaseDir));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error(`Upload rejected: configured MCP_UPLOAD_BASE_DIR does not exist (${options.allowedBaseDir}).`);
|
|
58
|
+
}
|
|
59
|
+
const baseWithSep = baseCanonical.endsWith(path.sep)
|
|
60
|
+
? baseCanonical
|
|
61
|
+
: baseCanonical + path.sep;
|
|
62
|
+
if (canonical !== baseCanonical && !canonical.startsWith(baseWithSep)) {
|
|
63
|
+
throw new Error(`Upload rejected: file must be located inside ${baseCanonical}.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return canonical;
|
|
67
|
+
}
|
|
68
|
+
export const APP_BINARY_EXTENSIONS = [
|
|
69
|
+
".apk",
|
|
70
|
+
".aab",
|
|
71
|
+
".ipa",
|
|
72
|
+
".app",
|
|
73
|
+
".zip",
|
|
74
|
+
];
|
|
75
|
+
export const TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS = [
|
|
76
|
+
".pdf",
|
|
77
|
+
".txt",
|
|
78
|
+
".md",
|
|
79
|
+
".doc",
|
|
80
|
+
".docx",
|
|
81
|
+
".png",
|
|
82
|
+
".jpg",
|
|
83
|
+
".jpeg",
|
|
84
|
+
".gif",
|
|
85
|
+
".csv",
|
|
86
|
+
".xls",
|
|
87
|
+
".xlsx",
|
|
88
|
+
".json",
|
|
89
|
+
".html",
|
|
90
|
+
".zip",
|
|
91
|
+
];
|
|
92
|
+
export const ONE_MB = 1024 * 1024;
|
|
93
|
+
export const MAX_APP_UPLOAD_BYTES = 4 * 1024 * ONE_MB; // 4 GB — matches BrowserStack app upload limit
|
|
94
|
+
export const MAX_ATTACHMENT_UPLOAD_BYTES = 100 * ONE_MB; // 100 MB
|
|
@@ -144,7 +144,7 @@ async function createAuthConfig(args, config) {
|
|
|
144
144
|
async function executeCreateAuthConfig(args, server, config) {
|
|
145
145
|
try {
|
|
146
146
|
trackMCP("createAccessibilityAuthConfig", server.server.getClientVersion(), undefined, config);
|
|
147
|
-
logger.info(`Creating auth config: ${JSON.stringify(args)}`);
|
|
147
|
+
logger.info(`Creating auth config: ${JSON.stringify({ ...args, password: "***" })}`);
|
|
148
148
|
const result = await createAuthConfig(args, config);
|
|
149
149
|
return createSuccessResponse([
|
|
150
150
|
`✅ Auth config "${args.name}" created successfully with ID: ${result.data?.id}`,
|
|
@@ -38,6 +38,6 @@ export declare function uploadEspressoApp(appPath: string, config: BrowserStackC
|
|
|
38
38
|
export declare function uploadEspressoTestSuite(testSuitePath: string, config: BrowserStackConfig): Promise<string>;
|
|
39
39
|
export declare function uploadXcuiApp(appPath: string, config: BrowserStackConfig): Promise<string>;
|
|
40
40
|
export declare function uploadXcuiTestSuite(testSuitePath: string, config: BrowserStackConfig): Promise<string>;
|
|
41
|
-
export declare function triggerEspressoBuild(app_url: string, test_suite_url: string, devices: string[], project: string): Promise<string>;
|
|
41
|
+
export declare function triggerEspressoBuild(app_url: string, test_suite_url: string, devices: string[], project: string, config: BrowserStackConfig): Promise<string>;
|
|
42
42
|
export declare function triggerXcuiBuild(app_url: string, test_suite_url: string, devices: string[], project: string, config: BrowserStackConfig): Promise<string>;
|
|
43
43
|
export {};
|
|
@@ -2,6 +2,9 @@ import fs from "fs";
|
|
|
2
2
|
import FormData from "form-data";
|
|
3
3
|
import { apiClient } from "../../../lib/apiClient.js";
|
|
4
4
|
import { customFuzzySearch } from "../../../lib/fuzzy.js";
|
|
5
|
+
import { getBrowserStackAuth } from "../../../lib/get-auth.js";
|
|
6
|
+
import { validateUploadPath, APP_BINARY_EXTENSIONS, MAX_APP_UPLOAD_BYTES, } from "../../../lib/upload-validator.js";
|
|
7
|
+
import appConfig from "../../../config.js";
|
|
5
8
|
/**
|
|
6
9
|
* Finds devices that exactly match the provided display name.
|
|
7
10
|
* Uses fuzzy search first, and then filters for exact case-insensitive match.
|
|
@@ -73,12 +76,13 @@ export function validateArgs(args) {
|
|
|
73
76
|
* Uploads an application file to AppAutomate and returns the app URL
|
|
74
77
|
*/
|
|
75
78
|
export async function uploadApp(appPath, username, password) {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
const safePath = validateUploadPath(appPath, {
|
|
80
|
+
allowedExtensions: APP_BINARY_EXTENSIONS,
|
|
81
|
+
maxSizeBytes: MAX_APP_UPLOAD_BYTES,
|
|
82
|
+
allowedBaseDir: appConfig.UPLOAD_BASE_DIR,
|
|
83
|
+
});
|
|
80
84
|
const formData = new FormData();
|
|
81
|
-
formData.append("file", fs.createReadStream(
|
|
85
|
+
formData.append("file", fs.createReadStream(safePath));
|
|
82
86
|
const response = await apiClient.post({
|
|
83
87
|
url: "https://api-cloud.browserstack.com/app-automate/upload",
|
|
84
88
|
headers: {
|
|
@@ -95,12 +99,14 @@ export async function uploadApp(appPath, username, password) {
|
|
|
95
99
|
}
|
|
96
100
|
}
|
|
97
101
|
// Helper to upload a file to a given BrowserStack endpoint and return a specific property from the response.
|
|
98
|
-
async function uploadFileToBrowserStack(filePath, endpoint, responseKey, config) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
async function uploadFileToBrowserStack(filePath, endpoint, responseKey, config, validation) {
|
|
103
|
+
const safePath = validateUploadPath(filePath, {
|
|
104
|
+
allowedExtensions: validation.allowedExtensions,
|
|
105
|
+
maxSizeBytes: MAX_APP_UPLOAD_BYTES,
|
|
106
|
+
allowedBaseDir: appConfig.UPLOAD_BASE_DIR,
|
|
107
|
+
});
|
|
102
108
|
const formData = new FormData();
|
|
103
|
-
formData.append("file", fs.createReadStream(
|
|
109
|
+
formData.append("file", fs.createReadStream(safePath));
|
|
104
110
|
const authHeader = "Basic " +
|
|
105
111
|
Buffer.from(`${config["browserstack-username"]}:${config["browserstack-access-key"]}`).toString("base64");
|
|
106
112
|
const response = await apiClient.post({
|
|
@@ -118,31 +124,27 @@ async function uploadFileToBrowserStack(filePath, endpoint, responseKey, config)
|
|
|
118
124
|
}
|
|
119
125
|
//Uploads an Android app (.apk or .aab) to BrowserStack Espresso endpoint and returns the app_url
|
|
120
126
|
export async function uploadEspressoApp(appPath, config) {
|
|
121
|
-
return uploadFileToBrowserStack(appPath, "https://api-cloud.browserstack.com/app-automate/espresso/v2/app", "app_url", config);
|
|
127
|
+
return uploadFileToBrowserStack(appPath, "https://api-cloud.browserstack.com/app-automate/espresso/v2/app", "app_url", config, { allowedExtensions: [".apk", ".aab"] });
|
|
122
128
|
}
|
|
123
129
|
//Uploads an Espresso test suite (.apk) to BrowserStack and returns the test_suite_url
|
|
124
130
|
export async function uploadEspressoTestSuite(testSuitePath, config) {
|
|
125
|
-
return uploadFileToBrowserStack(testSuitePath, "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite", "test_suite_url", config);
|
|
131
|
+
return uploadFileToBrowserStack(testSuitePath, "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite", "test_suite_url", config, { allowedExtensions: [".apk"] });
|
|
126
132
|
}
|
|
127
133
|
//Uploads an iOS app (.ipa) to BrowserStack XCUITest endpoint and returns the app_url
|
|
128
134
|
export async function uploadXcuiApp(appPath, config) {
|
|
129
|
-
return uploadFileToBrowserStack(appPath, "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app", "app_url", config);
|
|
135
|
+
return uploadFileToBrowserStack(appPath, "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app", "app_url", config, { allowedExtensions: [".ipa"] });
|
|
130
136
|
}
|
|
131
137
|
//Uploads an XCUITest test suite (.zip) to BrowserStack and returns the test_suite_url
|
|
132
138
|
export async function uploadXcuiTestSuite(testSuitePath, config) {
|
|
133
|
-
return uploadFileToBrowserStack(testSuitePath, "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite", "test_suite_url", config);
|
|
139
|
+
return uploadFileToBrowserStack(testSuitePath, "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite", "test_suite_url", config, { allowedExtensions: [".zip"] });
|
|
134
140
|
}
|
|
135
141
|
// Triggers an Espresso test run on BrowserStack and returns the build_id
|
|
136
|
-
export async function triggerEspressoBuild(app_url, test_suite_url, devices, project) {
|
|
137
|
-
const
|
|
138
|
-
username: process.env.BROWSERSTACK_USERNAME || "",
|
|
139
|
-
password: process.env.BROWSERSTACK_ACCESS_KEY || "",
|
|
140
|
-
};
|
|
142
|
+
export async function triggerEspressoBuild(app_url, test_suite_url, devices, project, config) {
|
|
143
|
+
const authHeader = "Basic " + Buffer.from(getBrowserStackAuth(config)).toString("base64");
|
|
141
144
|
const response = await apiClient.post({
|
|
142
145
|
url: "https://api-cloud.browserstack.com/app-automate/espresso/v2/build",
|
|
143
146
|
headers: {
|
|
144
|
-
Authorization:
|
|
145
|
-
Buffer.from(`${auth.username}:${auth.password}`).toString("base64"),
|
|
147
|
+
Authorization: authHeader,
|
|
146
148
|
"Content-Type": "application/json",
|
|
147
149
|
},
|
|
148
150
|
body: {
|
|
@@ -133,7 +133,7 @@ async function runAppTestsOnBrowserStack(args, config) {
|
|
|
133
133
|
const [, deviceName, osVersion] = device;
|
|
134
134
|
return `${deviceName}-${osVersion}`;
|
|
135
135
|
});
|
|
136
|
-
const build_id = await triggerEspressoBuild(app_url, test_suite_url, deviceStrings, args.project);
|
|
136
|
+
const build_id = await triggerEspressoBuild(app_url, test_suite_url, deviceStrings, args.project, config);
|
|
137
137
|
return {
|
|
138
138
|
content: [
|
|
139
139
|
{
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { apiClient } from "../../lib/apiClient.js";
|
|
2
2
|
import FormData from "form-data";
|
|
3
3
|
import fs from "fs";
|
|
4
|
+
import { validateUploadPath, APP_BINARY_EXTENSIONS, MAX_APP_UPLOAD_BYTES, } from "../../lib/upload-validator.js";
|
|
5
|
+
import appConfig from "../../config.js";
|
|
4
6
|
export async function uploadApp(filePath, username, password) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
const safePath = validateUploadPath(filePath, {
|
|
8
|
+
allowedExtensions: APP_BINARY_EXTENSIONS,
|
|
9
|
+
maxSizeBytes: MAX_APP_UPLOAD_BYTES,
|
|
10
|
+
allowedBaseDir: appConfig.UPLOAD_BASE_DIR,
|
|
11
|
+
});
|
|
8
12
|
const formData = new FormData();
|
|
9
|
-
formData.append("file", fs.createReadStream(
|
|
13
|
+
formData.append("file", fs.createReadStream(safePath));
|
|
10
14
|
try {
|
|
11
15
|
const response = await apiClient.post({
|
|
12
16
|
url: "https://api-cloud.browserstack.com/app-live/upload",
|
package/dist/tools/percy-sdk.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { trackMCP } from "../index.js";
|
|
2
2
|
import { fetchPercyChanges } from "./review-agent.js";
|
|
3
3
|
import { addListTestFiles } from "./list-test-files.js";
|
|
4
|
+
import { runPercyScan } from "./run-percy-scan.js";
|
|
4
5
|
import { SetUpPercyParamsShape } from "./sdk-utils/common/schema.js";
|
|
5
6
|
import { updateTestsWithPercyCommands } from "./add-percy-snapshots.js";
|
|
6
7
|
import { approveOrDeclinePercyBuild } from "./review-agent-utils/percy-approve-reject.js";
|
|
@@ -8,10 +9,7 @@ import { setUpPercyHandler, simulatePercyChangeHandler, } from "./sdk-utils/hand
|
|
|
8
9
|
import { z } from "zod";
|
|
9
10
|
import { SETUP_PERCY_DESCRIPTION, LIST_TEST_FILES_DESCRIPTION, PERCY_SNAPSHOT_COMMANDS_DESCRIPTION, SIMULATE_PERCY_CHANGE_DESCRIPTION, } from "./sdk-utils/common/constants.js";
|
|
10
11
|
import { UpdateTestFileWithInstructionsParams } from "./percy-snapshot-utils/constants.js";
|
|
11
|
-
import {
|
|
12
|
-
// PMAA-100: kept commented so the registration block below is easy to restore once the proper fix lands.
|
|
13
|
-
// RunPercyScanParamsShape,
|
|
14
|
-
FetchPercyChangesParamsShape, ManagePercyBuildApprovalParamsShape, } from "./sdk-utils/common/schema.js";
|
|
12
|
+
import { RunPercyScanParamsShape, FetchPercyChangesParamsShape, ManagePercyBuildApprovalParamsShape, } from "./sdk-utils/common/schema.js";
|
|
15
13
|
import { handleMCPError } from "../lib/utils.js";
|
|
16
14
|
export function registerPercyTools(server, config) {
|
|
17
15
|
const tools = {};
|
|
@@ -68,22 +66,15 @@ export function registerPercyTools(server, config) {
|
|
|
68
66
|
return handleMCPError("listTestFiles", server, config, error);
|
|
69
67
|
}
|
|
70
68
|
});
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
// trackMCP("runPercyScan", server.server.getClientVersion()!, config);
|
|
81
|
-
// return runPercyScan(args, config);
|
|
82
|
-
// } catch (error) {
|
|
83
|
-
// return handleMCPError("runPercyScan", server, config, error);
|
|
84
|
-
// }
|
|
85
|
-
// },
|
|
86
|
-
// );
|
|
69
|
+
tools.runPercyScan = server.tool("runPercyScan", "Run a Percy visual test scan. Example prompts : Run this Percy build/scan. Never run percy scan/build without this tool", RunPercyScanParamsShape, async (args) => {
|
|
70
|
+
try {
|
|
71
|
+
trackMCP("runPercyScan", server.server.getClientVersion(), config);
|
|
72
|
+
return runPercyScan(args);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
return handleMCPError("runPercyScan", server, config, error);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
87
78
|
tools.fetchPercyChanges = server.tool("fetchPercyChanges", "Retrieves and summarizes all visual changes detected by Percy AI between the latest and previous builds, helping quickly review what has changed in your project.", FetchPercyChangesParamsShape, async (args) => {
|
|
88
79
|
try {
|
|
89
80
|
trackMCP("fetchPercyChanges", server.server.getClientVersion(), config);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
2
|
import { PercyIntegrationTypeEnum } from "./sdk-utils/common/types.js";
|
|
3
|
-
import { BrowserStackConfig } from "../lib/types.js";
|
|
4
3
|
export declare function runPercyScan(args: {
|
|
5
4
|
projectName: string;
|
|
6
5
|
integrationType: PercyIntegrationTypeEnum;
|
|
7
6
|
instruction?: string;
|
|
8
|
-
}
|
|
7
|
+
}): Promise<CallToolResult>;
|
|
@@ -1,14 +1,8 @@
|
|
|
1
|
-
import { getBrowserStackAuth } from "../lib/get-auth.js";
|
|
2
|
-
import { fetchPercyToken } from "./sdk-utils/percy-web/fetchPercyToken.js";
|
|
3
1
|
import { storedPercyResults } from "../lib/inmemory-store.js";
|
|
4
2
|
import { getFrameworkTestCommand, PERCY_FALLBACK_STEPS, } from "./sdk-utils/percy-web/constants.js";
|
|
5
3
|
import path from "path";
|
|
6
|
-
export async function runPercyScan(args
|
|
7
|
-
const { projectName,
|
|
8
|
-
const authorization = getBrowserStackAuth(config);
|
|
9
|
-
const percyToken = await fetchPercyToken(projectName, authorization, {
|
|
10
|
-
type: integrationType,
|
|
11
|
-
});
|
|
4
|
+
export async function runPercyScan(args) {
|
|
5
|
+
const { projectName, instruction } = args;
|
|
12
6
|
// Check if we have stored data and project matches
|
|
13
7
|
const stored = storedPercyResults.get();
|
|
14
8
|
// Compute if we have updated files to run
|
|
@@ -16,7 +10,7 @@ export async function runPercyScan(args, config) {
|
|
|
16
10
|
const updatedFiles = hasUpdatedFiles ? getUpdatedFiles(stored) : [];
|
|
17
11
|
// Build steps array with conditional spread
|
|
18
12
|
const steps = [
|
|
19
|
-
generatePercyTokenInstructions(
|
|
13
|
+
generatePercyTokenInstructions(),
|
|
20
14
|
...(hasUpdatedFiles ? generateUpdatedFilesSteps(stored, updatedFiles) : []),
|
|
21
15
|
...(instruction && !hasUpdatedFiles
|
|
22
16
|
? generateInstructionSteps(instruction)
|
|
@@ -35,12 +29,14 @@ export async function runPercyScan(args, config) {
|
|
|
35
29
|
],
|
|
36
30
|
};
|
|
37
31
|
}
|
|
38
|
-
function generatePercyTokenInstructions(
|
|
39
|
-
return `Set the environment variable for your project:
|
|
32
|
+
function generatePercyTokenInstructions() {
|
|
33
|
+
return `Set the PERCY_TOKEN environment variable for your project. Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token) and add it to your project's .env file (PERCY_TOKEN=<your Percy project token>) or export it in your shell:
|
|
40
34
|
|
|
41
|
-
export PERCY_TOKEN="
|
|
35
|
+
- macOS/Linux: export PERCY_TOKEN="<your Percy project token>"
|
|
36
|
+
- Windows (PS): $env:PERCY_TOKEN="<your Percy project token>"
|
|
37
|
+
- Windows (CMD): set PERCY_TOKEN=<your Percy project token>
|
|
42
38
|
|
|
43
|
-
|
|
39
|
+
Do not paste the token into chat or commit it.`;
|
|
44
40
|
}
|
|
45
41
|
const toAbs = (p) => p ? path.resolve(p) : undefined;
|
|
46
42
|
function checkForUpdatedFiles(stored, // storedPercyResults structure
|
|
@@ -33,7 +33,7 @@ const GRADLE_SETUP_INSTRUCTIONS = `
|
|
|
33
33
|
jvmArgs "-javaagent:\${browserstackSDKArtifact.file}"
|
|
34
34
|
`;
|
|
35
35
|
// Generates Maven archetype command for Windows platform
|
|
36
|
-
function getMavenCommandForWindows(
|
|
36
|
+
function getMavenCommandForWindows(username, accessKey, mavenFramework) {
|
|
37
37
|
return (`mvn archetype:generate -B ` +
|
|
38
38
|
`-DarchetypeGroupId="${MAVEN_ARCHETYPE_GROUP_ID}" ` +
|
|
39
39
|
`-DarchetypeArtifactId="${MAVEN_ARCHETYPE_ARTIFACT_ID}" ` +
|
|
@@ -41,8 +41,8 @@ function getMavenCommandForWindows(framework, mavenFramework) {
|
|
|
41
41
|
`-DgroupId="${MAVEN_ARCHETYPE_GROUP_ID}" ` +
|
|
42
42
|
`-DartifactId="${MAVEN_ARCHETYPE_ARTIFACT_ID}" ` +
|
|
43
43
|
`-Dversion="${MAVEN_ARCHETYPE_VERSION}" ` +
|
|
44
|
-
`-DBROWSERSTACK_USERNAME="${
|
|
45
|
-
`-DBROWSERSTACK_ACCESS_KEY="${
|
|
44
|
+
`-DBROWSERSTACK_USERNAME="${username}" ` +
|
|
45
|
+
`-DBROWSERSTACK_ACCESS_KEY="${accessKey}" ` +
|
|
46
46
|
`-DBROWSERSTACK_FRAMEWORK="${mavenFramework}"`);
|
|
47
47
|
}
|
|
48
48
|
// Generates Maven archetype command for Unix-like platforms (macOS/Linux)
|
|
@@ -60,7 +60,7 @@ function getJavaSDKInstructions(framework, username, accessKey) {
|
|
|
60
60
|
const isWindows = process.platform === "win32";
|
|
61
61
|
const platformLabel = isWindows ? "Windows" : "macOS/Linux";
|
|
62
62
|
const mavenCommand = isWindows
|
|
63
|
-
? getMavenCommandForWindows(
|
|
63
|
+
? getMavenCommandForWindows(username, accessKey, mavenFramework)
|
|
64
64
|
: getMavenCommandForUnix(username, accessKey, mavenFramework);
|
|
65
65
|
return `---STEP---
|
|
66
66
|
Install BrowserStack Java SDK
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { formatToolResult } from "./common/utils.js";
|
|
2
2
|
import { PercyIntegrationTypeEnum } from "./common/types.js";
|
|
3
|
-
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
4
|
-
import { fetchPercyToken } from "./percy-web/fetchPercyToken.js";
|
|
5
3
|
import { runPercyWeb } from "./percy-web/handler.js";
|
|
6
4
|
import { runPercyAutomateOnly } from "./percy-automate/handler.js";
|
|
7
5
|
import { runBstackSDKOnly } from "./bstack/sdkHandler.js";
|
|
@@ -32,7 +30,6 @@ export async function setUpPercyHandler(rawInput, config) {
|
|
|
32
30
|
filePaths: input.filePaths || [],
|
|
33
31
|
testFiles: {},
|
|
34
32
|
});
|
|
35
|
-
const authorization = getBrowserStackAuth(config);
|
|
36
33
|
const folderPaths = input.folderPaths || [];
|
|
37
34
|
const filePaths = input.filePaths || [];
|
|
38
35
|
const percyInput = {
|
|
@@ -50,9 +47,7 @@ export async function setUpPercyHandler(rawInput, config) {
|
|
|
50
47
|
if (!supportCheck.supported) {
|
|
51
48
|
return percyUnsupportedResult(PercyIntegrationTypeEnum.WEB, supportCheck);
|
|
52
49
|
}
|
|
53
|
-
|
|
54
|
-
const percyToken = await fetchPercyToken(input.projectName, authorization, { type: PercyIntegrationTypeEnum.WEB });
|
|
55
|
-
const result = runPercyWeb(percyInput, percyToken);
|
|
50
|
+
const result = runPercyWeb(percyInput);
|
|
56
51
|
return await formatToolResult(result, "percy-web");
|
|
57
52
|
}
|
|
58
53
|
else if (input.integrationType === PercyIntegrationTypeEnum.AUTOMATE) {
|
|
@@ -93,8 +88,7 @@ export async function setUpPercyHandler(rawInput, config) {
|
|
|
93
88
|
};
|
|
94
89
|
const sdkResult = await runBstackSDKOnly(sdkInput, config, true);
|
|
95
90
|
// Percy Automate instructions
|
|
96
|
-
const
|
|
97
|
-
const percyAutomateResult = runPercyAutomateOnly(percyInput, percyToken);
|
|
91
|
+
const percyAutomateResult = runPercyAutomateOnly(percyInput);
|
|
98
92
|
// Combine steps: warning, SDK steps, Percy Automate steps
|
|
99
93
|
const steps = [
|
|
100
94
|
{
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { RunTestsInstructionResult } from "../common/types.js";
|
|
2
2
|
import { SetUpPercyInput } from "../common/schema.js";
|
|
3
|
-
export declare function runPercyAutomateOnly(input: SetUpPercyInput
|
|
3
|
+
export declare function runPercyAutomateOnly(input: SetUpPercyInput): RunTestsInstructionResult;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SUPPORTED_CONFIGURATIONS } from "./frameworks.js";
|
|
2
|
-
export function runPercyAutomateOnly(input
|
|
2
|
+
export function runPercyAutomateOnly(input) {
|
|
3
3
|
const steps = [];
|
|
4
4
|
// Assume configuration is supported due to guardrails at orchestration layer
|
|
5
5
|
const languageConfig = SUPPORTED_CONFIGURATIONS[input.detectedLanguage];
|
|
@@ -15,7 +15,7 @@ export function runPercyAutomateOnly(input, percyToken) {
|
|
|
15
15
|
steps.push({
|
|
16
16
|
type: "instruction",
|
|
17
17
|
title: "Set Percy Token in Environment",
|
|
18
|
-
content: `
|
|
18
|
+
content: `Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token) and add it to your project's .env file (PERCY_TOKEN=<your Percy project token>) or export it in your shell (e.g. export PERCY_TOKEN="<your Percy project token>"). Do not paste the token into chat or commit it.`,
|
|
19
19
|
});
|
|
20
20
|
steps.push({
|
|
21
21
|
type: "instruction",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { RunTestsInstructionResult } from "../common/types.js";
|
|
2
2
|
import { SetUpPercyInput } from "../common/schema.js";
|
|
3
3
|
export declare let percyWebSetupInstructions: string;
|
|
4
|
-
export declare function runPercyWeb(input: SetUpPercyInput
|
|
4
|
+
export declare function runPercyWeb(input: SetUpPercyInput): RunTestsInstructionResult;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SUPPORTED_CONFIGURATIONS } from "./frameworks.js";
|
|
2
2
|
export let percyWebSetupInstructions = "";
|
|
3
|
-
export function runPercyWeb(input
|
|
3
|
+
export function runPercyWeb(input) {
|
|
4
4
|
const steps = [];
|
|
5
5
|
// Assume configuration is supported due to guardrails at orchestration layer
|
|
6
6
|
const languageConfig = SUPPORTED_CONFIGURATIONS[input.detectedLanguage];
|
|
@@ -12,9 +12,12 @@ export function runPercyWeb(input, percyToken) {
|
|
|
12
12
|
steps.push({
|
|
13
13
|
type: "instruction",
|
|
14
14
|
title: "Set Percy Token in Environment",
|
|
15
|
-
content: `
|
|
16
|
-
export PERCY_TOKEN="
|
|
17
|
-
(
|
|
15
|
+
content: `Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token) and add it to your project's .env file (PERCY_TOKEN=<your Percy project token>) or export it in your shell:
|
|
16
|
+
macOS/Linux: export PERCY_TOKEN="<your Percy project token>"
|
|
17
|
+
Windows (PS): $env:PERCY_TOKEN="<your Percy project token>"
|
|
18
|
+
Windows (CMD): set PERCY_TOKEN=<your Percy project token>
|
|
19
|
+
|
|
20
|
+
Do not paste the token into chat or commit it.`,
|
|
18
21
|
});
|
|
19
22
|
steps.push({
|
|
20
23
|
type: "instruction",
|
|
@@ -141,7 +141,7 @@ export async function updateTestCase(params, config) {
|
|
|
141
141
|
if (params.preconditions !== undefined)
|
|
142
142
|
testCaseBody.preconditions = params.preconditions;
|
|
143
143
|
if (params.test_case_steps !== undefined)
|
|
144
|
-
testCaseBody.
|
|
144
|
+
testCaseBody.test_case_steps = params.test_case_steps;
|
|
145
145
|
if (params.owner !== undefined)
|
|
146
146
|
testCaseBody.owner = params.owner;
|
|
147
147
|
if (params.status !== undefined)
|
|
@@ -8,6 +8,8 @@ import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
|
8
8
|
import { signedUrlMap } from "../../lib/inmemory-store.js";
|
|
9
9
|
import { projectIdentifierToId } from "./TCG-utils/api.js";
|
|
10
10
|
import { getTMBaseURL } from "../../lib/tm-base-url.js";
|
|
11
|
+
import { validateUploadPath, TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS, MAX_ATTACHMENT_UPLOAD_BYTES, } from "../../lib/upload-validator.js";
|
|
12
|
+
import appConfig from "../../config.js";
|
|
11
13
|
/**
|
|
12
14
|
* Schema for the upload file tool
|
|
13
15
|
*/
|
|
@@ -25,22 +27,17 @@ export const UploadFileSchema = z.object({
|
|
|
25
27
|
export async function uploadFile(args, config) {
|
|
26
28
|
const { project_identifier, file_path } = args;
|
|
27
29
|
try {
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
],
|
|
37
|
-
isError: true,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
30
|
+
// Canonicalize path and enforce upload safety rules (extension, size,
|
|
31
|
+
// hidden-directory traversal, optional base-dir containment).
|
|
32
|
+
const safePath = validateUploadPath(file_path, {
|
|
33
|
+
allowedExtensions: TEST_MANAGEMENT_ATTACHMENT_EXTENSIONS,
|
|
34
|
+
maxSizeBytes: MAX_ATTACHMENT_UPLOAD_BYTES,
|
|
35
|
+
allowedBaseDir: appConfig.UPLOAD_BASE_DIR,
|
|
36
|
+
});
|
|
40
37
|
// Get the project ID
|
|
41
38
|
const projectIdResponse = await projectIdentifierToId(project_identifier, config);
|
|
42
39
|
const formData = new FormData();
|
|
43
|
-
formData.append("attachments[]", fs.createReadStream(
|
|
40
|
+
formData.append("attachments[]", fs.createReadStream(safePath));
|
|
44
41
|
const tmBaseUrl = await getTMBaseURL(config);
|
|
45
42
|
const uploadUrl = `${tmBaseUrl}/api/v1/projects/${projectIdResponse}/generic/attachments/ai_uploads`;
|
|
46
43
|
const response = await apiClient.post({
|