@browserstack/mcp-server 1.1.4 → 1.1.6
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/index.js +2 -0
- package/dist/tools/accessiblity-utils/report-parser.js +1 -1
- package/dist/tools/applive-utils/device-search.js +22 -0
- package/dist/tools/applive-utils/start-session.js +37 -110
- package/dist/tools/applive-utils/types.js +1 -0
- package/dist/tools/applive-utils/version-utils.js +13 -0
- package/dist/tools/live-utils/desktop-filter.js +1 -1
- package/dist/tools/live-utils/mobile-filter.js +1 -1
- package/dist/tools/selfheal-utils/selfheal.js +51 -0
- package/dist/tools/selfheal.js +46 -0
- package/dist/tools/testmanagement-utils/TCG-utils/api.js +43 -4
- package/dist/tools/testmanagement-utils/create-lca-steps.js +166 -0
- package/dist/tools/testmanagement-utils/poll-lca-status.js +117 -0
- package/dist/tools/testmanagement-utils/testcase-from-file.js +5 -0
- package/dist/tools/testmanagement.js +26 -2
- package/package.json +1 -1
- package/dist/tools/applive-utils/fuzzy-search.js +0 -8
- /package/dist/{tools/live-utils → lib}/version-resolver.js +0 -0
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import addTestManagementTools from "./tools/testmanagement.js";
|
|
|
14
14
|
import addAppAutomationTools from "./tools/appautomate.js";
|
|
15
15
|
import addFailureLogsTools from "./tools/getFailureLogs.js";
|
|
16
16
|
import addAutomateTools from "./tools/automate.js";
|
|
17
|
+
import addSelfHealTools from "./tools/selfheal.js";
|
|
17
18
|
import { setupOnInitialized } from "./oninitialized.js";
|
|
18
19
|
function registerTools(server) {
|
|
19
20
|
addSDKTools(server);
|
|
@@ -24,6 +25,7 @@ function registerTools(server) {
|
|
|
24
25
|
addAppAutomationTools(server);
|
|
25
26
|
addFailureLogsTools(server);
|
|
26
27
|
addAutomateTools(server);
|
|
28
|
+
addSelfHealTools(server);
|
|
27
29
|
}
|
|
28
30
|
// Create an MCP server
|
|
29
31
|
const server = new McpServer({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fetch from "node-fetch";
|
|
2
2
|
import { parse } from "csv-parse/sync";
|
|
3
|
-
export async function parseAccessibilityReportFromCSV(reportLink, { maxCharacterLength =
|
|
3
|
+
export async function parseAccessibilityReportFromCSV(reportLink, { maxCharacterLength = 5000, nextPage = 0 } = {}) {
|
|
4
4
|
// 1) Download & parse
|
|
5
5
|
const res = await fetch(reportLink);
|
|
6
6
|
if (!res.ok)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { customFuzzySearch } from "../../lib/fuzzy.js";
|
|
2
|
+
/**
|
|
3
|
+
* Find matching devices by name with exact match preference.
|
|
4
|
+
* Throws if none or multiple exact matches.
|
|
5
|
+
*/
|
|
6
|
+
export function findDeviceByName(devices, desiredPhone) {
|
|
7
|
+
const matches = customFuzzySearch(devices, ["display_name"], desiredPhone, 5);
|
|
8
|
+
if (matches.length === 0) {
|
|
9
|
+
const options = [...new Set(devices.map((d) => d.display_name))].join(", ");
|
|
10
|
+
throw new Error(`No devices matching "${desiredPhone}". Available devices: ${options}`);
|
|
11
|
+
}
|
|
12
|
+
// Exact-case-insensitive filter
|
|
13
|
+
const exact = matches.filter((m) => m.display_name.toLowerCase() === desiredPhone.toLowerCase());
|
|
14
|
+
if (exact.length)
|
|
15
|
+
return exact;
|
|
16
|
+
// If no exact but multiple fuzzy, ask user
|
|
17
|
+
if (matches.length > 1) {
|
|
18
|
+
const names = matches.map((d) => d.display_name).join(", ");
|
|
19
|
+
throw new Error(`Alternative Device/Device's found : ${names}. Please Select one.`);
|
|
20
|
+
}
|
|
21
|
+
return matches;
|
|
22
|
+
}
|
|
@@ -1,129 +1,56 @@
|
|
|
1
|
-
import childProcess from "child_process";
|
|
2
1
|
import logger from "../../logger.js";
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
2
|
+
import childProcess from "child_process";
|
|
3
|
+
import { getDevicesAndBrowsers, BrowserStackProducts, } from "../../lib/device-cache.js";
|
|
5
4
|
import { sanitizeUrlParam } from "../../lib/utils.js";
|
|
6
5
|
import { uploadApp } from "./upload-app.js";
|
|
6
|
+
import { findDeviceByName } from "./device-search.js";
|
|
7
|
+
import { pickVersion } from "./version-utils.js";
|
|
7
8
|
/**
|
|
8
|
-
*
|
|
9
|
-
* @param args - The arguments for starting the session.
|
|
10
|
-
* @returns The launch URL for the session.
|
|
11
|
-
* @throws Will throw an error if no devices are found or if the app URL is invalid.
|
|
9
|
+
* Start an App Live session: filter, select, upload, and open.
|
|
12
10
|
*/
|
|
13
11
|
export async function startSession(args) {
|
|
14
|
-
const { appPath, desiredPlatform, desiredPhone } = args;
|
|
15
|
-
|
|
12
|
+
const { appPath, desiredPlatform, desiredPhone, desiredPlatformVersion } = args;
|
|
13
|
+
// 1) Fetch devices for APP_LIVE
|
|
16
14
|
const data = await getDevicesAndBrowsers(BrowserStackProducts.APP_LIVE);
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const { app_url } = await uploadApp(appPath);
|
|
23
|
-
validateAppUrl(app_url);
|
|
24
|
-
const launchUrl = constructLaunchUrl(app_url, selectedDevice, desiredPlatform, desiredPlatformVersion);
|
|
25
|
-
openBrowser(launchUrl);
|
|
26
|
-
return launchUrl;
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Resolves the platform version based on the desired platform and version.
|
|
30
|
-
* @param allDevices - The list of all devices.
|
|
31
|
-
* @param desiredPlatform - The desired platform (android or ios).
|
|
32
|
-
* @param desiredPlatformVersion - The desired platform version.
|
|
33
|
-
* @returns The resolved platform version.
|
|
34
|
-
* @throws Will throw an error if the platform version is not valid.
|
|
35
|
-
*/
|
|
36
|
-
function resolvePlatformVersion(allDevices, desiredPlatform, desiredPlatformVersion) {
|
|
37
|
-
if (desiredPlatformVersion === "latest" ||
|
|
38
|
-
desiredPlatformVersion === "oldest") {
|
|
39
|
-
const filtered = allDevices.filter((d) => d.os === desiredPlatform);
|
|
40
|
-
filtered.sort((a, b) => {
|
|
41
|
-
const versionA = parseFloat(a.os_version);
|
|
42
|
-
const versionB = parseFloat(b.os_version);
|
|
43
|
-
return desiredPlatformVersion === "latest"
|
|
44
|
-
? versionB - versionA
|
|
45
|
-
: versionA - versionB;
|
|
46
|
-
});
|
|
47
|
-
return filtered[0].os_version;
|
|
15
|
+
const all = data.mobile.flatMap((grp) => grp.devices.map((dev) => ({ ...dev, os: grp.os })));
|
|
16
|
+
// 2) Filter by OS
|
|
17
|
+
const osMatches = all.filter((d) => d.os === desiredPlatform);
|
|
18
|
+
if (!osMatches.length) {
|
|
19
|
+
throw new Error(`No devices for OS "${desiredPlatform}"`);
|
|
48
20
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
*/
|
|
59
|
-
function filterDevicesByPlatformAndVersion(allDevices, desiredPlatform, desiredPlatformVersion) {
|
|
60
|
-
return allDevices.filter((d) => {
|
|
61
|
-
if (d.os !== desiredPlatform)
|
|
62
|
-
return false;
|
|
63
|
-
try {
|
|
64
|
-
const versionA = parseFloat(d.os_version);
|
|
65
|
-
const versionB = parseFloat(desiredPlatformVersion);
|
|
66
|
-
return versionA === versionB;
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
return d.os_version === desiredPlatformVersion;
|
|
70
|
-
}
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* Validates the selected device and handles multiple matches.
|
|
75
|
-
* @param matches - The list of device matches.
|
|
76
|
-
* @param desiredPhone - The desired phone name.
|
|
77
|
-
* @param desiredPlatform - The desired platform (android or ios).
|
|
78
|
-
* @param desiredPlatformVersion - The desired platform version.
|
|
79
|
-
* @returns The selected device entry.
|
|
80
|
-
*/
|
|
81
|
-
function validateAndSelectDevice(matches, desiredPhone, desiredPlatform, desiredPlatformVersion) {
|
|
82
|
-
if (matches.length === 0) {
|
|
83
|
-
throw new Error(`No devices found matching "${desiredPhone}" for ${desiredPlatform} ${desiredPlatformVersion}`);
|
|
84
|
-
}
|
|
85
|
-
const exactMatch = matches.find((d) => d.display_name.toLowerCase() === desiredPhone.toLowerCase());
|
|
86
|
-
if (exactMatch) {
|
|
87
|
-
return exactMatch;
|
|
21
|
+
// 3) Select by name
|
|
22
|
+
const nameMatches = findDeviceByName(osMatches, desiredPhone);
|
|
23
|
+
// 4) Resolve version
|
|
24
|
+
const versions = [...new Set(nameMatches.map((d) => d.os_version))];
|
|
25
|
+
const version = pickVersion(versions, desiredPlatformVersion);
|
|
26
|
+
// 5) Final candidates for version
|
|
27
|
+
const final = nameMatches.filter((d) => d.os_version === version);
|
|
28
|
+
if (!final.length) {
|
|
29
|
+
throw new Error(`No devices for version "${version}" on ${desiredPlatform}`);
|
|
88
30
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
return matches[0];
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Validates the app URL.
|
|
100
|
-
* @param appUrl - The app URL to validate.
|
|
101
|
-
* @throws Will throw an error if the app URL is not valid.
|
|
102
|
-
*/
|
|
103
|
-
function validateAppUrl(appUrl) {
|
|
104
|
-
if (!appUrl.match("bs://")) {
|
|
105
|
-
throw new Error("The app path is not a valid BrowserStack app URL.");
|
|
31
|
+
const selected = final[0];
|
|
32
|
+
let note = "";
|
|
33
|
+
if (version != desiredPlatformVersion &&
|
|
34
|
+
desiredPlatformVersion !== "latest" &&
|
|
35
|
+
desiredPlatformVersion !== "oldest") {
|
|
36
|
+
note = `\n Note: The requested version "${desiredPlatformVersion}" is not available. Using "${version}" instead.`;
|
|
106
37
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
* @param desiredPlatform - The desired platform (android or ios).
|
|
113
|
-
* @param desiredPlatformVersion - The desired platform version.
|
|
114
|
-
* @returns The constructed launch URL.
|
|
115
|
-
*/
|
|
116
|
-
function constructLaunchUrl(appUrl, device, desiredPlatform, desiredPlatformVersion) {
|
|
117
|
-
const deviceParam = sanitizeUrlParam(device.display_name.replace(/\s+/g, "+"));
|
|
38
|
+
// 6) Upload app
|
|
39
|
+
const { app_url } = await uploadApp(appPath);
|
|
40
|
+
logger.info(`App uploaded: ${app_url}`);
|
|
41
|
+
// 7) Build URL & open
|
|
42
|
+
const deviceParam = sanitizeUrlParam(selected.display_name.replace(/\s+/g, "+"));
|
|
118
43
|
const params = new URLSearchParams({
|
|
119
44
|
os: desiredPlatform,
|
|
120
|
-
os_version:
|
|
121
|
-
app_hashed_id:
|
|
45
|
+
os_version: version,
|
|
46
|
+
app_hashed_id: app_url.split("bs://").pop() || "",
|
|
122
47
|
scale_to_fit: "true",
|
|
123
48
|
speed: "1",
|
|
124
49
|
start: "true",
|
|
125
50
|
});
|
|
126
|
-
|
|
51
|
+
const launchUrl = `https://app-live.browserstack.com/dashboard#${params.toString()}&device=${deviceParam}`;
|
|
52
|
+
openBrowser(launchUrl);
|
|
53
|
+
return launchUrl + note;
|
|
127
54
|
}
|
|
128
55
|
/**
|
|
129
56
|
* Opens the launch URL in the default browser.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { resolveVersion } from "../../lib/version-resolver.js";
|
|
2
|
+
/**
|
|
3
|
+
* Resolve desired version against available list
|
|
4
|
+
*/
|
|
5
|
+
export function pickVersion(available, requested) {
|
|
6
|
+
try {
|
|
7
|
+
return resolveVersion(requested, available);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
const opts = available.join(", ");
|
|
11
|
+
throw new Error(`Version "${requested}" not found. Available versions: ${opts}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getDevicesAndBrowsers, BrowserStackProducts, } from "../../lib/device-cache.js";
|
|
2
|
-
import { resolveVersion } from "
|
|
2
|
+
import { resolveVersion } from "../../lib/version-resolver.js";
|
|
3
3
|
import { customFuzzySearch } from "../../lib/fuzzy.js";
|
|
4
4
|
export async function filterDesktop(args) {
|
|
5
5
|
const data = await getDevicesAndBrowsers(BrowserStackProducts.LIVE);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getDevicesAndBrowsers, BrowserStackProducts, } from "../../lib/device-cache.js";
|
|
2
|
-
import { resolveVersion } from "
|
|
2
|
+
import { resolveVersion } from "../../lib/version-resolver.js";
|
|
3
3
|
import { customFuzzySearch } from "../../lib/fuzzy.js";
|
|
4
4
|
// Extract all mobile entries from the data
|
|
5
5
|
function getAllMobileEntries(data) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { assertOkResponse } from "../../lib/utils.js";
|
|
2
|
+
import config from "../../config.js";
|
|
3
|
+
export async function getSelfHealSelectors(sessionId) {
|
|
4
|
+
const credentials = `${config.browserstackUsername}:${config.browserstackAccessKey}`;
|
|
5
|
+
const auth = Buffer.from(credentials).toString("base64");
|
|
6
|
+
const url = `https://api.browserstack.com/automate/sessions/${sessionId}/logs`;
|
|
7
|
+
const response = await fetch(url, {
|
|
8
|
+
headers: {
|
|
9
|
+
"Content-Type": "application/json",
|
|
10
|
+
Authorization: `Basic ${auth}`,
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
await assertOkResponse(response, "session logs");
|
|
14
|
+
const logText = await response.text();
|
|
15
|
+
return extractHealedSelectors(logText);
|
|
16
|
+
}
|
|
17
|
+
function extractHealedSelectors(logText) {
|
|
18
|
+
// Split log text into lines for easier context handling
|
|
19
|
+
const logLines = logText.split("\n");
|
|
20
|
+
// Pattern to match successful SELFHEAL entries only
|
|
21
|
+
const selfhealPattern = /SELFHEAL\s*{\s*"status":"true",\s*"data":\s*{\s*"using":"css selector",\s*"value":"(.*?)"}/;
|
|
22
|
+
// Pattern to match preceding selector requests
|
|
23
|
+
const requestPattern = /POST \/session\/[^/]+\/element.*?"using":"css selector","value":"(.*?)"/;
|
|
24
|
+
// Find all successful healed selectors with their line numbers and context
|
|
25
|
+
const healedMappings = [];
|
|
26
|
+
for (let i = 0; i < logLines.length; i++) {
|
|
27
|
+
const match = logLines[i].match(selfhealPattern);
|
|
28
|
+
if (match) {
|
|
29
|
+
const beforeLine = i > 0 ? logLines[i - 1] : "";
|
|
30
|
+
const afterLine = i < logLines.length - 1 ? logLines[i + 1] : "";
|
|
31
|
+
// Look backwards to find the most recent original selector request
|
|
32
|
+
let originalSelector = "UNKNOWN";
|
|
33
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
34
|
+
const requestMatch = logLines[j].match(requestPattern);
|
|
35
|
+
if (requestMatch) {
|
|
36
|
+
originalSelector = requestMatch[1];
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
healedMappings.push({
|
|
41
|
+
originalSelector,
|
|
42
|
+
healedSelector: match[1],
|
|
43
|
+
context: {
|
|
44
|
+
before: beforeLine,
|
|
45
|
+
after: afterLine,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return healedMappings;
|
|
51
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getSelfHealSelectors } from "./selfheal-utils/selfheal.js";
|
|
3
|
+
import logger from "../logger.js";
|
|
4
|
+
import { trackMCP } from "../lib/instrumentation.js";
|
|
5
|
+
// Tool function that fetches self-healing selectors
|
|
6
|
+
export async function fetchSelfHealSelectorTool(args) {
|
|
7
|
+
try {
|
|
8
|
+
const selectors = await getSelfHealSelectors(args.sessionId);
|
|
9
|
+
return {
|
|
10
|
+
content: [
|
|
11
|
+
{
|
|
12
|
+
type: "text",
|
|
13
|
+
text: "Self-heal selectors fetched successfully" +
|
|
14
|
+
JSON.stringify(selectors),
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
logger.error("Error fetching self-heal selector suggestions", error);
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Registers the fetchSelfHealSelector tool with the MCP server
|
|
25
|
+
export default function addSelfHealTools(server) {
|
|
26
|
+
server.tool("fetchSelfHealedSelectors", "Retrieves AI-generated, self-healed selectors for a BrowserStack Automate session to resolve flaky tests caused by dynamic DOM changes.", {
|
|
27
|
+
sessionId: z.string().describe("The session ID of the test run"),
|
|
28
|
+
}, async (args) => {
|
|
29
|
+
try {
|
|
30
|
+
trackMCP("fetchSelfHealedSelectors", server.server.getClientVersion());
|
|
31
|
+
return await fetchSelfHealSelectorTool(args);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
trackMCP("fetchSelfHealedSelectors", server.server.getClientVersion(), error);
|
|
35
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
36
|
+
return {
|
|
37
|
+
content: [
|
|
38
|
+
{
|
|
39
|
+
type: "text",
|
|
40
|
+
text: `Error during fetching self-heal suggestions: ${errorMessage}`,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -127,7 +127,7 @@ export async function pollScenariosTestDetails(args, traceId, context, documentI
|
|
|
127
127
|
progressToken: context._meta?.progressToken ?? traceId,
|
|
128
128
|
progress: count,
|
|
129
129
|
total: count,
|
|
130
|
-
message: `
|
|
130
|
+
message: `Generated ${count} scenarios`,
|
|
131
131
|
},
|
|
132
132
|
});
|
|
133
133
|
}
|
|
@@ -157,7 +157,7 @@ export async function pollScenariosTestDetails(args, traceId, context, documentI
|
|
|
157
157
|
progressToken: context._meta?.progressToken ?? traceId,
|
|
158
158
|
progress: iteratorCount,
|
|
159
159
|
total,
|
|
160
|
-
message: `
|
|
160
|
+
message: `Generated ${array.length} test cases for scenario ${iteratorCount} out of ${total}`,
|
|
161
161
|
},
|
|
162
162
|
});
|
|
163
163
|
}
|
|
@@ -214,7 +214,7 @@ export async function bulkCreateTestCases(scenariosMap, projectId, folderId, fie
|
|
|
214
214
|
method: "notifications/progress",
|
|
215
215
|
params: {
|
|
216
216
|
progressToken: context._meta?.progressToken ?? "bulk-create",
|
|
217
|
-
message: `
|
|
217
|
+
message: `Saving and creating test cases...`,
|
|
218
218
|
total,
|
|
219
219
|
progress: doneCount,
|
|
220
220
|
},
|
|
@@ -226,7 +226,7 @@ export async function bulkCreateTestCases(scenariosMap, projectId, folderId, fie
|
|
|
226
226
|
method: "notifications/progress",
|
|
227
227
|
params: {
|
|
228
228
|
progressToken: context._meta?.progressToken ?? traceId,
|
|
229
|
-
message: `
|
|
229
|
+
message: `Creation failed for scenario ${id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
230
230
|
total,
|
|
231
231
|
progress: doneCount,
|
|
232
232
|
},
|
|
@@ -257,3 +257,42 @@ export async function projectIdentifierToId(projectId) {
|
|
|
257
257
|
}
|
|
258
258
|
throw new Error(`Project with identifier ${projectId} not found.`);
|
|
259
259
|
}
|
|
260
|
+
export async function testCaseIdentifierToDetails(projectId, testCaseIdentifier) {
|
|
261
|
+
const url = `https://test-management.browserstack.com/api/v1/projects/${projectId}/test-cases/search?q[query]=${testCaseIdentifier}`;
|
|
262
|
+
const response = await axios.get(url, {
|
|
263
|
+
headers: {
|
|
264
|
+
"API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`,
|
|
265
|
+
accept: "application/json, text/plain, */*",
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
if (response.data.success !== true) {
|
|
269
|
+
throw new Error(`Failed to fetch test case details: ${response.statusText}`);
|
|
270
|
+
}
|
|
271
|
+
// Check if test_cases array exists and has items
|
|
272
|
+
if (!response.data.test_cases ||
|
|
273
|
+
!Array.isArray(response.data.test_cases) ||
|
|
274
|
+
response.data.test_cases.length === 0) {
|
|
275
|
+
throw new Error(`No test cases found in response for identifier ${testCaseIdentifier}`);
|
|
276
|
+
}
|
|
277
|
+
for (const testCase of response.data.test_cases) {
|
|
278
|
+
if (testCase.identifier === testCaseIdentifier) {
|
|
279
|
+
// Extract folder ID from the links.folder URL
|
|
280
|
+
// URL format: "/api/v1/projects/1930314/folder/10193436/test-cases"
|
|
281
|
+
let folderId = "";
|
|
282
|
+
if (testCase.links && testCase.links.folder) {
|
|
283
|
+
const folderMatch = testCase.links.folder.match(/\/folder\/(\d+)\//);
|
|
284
|
+
if (folderMatch && folderMatch[1]) {
|
|
285
|
+
folderId = folderMatch[1];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (!folderId) {
|
|
289
|
+
throw new Error(`Could not extract folder ID for test case ${testCaseIdentifier}`);
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
testCaseId: testCase.id.toString(),
|
|
293
|
+
folderId: folderId,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
throw new Error(`Test case with identifier ${testCaseIdentifier} not found.`);
|
|
298
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import config from "../../config.js";
|
|
4
|
+
import { formatAxiosError } from "../../lib/error.js";
|
|
5
|
+
import { projectIdentifierToId, testCaseIdentifierToDetails, } from "./TCG-utils/api.js";
|
|
6
|
+
import { pollLCAStatus } from "./poll-lca-status.js";
|
|
7
|
+
/**
|
|
8
|
+
* Schema for creating LCA steps for a test case
|
|
9
|
+
*/
|
|
10
|
+
export const CreateLCAStepsSchema = z.object({
|
|
11
|
+
project_identifier: z
|
|
12
|
+
.string()
|
|
13
|
+
.describe("ID of the project (Starts with 'PR-')"),
|
|
14
|
+
test_case_identifier: z
|
|
15
|
+
.string()
|
|
16
|
+
.describe("Identifier of the test case (e.g., 'TC-12345')"),
|
|
17
|
+
base_url: z.string().describe("Base URL for the test (e.g., 'google.com')"),
|
|
18
|
+
credentials: z
|
|
19
|
+
.object({
|
|
20
|
+
username: z.string().describe("Username for authentication"),
|
|
21
|
+
password: z.string().describe("Password for authentication"),
|
|
22
|
+
})
|
|
23
|
+
.optional()
|
|
24
|
+
.describe("Optional credentials for authentication. Extract from the test case details if provided in it. This is required for the test cases which require authentication."),
|
|
25
|
+
local_enabled: z
|
|
26
|
+
.boolean()
|
|
27
|
+
.optional()
|
|
28
|
+
.default(false)
|
|
29
|
+
.describe("Whether local testing is enabled"),
|
|
30
|
+
test_name: z.string().describe("Name of the test"),
|
|
31
|
+
test_case_details: z
|
|
32
|
+
.object({
|
|
33
|
+
name: z.string().describe("Name of the test case"),
|
|
34
|
+
description: z.string().describe("Description of the test case"),
|
|
35
|
+
preconditions: z.string().describe("Preconditions for the test case"),
|
|
36
|
+
test_case_steps: z
|
|
37
|
+
.array(z.object({
|
|
38
|
+
step: z.string().describe("Test step description"),
|
|
39
|
+
result: z.string().describe("Expected result"),
|
|
40
|
+
}))
|
|
41
|
+
.describe("Array of test case steps with expected results"),
|
|
42
|
+
})
|
|
43
|
+
.describe("Test case details including steps"),
|
|
44
|
+
wait_for_completion: z
|
|
45
|
+
.boolean()
|
|
46
|
+
.optional()
|
|
47
|
+
.default(true)
|
|
48
|
+
.describe("Whether to wait for LCA build completion (default: true)"),
|
|
49
|
+
});
|
|
50
|
+
/**
|
|
51
|
+
* Creates LCA (Low Code Automation) steps for a test case in BrowserStack Test Management
|
|
52
|
+
*/
|
|
53
|
+
export async function createLCASteps(args, context) {
|
|
54
|
+
try {
|
|
55
|
+
// Get the project ID from identifier
|
|
56
|
+
const projectId = await projectIdentifierToId(args.project_identifier);
|
|
57
|
+
// Get the test case ID and folder ID from identifier
|
|
58
|
+
const { testCaseId, folderId } = await testCaseIdentifierToDetails(projectId, args.test_case_identifier);
|
|
59
|
+
const url = `https://test-management.browserstack.com/api/v1/projects/${projectId}/test-cases/${testCaseId}/lcnc`;
|
|
60
|
+
const payload = {
|
|
61
|
+
base_url: args.base_url,
|
|
62
|
+
credentials: args.credentials,
|
|
63
|
+
local_enabled: args.local_enabled,
|
|
64
|
+
test_name: args.test_name,
|
|
65
|
+
test_case_details: args.test_case_details,
|
|
66
|
+
version: "v2",
|
|
67
|
+
webhook_path: `https://test-management.browserstack.com/api/v1/projects/${projectId}/test-cases/${testCaseId}/webhooks/lcnc`,
|
|
68
|
+
};
|
|
69
|
+
const response = await axios.post(url, payload, {
|
|
70
|
+
headers: {
|
|
71
|
+
"API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`,
|
|
72
|
+
accept: "application/json, text/plain, */*",
|
|
73
|
+
"Content-Type": "application/json",
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
if (response.status >= 200 && response.status < 300) {
|
|
77
|
+
// Check if user wants to wait for completion
|
|
78
|
+
if (!args.wait_for_completion) {
|
|
79
|
+
return {
|
|
80
|
+
content: [
|
|
81
|
+
{
|
|
82
|
+
type: "text",
|
|
83
|
+
text: `LCA steps creation initiated for test case ${args.test_case_identifier} (ID: ${testCaseId})`,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
type: "text",
|
|
87
|
+
text: "LCA build started. Check the BrowserStack Lowcode Automation UI for completion status.",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// Start polling for LCA build completion
|
|
93
|
+
try {
|
|
94
|
+
const max_wait_minutes = 10; // Maximum wait time in minutes
|
|
95
|
+
const maxWaitMs = max_wait_minutes * 60 * 1000;
|
|
96
|
+
const lcaResult = await pollLCAStatus(projectId, folderId, testCaseId, context, maxWaitMs, // max wait time
|
|
97
|
+
2 * 60 * 1000, // 2 minutes initial wait
|
|
98
|
+
10 * 1000);
|
|
99
|
+
if (lcaResult && lcaResult.status === "done") {
|
|
100
|
+
return {
|
|
101
|
+
content: [
|
|
102
|
+
{
|
|
103
|
+
type: "text",
|
|
104
|
+
text: `Successfully created LCA steps for test case ${args.test_case_identifier} (ID: ${testCaseId})`,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
type: "text",
|
|
108
|
+
text: `LCA build completed! Resource URL: ${lcaResult.resource_path}`,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
return {
|
|
115
|
+
content: [
|
|
116
|
+
{
|
|
117
|
+
type: "text",
|
|
118
|
+
text: `LCA steps creation initiated for test case ${args.test_case_identifier} (ID: ${testCaseId})`,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
type: "text",
|
|
122
|
+
text: `Warning: LCA build did not complete within ${max_wait_minutes} minutes. You can check the status later in the BrowserStack Test Management UI.`,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (pollError) {
|
|
129
|
+
console.error("Error during LCA polling:", pollError);
|
|
130
|
+
return {
|
|
131
|
+
content: [
|
|
132
|
+
{
|
|
133
|
+
type: "text",
|
|
134
|
+
text: `LCA steps creation initiated for test case ${args.test_case_identifier} (ID: ${testCaseId})`,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
type: "text",
|
|
138
|
+
text: "Warning: Error occurred while polling for LCA build completion. Check the BrowserStack Test Management UI for status.",
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
throw new Error(`Unexpected response: ${JSON.stringify(response.data)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
// Add more specific error handling
|
|
150
|
+
if (error instanceof Error) {
|
|
151
|
+
if (error.message.includes("not found")) {
|
|
152
|
+
return {
|
|
153
|
+
content: [
|
|
154
|
+
{
|
|
155
|
+
type: "text",
|
|
156
|
+
text: `Error: ${error.message}. Please verify that the project identifier "${args.project_identifier}" and test case identifier "${args.test_case_identifier}" are correct.`,
|
|
157
|
+
isError: true,
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
isError: true,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return formatAxiosError(error, "Failed to create LCA steps");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import config from "../../config.js";
|
|
3
|
+
/**
|
|
4
|
+
* Poll test case details to check LCA build status
|
|
5
|
+
*/
|
|
6
|
+
export async function pollLCAStatus(projectId, folderId, testCaseId, context, maxWaitTimeMs = 10 * 60 * 1000, // 10 minutes default
|
|
7
|
+
initialWaitMs = 2 * 60 * 1000, // 2 minutes initial wait
|
|
8
|
+
pollIntervalMs = 10 * 1000) {
|
|
9
|
+
const url = `https://test-management.browserstack.com/api/v1/projects/${projectId}/folder/${folderId}/test-cases/${testCaseId}`;
|
|
10
|
+
const startTime = Date.now();
|
|
11
|
+
// Send initial notification that polling is starting
|
|
12
|
+
const notificationInterval = Math.min(initialWaitMs, pollIntervalMs);
|
|
13
|
+
const notificationStartTime = Date.now();
|
|
14
|
+
const notificationIntervalId = setInterval(async () => {
|
|
15
|
+
const elapsedTime = Date.now() - notificationStartTime;
|
|
16
|
+
const progressPercentage = Math.min(90, Math.floor((elapsedTime / maxWaitTimeMs) * 90));
|
|
17
|
+
await context.sendNotification({
|
|
18
|
+
method: "notifications/progress",
|
|
19
|
+
params: {
|
|
20
|
+
progressToken: context._meta?.progressToken ?? `lca-${testCaseId}`,
|
|
21
|
+
message: `Generating Low Code Automation Test..`,
|
|
22
|
+
progress: progressPercentage,
|
|
23
|
+
total: 100,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
if (elapsedTime >= initialWaitMs) {
|
|
27
|
+
clearInterval(notificationIntervalId);
|
|
28
|
+
}
|
|
29
|
+
}, notificationInterval);
|
|
30
|
+
// Wait for initial period before starting to poll
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, initialWaitMs));
|
|
32
|
+
clearInterval(notificationIntervalId);
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
// Set up timeout to handle max wait time
|
|
35
|
+
const timeoutId = setTimeout(() => {
|
|
36
|
+
clearInterval(intervalId);
|
|
37
|
+
// Send timeout notification
|
|
38
|
+
context.sendNotification({
|
|
39
|
+
method: "notifications/progress",
|
|
40
|
+
params: {
|
|
41
|
+
progressToken: context._meta?.progressToken ?? `lca-${testCaseId}`,
|
|
42
|
+
message: `LCA build polling timed out after ${Math.floor(maxWaitTimeMs / 60000)} minutes`,
|
|
43
|
+
progress: 100,
|
|
44
|
+
total: 100,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
resolve(null);
|
|
48
|
+
}, maxWaitTimeMs);
|
|
49
|
+
// Set up polling interval
|
|
50
|
+
const intervalId = setInterval(async () => {
|
|
51
|
+
try {
|
|
52
|
+
const response = await axios.get(url, {
|
|
53
|
+
headers: {
|
|
54
|
+
"API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`,
|
|
55
|
+
accept: "application/json, text/plain, */*",
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
if (response.data.data.success && response.data.data.test_case) {
|
|
59
|
+
const testCase = response.data.data.test_case;
|
|
60
|
+
// Check lcnc_build_map in both possible locations
|
|
61
|
+
const lcncBuildMap = testCase.lcnc_build_map ||
|
|
62
|
+
response.data.data.metadata?.lcnc_build_map;
|
|
63
|
+
if (lcncBuildMap) {
|
|
64
|
+
if (lcncBuildMap.status === "done") {
|
|
65
|
+
// Clean up timers
|
|
66
|
+
clearInterval(intervalId);
|
|
67
|
+
clearTimeout(timeoutId);
|
|
68
|
+
// Send completion notification
|
|
69
|
+
await context.sendNotification({
|
|
70
|
+
method: "notifications/progress",
|
|
71
|
+
params: {
|
|
72
|
+
progressToken: context._meta?.progressToken ?? `lca-${testCaseId}`,
|
|
73
|
+
message: `LCA build completed successfully!`,
|
|
74
|
+
progress: 100,
|
|
75
|
+
total: 100,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
resolve({
|
|
79
|
+
resource_path: lcncBuildMap.resource_path,
|
|
80
|
+
status: lcncBuildMap.status,
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Send progress notification with current status
|
|
85
|
+
const elapsedTime = Date.now() - startTime;
|
|
86
|
+
const progressPercentage = Math.min(90, Math.floor((elapsedTime / maxWaitTimeMs) * 90) + 10);
|
|
87
|
+
// Cycle through different numbers of dots (2, 3, 4, 5, then back to 2)
|
|
88
|
+
const dotCount = (Math.floor(elapsedTime / pollIntervalMs) % 4) + 2;
|
|
89
|
+
const dots = ".".repeat(dotCount);
|
|
90
|
+
await context.sendNotification({
|
|
91
|
+
method: "notifications/progress",
|
|
92
|
+
params: {
|
|
93
|
+
progressToken: context._meta?.progressToken ?? `lca-${testCaseId}`,
|
|
94
|
+
message: `Generating Low Code Automation Test${dots}`,
|
|
95
|
+
progress: progressPercentage,
|
|
96
|
+
total: 100,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
console.error("Error polling LCA status:", error);
|
|
104
|
+
// Send error notification but continue polling
|
|
105
|
+
await context.sendNotification({
|
|
106
|
+
method: "notifications/progress",
|
|
107
|
+
params: {
|
|
108
|
+
progressToken: context._meta?.progressToken ?? `lca-${testCaseId}`,
|
|
109
|
+
message: `Error occurred while polling, retrying... (${error instanceof Error ? error.message : "Unknown error"})`,
|
|
110
|
+
progress: Math.min(90, Math.floor(((Date.now() - startTime) / maxWaitTimeMs) * 90) + 10),
|
|
111
|
+
total: 100,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}, pollIntervalMs);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -31,12 +31,17 @@ export async function createTestCasesFromFile(args, context) {
|
|
|
31
31
|
const scenariosMap = await pollScenariosTestDetails(args, traceId, context, documentId, source);
|
|
32
32
|
const resultString = await bulkCreateTestCases(scenariosMap, args.projectReferenceId, args.folderId, fieldMaps, booleanFieldId, traceId, context, documentId);
|
|
33
33
|
signedUrlMap.delete(args.documentId);
|
|
34
|
+
const dashboardURL = `https://test-management.browserstack.com/projects/${args.projectReferenceId}/folder/${args.folderId}/test-cases`;
|
|
34
35
|
return {
|
|
35
36
|
content: [
|
|
36
37
|
{
|
|
37
38
|
type: "text",
|
|
38
39
|
text: resultString,
|
|
39
40
|
},
|
|
41
|
+
{
|
|
42
|
+
type: "text",
|
|
43
|
+
text: `Dashboard URL: ${dashboardURL}`,
|
|
44
|
+
},
|
|
40
45
|
],
|
|
41
46
|
};
|
|
42
47
|
}
|
|
@@ -11,6 +11,7 @@ import { addTestResult, AddTestResultSchema, } from "./testmanagement-utils/add-
|
|
|
11
11
|
import { UploadFileSchema, uploadFile, } from "./testmanagement-utils/upload-file.js";
|
|
12
12
|
import { createTestCasesFromFile } from "./testmanagement-utils/testcase-from-file.js";
|
|
13
13
|
import { CreateTestCasesFromFileSchema } from "./testmanagement-utils/TCG-utils/types.js";
|
|
14
|
+
import { createLCASteps, CreateLCAStepsSchema, } from "./testmanagement-utils/create-lca-steps.js";
|
|
14
15
|
//TODO: Moving the traceMCP and catch block to the parent(server) function
|
|
15
16
|
/**
|
|
16
17
|
* Wrapper to call createProjectOrFolder util.
|
|
@@ -216,6 +217,28 @@ export async function createTestCasesFromFileTool(args, context) {
|
|
|
216
217
|
};
|
|
217
218
|
}
|
|
218
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Creates LCA (Low Code Automation) steps for a test case in BrowserStack Test Management.
|
|
222
|
+
*/
|
|
223
|
+
export async function createLCAStepsTool(args, context) {
|
|
224
|
+
try {
|
|
225
|
+
trackMCP("createLCASteps", serverInstance.server.getClientVersion());
|
|
226
|
+
return await createLCASteps(args, context);
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
trackMCP("createLCASteps", serverInstance.server.getClientVersion(), err);
|
|
230
|
+
return {
|
|
231
|
+
content: [
|
|
232
|
+
{
|
|
233
|
+
type: "text",
|
|
234
|
+
text: `Failed to create LCA steps: ${err instanceof Error ? err.message : "Unknown error"}. Please open an issue on GitHub if the problem persists`,
|
|
235
|
+
isError: true,
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
isError: true,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
219
242
|
/**
|
|
220
243
|
* Registers both project/folder and test-case tools.
|
|
221
244
|
*/
|
|
@@ -228,6 +251,7 @@ export default function addTestManagementTools(server) {
|
|
|
228
251
|
server.tool("listTestRuns", "List test runs in a project with optional filters (date ranges, assignee, state, etc.)", ListTestRunsSchema.shape, listTestRunsTool);
|
|
229
252
|
server.tool("updateTestRun", "Update a test run in BrowserStack Test Management.", UpdateTestRunSchema.shape, updateTestRunTool);
|
|
230
253
|
server.tool("addTestResult", "Add a test result to a specific test run via BrowserStack Test Management API.", AddTestResultSchema.shape, addTestResultTool);
|
|
231
|
-
server.tool("uploadProductRequirementFile", "Upload files
|
|
232
|
-
server.tool("createTestCasesFromFile", "
|
|
254
|
+
server.tool("uploadProductRequirementFile", "Upload files (e.g., PDRs, PDFs) to BrowserStack Test Management and retrieve a file mapping ID. This is utilized for generating test cases from files and is part of the Test Case Generator AI Agent in BrowserStack.", UploadFileSchema.shape, uploadProductRequirementFileTool);
|
|
255
|
+
server.tool("createTestCasesFromFile", "Generate test cases from a file in BrowserStack Test Management using the Test Case Generator AI Agent.", CreateTestCasesFromFileSchema.shape, createTestCasesFromFileTool);
|
|
256
|
+
server.tool("createLCASteps", "Generate Low Code Automation (LCA) steps for a test case in BrowserStack Test Management using the Low Code Automation Agent.", CreateLCAStepsSchema.shape, createLCAStepsTool);
|
|
233
257
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { customFuzzySearch } from "../../lib/fuzzy.js";
|
|
2
|
-
/**
|
|
3
|
-
* Fuzzy searches App Live device entries by name.
|
|
4
|
-
*/
|
|
5
|
-
export async function fuzzySearchDevices(devices, query, limit = 5) {
|
|
6
|
-
const top_match = customFuzzySearch(devices, ["device", "display_name"], query, limit);
|
|
7
|
-
return top_match;
|
|
8
|
-
}
|
|
File without changes
|