@browserstack/mcp-server 1.0.13 → 1.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/dist/config.js +2 -6
- package/dist/index.js +29 -32
- package/dist/lib/api.js +3 -57
- package/dist/lib/constants.js +14 -0
- package/dist/lib/device-cache.js +32 -33
- package/dist/lib/error.js +3 -6
- package/dist/lib/fuzzy.js +1 -4
- package/dist/lib/inmemory-store.js +1 -0
- package/dist/lib/instrumentation.js +12 -18
- package/dist/lib/local.js +27 -35
- package/dist/lib/utils.js +29 -4
- package/dist/logger.js +4 -9
- package/dist/tools/accessibility.js +51 -21
- package/dist/tools/accessiblity-utils/report-fetcher.js +28 -0
- package/dist/tools/accessiblity-utils/report-parser.js +51 -0
- package/dist/tools/accessiblity-utils/scanner.js +80 -0
- package/dist/tools/appautomate-utils/appautomate.js +95 -0
- package/dist/tools/appautomate.js +116 -0
- package/dist/tools/applive-utils/fuzzy-search.js +3 -6
- package/dist/tools/applive-utils/start-session.js +14 -20
- package/dist/tools/applive-utils/upload-app.js +12 -51
- package/dist/tools/applive.js +18 -25
- package/dist/tools/automate-utils/fetch-screenshots.js +59 -0
- package/dist/tools/automate.js +44 -37
- package/dist/tools/bstack-sdk.js +14 -18
- package/dist/tools/failurelogs-utils/app-automate.js +88 -0
- package/dist/tools/failurelogs-utils/automate.js +97 -0
- package/dist/tools/getFailureLogs.js +173 -0
- package/dist/tools/live-utils/desktop-filter.js +8 -11
- package/dist/tools/live-utils/mobile-filter.js +7 -10
- package/dist/tools/live-utils/start-session.js +17 -23
- package/dist/tools/live-utils/types.js +2 -5
- package/dist/tools/live-utils/version-resolver.js +1 -4
- package/dist/tools/live.js +23 -29
- package/dist/tools/observability.js +12 -19
- package/dist/tools/sdk-utils/constants.js +3 -9
- package/dist/tools/sdk-utils/instructions.js +4 -9
- package/dist/tools/sdk-utils/types.js +1 -2
- package/dist/tools/testmanagement-utils/TCG-utils/api.js +259 -0
- package/dist/tools/testmanagement-utils/TCG-utils/config.js +5 -0
- package/dist/tools/testmanagement-utils/TCG-utils/helpers.js +53 -0
- package/dist/tools/testmanagement-utils/TCG-utils/types.js +8 -0
- package/dist/tools/testmanagement-utils/add-test-result.js +18 -25
- package/dist/tools/testmanagement-utils/create-project-folder.js +21 -28
- package/dist/tools/testmanagement-utils/create-testcase.js +30 -38
- package/dist/tools/testmanagement-utils/create-testrun.js +23 -30
- package/dist/tools/testmanagement-utils/list-testcases.js +16 -23
- package/dist/tools/testmanagement-utils/list-testruns.js +13 -20
- package/dist/tools/testmanagement-utils/testcase-from-file.js +42 -0
- package/dist/tools/testmanagement-utils/update-testrun.js +16 -23
- package/dist/tools/testmanagement-utils/upload-file.js +101 -0
- package/dist/tools/testmanagement.js +98 -61
- package/package.json +13 -7
- package/dist/tools/accessiblity-utils/accessibility.js +0 -82
|
@@ -1,36 +1,66 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AccessibilityScanner } from "./accessiblity-utils/scanner.js";
|
|
3
|
+
import { AccessibilityReportFetcher } from "./accessiblity-utils/report-fetcher.js";
|
|
4
|
+
import { trackMCP } from "../lib/instrumentation.js";
|
|
5
|
+
import { parseAccessibilityReportFromCSV } from "./accessiblity-utils/report-parser.js";
|
|
6
|
+
const scanner = new AccessibilityScanner();
|
|
7
|
+
const reportFetcher = new AccessibilityReportFetcher();
|
|
8
|
+
async function runAccessibilityScan(name, pageURL, context) {
|
|
9
|
+
// Start scan
|
|
10
|
+
const startResp = await scanner.startScan(name, [pageURL]);
|
|
11
|
+
const scanId = startResp.data.id;
|
|
12
|
+
const scanRunId = startResp.data.scanRunId;
|
|
13
|
+
// Notify scan start
|
|
14
|
+
await context.sendNotification({
|
|
15
|
+
method: "notifications/progress",
|
|
16
|
+
params: {
|
|
17
|
+
progressToken: context._meta?.progressToken ?? "NOT_FOUND",
|
|
18
|
+
message: `Accessibility scan "${name}" started`,
|
|
19
|
+
progress: 0,
|
|
20
|
+
total: 100,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
// Wait until scan completes
|
|
24
|
+
const status = await scanner.waitUntilComplete(scanId, scanRunId, context);
|
|
25
|
+
if (status !== "completed") {
|
|
26
|
+
return {
|
|
27
|
+
content: [
|
|
28
|
+
{
|
|
29
|
+
type: "text",
|
|
30
|
+
text: `❌ Accessibility scan "${name}" failed with status: ${status} , check the BrowserStack dashboard for more details [https://scanner.browserstack.com/site-scanner/scan-details/${name}].`,
|
|
31
|
+
isError: true,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
isError: true,
|
|
35
|
+
};
|
|
13
36
|
}
|
|
37
|
+
// Fetch CSV report link
|
|
38
|
+
const reportLink = await reportFetcher.getReportLink(scanId, scanRunId);
|
|
39
|
+
const { records } = await parseAccessibilityReportFromCSV(reportLink);
|
|
14
40
|
return {
|
|
15
41
|
content: [
|
|
16
42
|
{
|
|
17
43
|
type: "text",
|
|
18
|
-
text:
|
|
44
|
+
text: `✅ Accessibility scan "${name}" completed. check the BrowserStack dashboard for more details [https://scanner.browserstack.com/site-scanner/scan-details/${name}].`,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: "text",
|
|
48
|
+
text: `Scan results: ${JSON.stringify(records, null, 2)}`,
|
|
19
49
|
},
|
|
20
50
|
],
|
|
21
51
|
};
|
|
22
52
|
}
|
|
23
|
-
function addAccessibilityTools(server) {
|
|
24
|
-
server.tool("startAccessibilityScan", "
|
|
25
|
-
name:
|
|
26
|
-
pageURL:
|
|
27
|
-
}, async (args) => {
|
|
53
|
+
export default function addAccessibilityTools(server) {
|
|
54
|
+
server.tool("startAccessibilityScan", "Start an accessibility scan via BrowserStack and retrieve a local CSV report path.", {
|
|
55
|
+
name: z.string().describe("Name of the accessibility scan"),
|
|
56
|
+
pageURL: z.string().describe("The URL to scan for accessibility issues"),
|
|
57
|
+
}, async (args, context) => {
|
|
28
58
|
try {
|
|
29
|
-
|
|
30
|
-
return await runAccessibilityScan(args.name, args.pageURL);
|
|
59
|
+
trackMCP("startAccessibilityScan", server.server.getClientVersion());
|
|
60
|
+
return await runAccessibilityScan(args.name, args.pageURL, context);
|
|
31
61
|
}
|
|
32
62
|
catch (error) {
|
|
33
|
-
|
|
63
|
+
trackMCP("startAccessibilityScan", server.server.getClientVersion(), error);
|
|
34
64
|
return {
|
|
35
65
|
content: [
|
|
36
66
|
{
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import config from "../../config.js";
|
|
3
|
+
export class AccessibilityReportFetcher {
|
|
4
|
+
auth = {
|
|
5
|
+
username: config.browserstackUsername,
|
|
6
|
+
password: config.browserstackAccessKey,
|
|
7
|
+
};
|
|
8
|
+
async getReportLink(scanId, scanRunId) {
|
|
9
|
+
// Initiate CSV link generation
|
|
10
|
+
const initUrl = `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?scan_run_id=${scanRunId}`;
|
|
11
|
+
const initResp = await axios.get(initUrl, {
|
|
12
|
+
auth: this.auth,
|
|
13
|
+
});
|
|
14
|
+
if (!initResp.data.success) {
|
|
15
|
+
throw new Error(`Failed to initiate report: ${initResp.data.error || initResp.data.data.message}`);
|
|
16
|
+
}
|
|
17
|
+
const taskId = initResp.data.data.task_id;
|
|
18
|
+
// Fetch the generated CSV link
|
|
19
|
+
const reportUrl = `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?task_id=${encodeURIComponent(taskId)}`;
|
|
20
|
+
const reportResp = await axios.get(reportUrl, {
|
|
21
|
+
auth: this.auth,
|
|
22
|
+
});
|
|
23
|
+
if (!reportResp.data.success) {
|
|
24
|
+
throw new Error(`Failed to fetch report: ${reportResp.data.error}`);
|
|
25
|
+
}
|
|
26
|
+
return reportResp.data.data.reportLink;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fetch from "node-fetch";
|
|
2
|
+
import { parse } from "csv-parse/sync";
|
|
3
|
+
export async function parseAccessibilityReportFromCSV(reportLink, { maxCharacterLength = 10_000, nextPage = 0 } = {}) {
|
|
4
|
+
// 1) Download & parse
|
|
5
|
+
const res = await fetch(reportLink);
|
|
6
|
+
if (!res.ok)
|
|
7
|
+
throw new Error(`Failed to download report: ${res.statusText}`);
|
|
8
|
+
const text = await res.text();
|
|
9
|
+
const all = parse(text, {
|
|
10
|
+
columns: true,
|
|
11
|
+
skip_empty_lines: true,
|
|
12
|
+
}).map((row) => ({
|
|
13
|
+
issue_type: row["Issue type"],
|
|
14
|
+
component: row["Component"],
|
|
15
|
+
issue_description: row["Issue description"],
|
|
16
|
+
HTML_snippet: row["HTML snippet"],
|
|
17
|
+
how_to_fix: row["How to fix this issue"],
|
|
18
|
+
severity: (row["Severity"] || "unknown").trim(),
|
|
19
|
+
}));
|
|
20
|
+
// 2) Sort by severity
|
|
21
|
+
const order = {
|
|
22
|
+
critical: 0,
|
|
23
|
+
serious: 1,
|
|
24
|
+
moderate: 2,
|
|
25
|
+
minor: 3,
|
|
26
|
+
};
|
|
27
|
+
all.sort((a, b) => (order[a.severity] ?? 99) - (order[b.severity] ?? 99));
|
|
28
|
+
// 3) Walk to the starting offset
|
|
29
|
+
let charCursor = 0;
|
|
30
|
+
let idx = 0;
|
|
31
|
+
for (; idx < all.length; idx++) {
|
|
32
|
+
const len = JSON.stringify(all[idx]).length;
|
|
33
|
+
if (charCursor + len > nextPage)
|
|
34
|
+
break;
|
|
35
|
+
charCursor += len;
|
|
36
|
+
}
|
|
37
|
+
// 4) Collect up to maxCharacterLength
|
|
38
|
+
const page = [];
|
|
39
|
+
for (let i = idx; i < all.length; i++) {
|
|
40
|
+
const recStr = JSON.stringify(all[i]);
|
|
41
|
+
if (charCursor - nextPage + recStr.length > maxCharacterLength)
|
|
42
|
+
break;
|
|
43
|
+
page.push(all[i]);
|
|
44
|
+
charCursor += recStr.length;
|
|
45
|
+
}
|
|
46
|
+
const hasMore = idx + page.length < all.length;
|
|
47
|
+
return {
|
|
48
|
+
records: page,
|
|
49
|
+
next_page: hasMore ? charCursor : null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import config from "../../config.js";
|
|
3
|
+
export class AccessibilityScanner {
|
|
4
|
+
auth = {
|
|
5
|
+
username: config.browserstackUsername,
|
|
6
|
+
password: config.browserstackAccessKey,
|
|
7
|
+
};
|
|
8
|
+
async startScan(name, urlList) {
|
|
9
|
+
try {
|
|
10
|
+
const { data } = await axios.post("https://api-accessibility.browserstack.com/api/website-scanner/v1/scans", { name, urlList, recurring: false }, { auth: this.auth });
|
|
11
|
+
if (!data.success)
|
|
12
|
+
throw new Error(`Unable to start scan: ${data.errors?.join(", ")}`);
|
|
13
|
+
return data;
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
if (axios.isAxiosError(err) && err.response?.data) {
|
|
17
|
+
const msg = err.response.data.error ||
|
|
18
|
+
err.response.data.message ||
|
|
19
|
+
err.message;
|
|
20
|
+
throw new Error(`Failed to start scan: ${msg}`);
|
|
21
|
+
}
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async pollStatus(scanId, scanRunId) {
|
|
26
|
+
try {
|
|
27
|
+
const { data } = await axios.get(`https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/${scanRunId}/status`, { auth: this.auth });
|
|
28
|
+
if (!data.success)
|
|
29
|
+
throw new Error(`Failed to get status: ${data.errors?.join(", ")}`);
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (axios.isAxiosError(err) && err.response?.data) {
|
|
34
|
+
const msg = err.response.data.message || err.message;
|
|
35
|
+
throw new Error(`Failed to get scan status: ${msg}`);
|
|
36
|
+
}
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async waitUntilComplete(scanId, scanRunId, context) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
let timepercent = 0;
|
|
43
|
+
let dotCount = 1;
|
|
44
|
+
const interval = setInterval(async () => {
|
|
45
|
+
try {
|
|
46
|
+
const statusResp = await this.pollStatus(scanId, scanRunId);
|
|
47
|
+
const status = statusResp.data.status;
|
|
48
|
+
timepercent += 1.67;
|
|
49
|
+
const progress = status === "completed" ? 100 : timepercent;
|
|
50
|
+
const dots = ".".repeat(dotCount);
|
|
51
|
+
dotCount = (dotCount % 4) + 1;
|
|
52
|
+
const message = status === "completed" || status === "failed"
|
|
53
|
+
? `Scan completed with status: ${status}`
|
|
54
|
+
: `Scan in progress${dots}`;
|
|
55
|
+
await context.sendNotification({
|
|
56
|
+
method: "notifications/progress",
|
|
57
|
+
params: {
|
|
58
|
+
progressToken: context._meta?.progressToken ?? "NOT_FOUND",
|
|
59
|
+
message: message,
|
|
60
|
+
progress: progress,
|
|
61
|
+
total: 100,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
if (status === "completed" || status === "failed") {
|
|
65
|
+
clearInterval(interval);
|
|
66
|
+
resolve(status);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
clearInterval(interval);
|
|
71
|
+
reject(e);
|
|
72
|
+
}
|
|
73
|
+
}, 5000);
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
clearInterval(interval);
|
|
76
|
+
reject(new Error("Scan timed out after 5 minutes"));
|
|
77
|
+
}, 5 * 60 * 1000);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import config from "../../config.js";
|
|
4
|
+
import FormData from "form-data";
|
|
5
|
+
import { customFuzzySearch } from "../../lib/fuzzy.js";
|
|
6
|
+
/**
|
|
7
|
+
* Finds devices that exactly match the provided display name.
|
|
8
|
+
* Uses fuzzy search first, and then filters for exact case-insensitive match.
|
|
9
|
+
*/
|
|
10
|
+
export function findMatchingDevice(devices, deviceName) {
|
|
11
|
+
const matches = customFuzzySearch(devices, ["display_name"], deviceName, 5);
|
|
12
|
+
if (matches.length === 0) {
|
|
13
|
+
const availableDevices = [
|
|
14
|
+
...new Set(devices.map((d) => d.display_name)),
|
|
15
|
+
].join(", ");
|
|
16
|
+
throw new Error(`No devices found matching "${deviceName}". Available devices: ${availableDevices}`);
|
|
17
|
+
}
|
|
18
|
+
const exactMatches = matches.filter((m) => m.display_name.toLowerCase() === deviceName.toLowerCase());
|
|
19
|
+
if (exactMatches.length === 0) {
|
|
20
|
+
const suggestions = [...new Set(matches.map((d) => d.display_name))].join(", ");
|
|
21
|
+
throw new Error(`Alternative devices found: ${suggestions}. Please select one of these exact device names.`);
|
|
22
|
+
}
|
|
23
|
+
return exactMatches;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Extracts all unique OS versions from a device list and sorts them.
|
|
27
|
+
*/
|
|
28
|
+
export function getDeviceVersions(devices) {
|
|
29
|
+
return [...new Set(devices.map((d) => d.os_version))].sort();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolves the requested platform version against available versions.
|
|
33
|
+
* Supports 'latest' and 'oldest' as dynamic selectors.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveVersion(versions, requestedVersion) {
|
|
36
|
+
if (requestedVersion === "latest") {
|
|
37
|
+
return versions[versions.length - 1];
|
|
38
|
+
}
|
|
39
|
+
if (requestedVersion === "oldest") {
|
|
40
|
+
return versions[0];
|
|
41
|
+
}
|
|
42
|
+
const match = versions.find((v) => v === requestedVersion);
|
|
43
|
+
if (!match) {
|
|
44
|
+
throw new Error(`Version "${requestedVersion}" not found. Available versions: ${versions.join(", ")}`);
|
|
45
|
+
}
|
|
46
|
+
return match;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Validates the input arguments for taking app screenshots.
|
|
50
|
+
* Checks for presence and correctness of platform, device, and file types.
|
|
51
|
+
*/
|
|
52
|
+
export function validateArgs(args) {
|
|
53
|
+
const { desiredPlatform, desiredPlatformVersion, appPath, desiredPhone } = args;
|
|
54
|
+
if (!desiredPlatform || !desiredPhone) {
|
|
55
|
+
throw new Error("Missing required arguments: desiredPlatform and desiredPhone are required");
|
|
56
|
+
}
|
|
57
|
+
if (!desiredPlatformVersion) {
|
|
58
|
+
throw new Error("Missing required arguments: desiredPlatformVersion is required");
|
|
59
|
+
}
|
|
60
|
+
if (!appPath) {
|
|
61
|
+
throw new Error("You must provide an appPath.");
|
|
62
|
+
}
|
|
63
|
+
if (desiredPlatform === "android" && !appPath.endsWith(".apk")) {
|
|
64
|
+
throw new Error("You must provide a valid Android app path (.apk).");
|
|
65
|
+
}
|
|
66
|
+
if (desiredPlatform === "ios" && !appPath.endsWith(".ipa")) {
|
|
67
|
+
throw new Error("You must provide a valid iOS app path (.ipa).");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Uploads an application file to AppAutomate and returns the app URL
|
|
72
|
+
*/
|
|
73
|
+
export async function uploadApp(appPath) {
|
|
74
|
+
const filePath = appPath;
|
|
75
|
+
if (!fs.existsSync(filePath)) {
|
|
76
|
+
throw new Error(`File not found at path: ${filePath}`);
|
|
77
|
+
}
|
|
78
|
+
const formData = new FormData();
|
|
79
|
+
formData.append("file", fs.createReadStream(filePath));
|
|
80
|
+
const response = await axios.post("https://api-cloud.browserstack.com/app-automate/upload", formData, {
|
|
81
|
+
headers: {
|
|
82
|
+
...formData.getHeaders(),
|
|
83
|
+
},
|
|
84
|
+
auth: {
|
|
85
|
+
username: config.browserstackUsername,
|
|
86
|
+
password: config.browserstackAccessKey,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
if (response.data.app_url) {
|
|
90
|
+
return response.data.app_url;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
throw new Error(`Failed to upload app: ${response.data}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import logger from "../logger.js";
|
|
3
|
+
import config from "../config.js";
|
|
4
|
+
import { trackMCP } from "../lib/instrumentation.js";
|
|
5
|
+
import { maybeCompressBase64 } from "../lib/utils.js";
|
|
6
|
+
import { remote } from "webdriverio";
|
|
7
|
+
import { getDevicesAndBrowsers, BrowserStackProducts, } from "../lib/device-cache.js";
|
|
8
|
+
import { findMatchingDevice, getDeviceVersions, resolveVersion, validateArgs, uploadApp, } from "./appautomate-utils/appautomate.js";
|
|
9
|
+
var Platform;
|
|
10
|
+
(function (Platform) {
|
|
11
|
+
Platform["ANDROID"] = "android";
|
|
12
|
+
Platform["IOS"] = "ios";
|
|
13
|
+
})(Platform || (Platform = {}));
|
|
14
|
+
/**
|
|
15
|
+
* Launches an app on a selected BrowserStack device and takes a screenshot.
|
|
16
|
+
*/
|
|
17
|
+
async function takeAppScreenshot(args) {
|
|
18
|
+
let driver;
|
|
19
|
+
try {
|
|
20
|
+
validateArgs(args);
|
|
21
|
+
const { desiredPlatform, desiredPhone, appPath } = args;
|
|
22
|
+
let { desiredPlatformVersion } = args;
|
|
23
|
+
const platforms = (await getDevicesAndBrowsers(BrowserStackProducts.APP_AUTOMATE)).mobile;
|
|
24
|
+
const platformData = platforms.find((p) => p.os === desiredPlatform.toLowerCase());
|
|
25
|
+
if (!platformData) {
|
|
26
|
+
throw new Error(`Platform ${desiredPlatform} not found in device cache.`);
|
|
27
|
+
}
|
|
28
|
+
const matchingDevices = findMatchingDevice(platformData.devices, desiredPhone);
|
|
29
|
+
const availableVersions = getDeviceVersions(matchingDevices);
|
|
30
|
+
desiredPlatformVersion = resolveVersion(availableVersions, desiredPlatformVersion);
|
|
31
|
+
const selectedDevice = matchingDevices.find((d) => d.os_version === desiredPlatformVersion);
|
|
32
|
+
if (!selectedDevice) {
|
|
33
|
+
throw new Error(`Device "${desiredPhone}" with version ${desiredPlatformVersion} not found.`);
|
|
34
|
+
}
|
|
35
|
+
const app_url = await uploadApp(appPath);
|
|
36
|
+
logger.info(`App uploaded. URL: ${app_url}`);
|
|
37
|
+
const capabilities = {
|
|
38
|
+
platformName: desiredPlatform,
|
|
39
|
+
"appium:platformVersion": selectedDevice.os_version,
|
|
40
|
+
"appium:deviceName": selectedDevice.device,
|
|
41
|
+
"appium:app": app_url,
|
|
42
|
+
"appium:autoGrantPermissions": true,
|
|
43
|
+
"bstack:options": {
|
|
44
|
+
userName: config.browserstackUsername,
|
|
45
|
+
accessKey: config.browserstackAccessKey,
|
|
46
|
+
appiumVersion: "2.0.1",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
logger.info("Starting WebDriver session on BrowserStack...");
|
|
50
|
+
driver = await remote({
|
|
51
|
+
protocol: "https",
|
|
52
|
+
hostname: "hub.browserstack.com",
|
|
53
|
+
port: 443,
|
|
54
|
+
path: "/wd/hub",
|
|
55
|
+
capabilities,
|
|
56
|
+
});
|
|
57
|
+
const screenshotBase64 = await driver.takeScreenshot();
|
|
58
|
+
const compressed = await maybeCompressBase64(screenshotBase64);
|
|
59
|
+
return {
|
|
60
|
+
content: [
|
|
61
|
+
{
|
|
62
|
+
type: "image",
|
|
63
|
+
data: compressed,
|
|
64
|
+
mimeType: "image/png",
|
|
65
|
+
name: `screenshot-${selectedDevice.device}-${Date.now()}`,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
logger.error("Error during app automation or screenshot capture", error);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
if (driver) {
|
|
76
|
+
logger.info("Cleaning up WebDriver session...");
|
|
77
|
+
await driver.deleteSession();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Registers the `takeAppScreenshot` tool with the MCP server.
|
|
83
|
+
*/
|
|
84
|
+
export default function addAppAutomationTools(server) {
|
|
85
|
+
server.tool("takeAppScreenshot", "Use this tool to take a screenshot of an app running on a BrowserStack device. This is useful for visual testing and debugging.", {
|
|
86
|
+
desiredPhone: z
|
|
87
|
+
.string()
|
|
88
|
+
.describe("The full name of the device to run the app on. Example: 'iPhone 12 Pro' or 'Samsung Galaxy S20'. Always ask the user for the device they want to use."),
|
|
89
|
+
desiredPlatformVersion: z
|
|
90
|
+
.string()
|
|
91
|
+
.describe("The platform version to run the app on. Use 'latest' or 'oldest' for dynamic resolution."),
|
|
92
|
+
desiredPlatform: z
|
|
93
|
+
.enum([Platform.ANDROID, Platform.IOS])
|
|
94
|
+
.describe("Platform to run the app on. Either 'android' or 'ios'."),
|
|
95
|
+
appPath: z
|
|
96
|
+
.string()
|
|
97
|
+
.describe("The path to the .apk or .ipa file. Required for app installation."),
|
|
98
|
+
}, async (args) => {
|
|
99
|
+
try {
|
|
100
|
+
trackMCP("takeAppScreenshot", server.server.getClientVersion());
|
|
101
|
+
return await takeAppScreenshot(args);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
trackMCP("takeAppScreenshot", server.server.getClientVersion(), error);
|
|
105
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
106
|
+
return {
|
|
107
|
+
content: [
|
|
108
|
+
{
|
|
109
|
+
type: "text",
|
|
110
|
+
text: `Error during app automation or screenshot capture: ${errorMessage}`,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.fuzzySearchDevices = fuzzySearchDevices;
|
|
4
|
-
const fuzzy_1 = require("../../lib/fuzzy");
|
|
1
|
+
import { customFuzzySearch } from "../../lib/fuzzy.js";
|
|
5
2
|
/**
|
|
6
3
|
* Fuzzy searches App Live device entries by name.
|
|
7
4
|
*/
|
|
8
|
-
async function fuzzySearchDevices(devices, query, limit = 5) {
|
|
9
|
-
const top_match =
|
|
5
|
+
export async function fuzzySearchDevices(devices, query, limit = 5) {
|
|
6
|
+
const top_match = customFuzzySearch(devices, ["device", "display_name"], query, limit);
|
|
10
7
|
return top_match;
|
|
11
8
|
}
|
|
@@ -1,31 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
};
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const child_process_1 = __importDefault(require("child_process"));
|
|
8
|
-
const logger_1 = __importDefault(require("../../logger"));
|
|
9
|
-
const device_cache_1 = require("../../lib/device-cache");
|
|
10
|
-
const fuzzy_search_1 = require("./fuzzy-search");
|
|
11
|
-
const utils_1 = require("../../lib/utils");
|
|
12
|
-
const upload_app_1 = require("./upload-app");
|
|
1
|
+
import childProcess from "child_process";
|
|
2
|
+
import logger from "../../logger.js";
|
|
3
|
+
import { BrowserStackProducts, getDevicesAndBrowsers, } from "../../lib/device-cache.js";
|
|
4
|
+
import { fuzzySearchDevices } from "./fuzzy-search.js";
|
|
5
|
+
import { sanitizeUrlParam } from "../../lib/utils.js";
|
|
6
|
+
import { uploadApp } from "./upload-app.js";
|
|
13
7
|
/**
|
|
14
8
|
* Starts an App Live session after filtering, fuzzy matching, and launching.
|
|
15
9
|
* @param args - The arguments for starting the session.
|
|
16
10
|
* @returns The launch URL for the session.
|
|
17
11
|
* @throws Will throw an error if no devices are found or if the app URL is invalid.
|
|
18
12
|
*/
|
|
19
|
-
async function startSession(args) {
|
|
13
|
+
export async function startSession(args) {
|
|
20
14
|
const { appPath, desiredPlatform, desiredPhone } = args;
|
|
21
15
|
let { desiredPlatformVersion } = args;
|
|
22
|
-
const data = await (
|
|
16
|
+
const data = await getDevicesAndBrowsers(BrowserStackProducts.APP_LIVE);
|
|
23
17
|
const allDevices = data.mobile.flatMap((group) => group.devices.map((dev) => ({ ...dev, os: group.os })));
|
|
24
18
|
desiredPlatformVersion = resolvePlatformVersion(allDevices, desiredPlatform, desiredPlatformVersion);
|
|
25
19
|
const filteredDevices = filterDevicesByPlatformAndVersion(allDevices, desiredPlatform, desiredPlatformVersion);
|
|
26
|
-
const matches = await
|
|
20
|
+
const matches = await fuzzySearchDevices(filteredDevices, desiredPhone);
|
|
27
21
|
const selectedDevice = validateAndSelectDevice(matches, desiredPhone, desiredPlatform, desiredPlatformVersion);
|
|
28
|
-
const { app_url } = await
|
|
22
|
+
const { app_url } = await uploadApp(appPath);
|
|
29
23
|
validateAppUrl(app_url);
|
|
30
24
|
const launchUrl = constructLaunchUrl(app_url, selectedDevice, desiredPlatform, desiredPlatformVersion);
|
|
31
25
|
openBrowser(launchUrl);
|
|
@@ -120,7 +114,7 @@ function validateAppUrl(appUrl) {
|
|
|
120
114
|
* @returns The constructed launch URL.
|
|
121
115
|
*/
|
|
122
116
|
function constructLaunchUrl(appUrl, device, desiredPlatform, desiredPlatformVersion) {
|
|
123
|
-
const deviceParam =
|
|
117
|
+
const deviceParam = sanitizeUrlParam(device.display_name.replace(/\s+/g, "+"));
|
|
124
118
|
const params = new URLSearchParams({
|
|
125
119
|
os: desiredPlatform,
|
|
126
120
|
os_version: desiredPlatformVersion,
|
|
@@ -144,16 +138,16 @@ function openBrowser(launchUrl) {
|
|
|
144
138
|
? ["cmd", "/c", "start", launchUrl]
|
|
145
139
|
: ["xdg-open", launchUrl];
|
|
146
140
|
// nosemgrep:javascript.lang.security.detect-child-process.detect-child-process
|
|
147
|
-
const child =
|
|
141
|
+
const child = childProcess.spawn(command[0], command.slice(1), {
|
|
148
142
|
stdio: "ignore",
|
|
149
143
|
detached: true,
|
|
150
144
|
});
|
|
151
145
|
child.on("error", (error) => {
|
|
152
|
-
|
|
146
|
+
logger.error(`Failed to open browser automatically: ${error}. Please open this URL manually: ${launchUrl}`);
|
|
153
147
|
});
|
|
154
148
|
child.unref();
|
|
155
149
|
}
|
|
156
150
|
catch (error) {
|
|
157
|
-
|
|
151
|
+
logger.error(`Failed to open browser automatically: ${error}. Please open this URL manually: ${launchUrl}`);
|
|
158
152
|
}
|
|
159
153
|
}
|
|
@@ -1,66 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
-
};
|
|
38
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.uploadApp = uploadApp;
|
|
40
|
-
const axios_1 = __importStar(require("axios"));
|
|
41
|
-
const form_data_1 = __importDefault(require("form-data"));
|
|
42
|
-
const fs_1 = __importDefault(require("fs"));
|
|
43
|
-
const config_1 = __importDefault(require("../../config"));
|
|
44
|
-
async function uploadApp(filePath) {
|
|
45
|
-
if (!fs_1.default.existsSync(filePath)) {
|
|
1
|
+
import axios, { AxiosError } from "axios";
|
|
2
|
+
import FormData from "form-data";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import config from "../../config.js";
|
|
5
|
+
export async function uploadApp(filePath) {
|
|
6
|
+
if (!fs.existsSync(filePath)) {
|
|
46
7
|
throw new Error(`File not found at path: ${filePath}`);
|
|
47
8
|
}
|
|
48
|
-
const formData = new
|
|
49
|
-
formData.append("file",
|
|
9
|
+
const formData = new FormData();
|
|
10
|
+
formData.append("file", fs.createReadStream(filePath));
|
|
50
11
|
try {
|
|
51
|
-
const response = await
|
|
12
|
+
const response = await axios.post("https://api-cloud.browserstack.com/app-live/upload", formData, {
|
|
52
13
|
headers: {
|
|
53
14
|
...formData.getHeaders(),
|
|
54
15
|
},
|
|
55
16
|
auth: {
|
|
56
|
-
username:
|
|
57
|
-
password:
|
|
17
|
+
username: config.browserstackUsername,
|
|
18
|
+
password: config.browserstackAccessKey,
|
|
58
19
|
},
|
|
59
20
|
});
|
|
60
21
|
return response.data;
|
|
61
22
|
}
|
|
62
23
|
catch (error) {
|
|
63
|
-
if (error instanceof
|
|
24
|
+
if (error instanceof AxiosError) {
|
|
64
25
|
throw new Error(`Failed to upload app: ${error.response?.data?.message || error.message}`);
|
|
65
26
|
}
|
|
66
27
|
throw error;
|