diff --git a/package-lock.json b/package-lock.json index 9a07d19..7678132 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@types/form-data": "^2.5.2", "axios": "^1.8.4", "browserstack-local": "^1.5.6", + "chitragupta": "github:browserstack/chitragupta-node", "csv-parse": "^5.6.0", "dotenv": "^16.5.0", "form-data": "^4.0.2", @@ -21,6 +22,7 @@ "sharp": "^0.34.1", "uuid": "^11.1.0", "webdriverio": "^9.13.0", + "winston": "^3.17.0", "zod": "^3.24.3" }, "bin": { @@ -43,6 +45,24 @@ "node": ">=18" } }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", @@ -1795,6 +1815,11 @@ "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", "license": "MIT" }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -2561,6 +2586,17 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" }, + "node_modules/async-hook-jl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/async-hook-jl/-/async-hook-jl-1.7.6.tgz", + "integrity": "sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg==", + "dependencies": { + "stack-chain": "^1.3.7" + }, + "engines": { + "node": "^4.7 || >=6.9 || >=7.3" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2957,6 +2993,22 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chitragupta": { + "version": "1.7.5", + "resolved": "git+ssh://git@github.com/browserstack/chitragupta-node.git#e72239a13fd00cd9a78df9a3f465db9663607b55", + "dependencies": { + "cls-hooked": "^4.2.2", + "uuid": "^8.3.2" + } + }, + "node_modules/chitragupta/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -2970,6 +3022,27 @@ "node": ">=12" } }, + "node_modules/cls-hooked": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/cls-hooked/-/cls-hooked-4.2.2.tgz", + "integrity": "sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw==", + "dependencies": { + "async-hook-jl": "^1.7.6", + "emitter-listener": "^1.0.1", + "semver": "^5.4.1" + }, + "engines": { + "node": "^4.7 || >=6.9 || >=7.3 || >=8.2.1" + } + }, + "node_modules/cls-hooked/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -3014,6 +3087,37 @@ "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==" }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, + "node_modules/colorspace/node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/colorspace/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/colorspace/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3489,11 +3593,24 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/emitter-listener": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/emitter-listener/-/emitter-listener-1.1.2.tgz", + "integrity": "sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ==", + "dependencies": { + "shimmer": "^1.2.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -4145,6 +4262,11 @@ "pend": "~1.2.0" } }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -4260,6 +4382,11 @@ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + }, "node_modules/follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", @@ -5052,6 +5179,11 @@ "json-buffer": "3.0.1" } }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -5188,6 +5320,22 @@ "integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==", "license": "MIT" }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/loglevel": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", @@ -5494,6 +5642,14 @@ "wrappy": "1" } }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "dependencies": { + "fn.name": "1.x.x" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6544,6 +6700,11 @@ "node": ">=8" } }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -6741,6 +6902,19 @@ "node": ">= 10.x" } }, + "node_modules/stack-chain": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", + "integrity": "sha512-D8cWtWVdIe/jBA7v5p5Hwl5yOSOrmZPWDPe2KxQ5UAGD+nxbxU0lKXA4h85Ta6+qgdKVL3vUxsbIZjc1kBG7ug==" + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "engines": { + "node": "*" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -6922,6 +7096,11 @@ "b4a": "^1.6.4" } }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -7036,6 +7215,14 @@ "node": ">=0.6" } }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -7591,6 +7778,66 @@ "node": ">=8" } }, + "node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index e1bb823..c8070a3 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/form-data": "^2.5.2", "axios": "^1.8.4", "browserstack-local": "^1.5.6", + "chitragupta": "github:browserstack/chitragupta-node", "csv-parse": "^5.6.0", "dotenv": "^16.5.0", "form-data": "^4.0.2", @@ -46,6 +47,7 @@ "sharp": "^0.34.1", "uuid": "^11.1.0", "webdriverio": "^9.13.0", + "winston": "^3.17.0", "zod": "^3.24.3" }, "devDependencies": { diff --git a/src/config.ts b/src/config.ts index 95136b8..63a4ac6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,20 +36,18 @@ for (const key of BROWSERSTACK_LOCAL_OPTION_KEYS) { */ export class Config { constructor( - public readonly browserstackUsername: string, - public readonly browserstackAccessKey: string, public readonly DEV_MODE: boolean, public readonly browserstackLocalOptions: Record, public readonly USE_OWN_LOCAL_BINARY_PROCESS: boolean, + public readonly REMOTE_MCP: boolean, ) {} } const config = new Config( - process.env.BROWSERSTACK_USERNAME!, - process.env.BROWSERSTACK_ACCESS_KEY!, process.env.DEV_MODE === "true", browserstackLocalOptions, process.env.USE_OWN_LOCAL_BINARY_PROCESS === "true", + process.env.REMOTE_MCP === "true", ); export default config; diff --git a/src/index.ts b/src/index.ts index c5da73f..230063f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,44 +1,12 @@ #!/usr/bin/env node -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { createRequire } from "module"; const require = createRequire(import.meta.url); const packageJson = require("../package.json"); import "dotenv/config"; import logger from "./logger.js"; -import addSDKTools from "./tools/bstack-sdk.js"; -import addAppLiveTools from "./tools/applive.js"; -import addBrowserLiveTools from "./tools/live.js"; -import addAccessibilityTools from "./tools/accessibility.js"; -import addTestManagementTools from "./tools/testmanagement.js"; -import addAppAutomationTools from "./tools/appautomate.js"; -import addFailureLogsTools from "./tools/getFailureLogs.js"; -import addAutomateTools from "./tools/automate.js"; -import addSelfHealTools from "./tools/selfheal.js"; -import { setupOnInitialized } from "./oninitialized.js"; - -function registerTools(server: McpServer) { - addAccessibilityTools(server); - addSDKTools(server); - addAppLiveTools(server); - addBrowserLiveTools(server); - addTestManagementTools(server); - addAppAutomationTools(server); - addFailureLogsTools(server); - addAutomateTools(server); - addSelfHealTools(server); -} - -// Create an MCP server -const server: McpServer = new McpServer({ - name: "BrowserStack MCP Server", - version: packageJson.version, -}); - -setupOnInitialized(server); - -registerTools(server); +import { createMcpServer } from "./server-factory.js"; async function main() { logger.info( @@ -46,8 +14,30 @@ async function main() { packageJson.version, ); - // Start receiving messages on stdin and sending messages on stdout + const remoteMCP = process.env.REMOTE_MCP === "true"; + if (remoteMCP) { + logger.info("Running in remote MCP mode"); + return; + } + + const username = process.env.BROWSERSTACK_USERNAME; + const accessKey = process.env.BROWSERSTACK_ACCESS_KEY; + + if (!username) { + throw new Error("BROWSERSTACK_USERNAME environment variable is required"); + } + + if (!accessKey) { + throw new Error("BROWSERSTACK_ACCESS_KEY environment variable is required"); + } + const transport = new StdioServerTransport(); + + const server = createMcpServer({ + "browserstack-username": username, + "browserstack-access-key": accessKey, + }); + await server.connect(transport); } @@ -57,3 +47,5 @@ main().catch(console.error); process.on("exit", () => { logger.flush(); }); + +export { createMcpServer } from "./server-factory.js"; diff --git a/src/lib/api.ts b/src/lib/api.ts index af453f1..64051e7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,19 +1,25 @@ -import config from "../config.js"; +import { getBrowserStackAuth } from "./get-auth.js"; +import { BrowserStackConfig } from "../lib/types.js"; +import { apiClient } from "./apiClient.js"; export async function getLatestO11YBuildInfo( buildName: string, projectName: string, + config: BrowserStackConfig, ) { const buildsUrl = `https://api-observability.browserstack.com/ext/v1/builds/latest?build_name=${encodeURIComponent( buildName, )}&project_name=${encodeURIComponent(projectName)}`; - const buildsResponse = await fetch(buildsUrl, { + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); + + const buildsResponse = await apiClient.get({ + url: buildsUrl, headers: { - Authorization: `Basic ${Buffer.from( - `${config.browserstackUsername}:${config.browserstackAccessKey}`, - ).toString("base64")}`, + Authorization: `Basic ${auth}`, }, + raise_error: false, }); if (!buildsResponse.ok) { @@ -25,5 +31,5 @@ export async function getLatestO11YBuildInfo( throw new Error(`Failed to fetch builds: ${buildsResponse.statusText}`); } - return buildsResponse.json(); + return buildsResponse; } diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts new file mode 100644 index 0000000..555f51b --- /dev/null +++ b/src/lib/apiClient.ts @@ -0,0 +1,137 @@ +import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; + +type RequestOptions = { + url: string; + headers?: Record; + params?: Record; + body?: any; + raise_error?: boolean; // default: true +}; + +class ApiResponse { + private _response: AxiosResponse; + + constructor(response: AxiosResponse) { + this._response = response; + } + + get data(): T { + return this._response.data; + } + + get status(): number { + return this._response.status; + } + + get statusText(): string { + return this._response.statusText; + } + + get headers(): Record { + const raw = this._response.headers; + const sanitized: Record = {}; + + for (const key in raw) { + const value = raw[key]; + if (typeof value === "string") { + sanitized[key] = value; + } + } + + return sanitized; + } + + get config(): AxiosRequestConfig { + return this._response.config; + } + + get url(): string | undefined { + return this._response.config.url; + } + + get ok(): boolean { + return this._response.status >= 200 && this._response.status < 300; + } +} + +class ApiClient { + private instance = axios.create(); + + private async requestWrapper( + fn: () => Promise>, + raise_error: boolean = true, + ): Promise> { + try { + const res = await fn(); + return new ApiResponse(res); + } catch (error: any) { + if (error.response && !raise_error) { + return new ApiResponse(error.response); + } + throw error; + } + } + + async get({ + url, + headers, + params, + raise_error = true, + }: RequestOptions): Promise> { + return this.requestWrapper( + () => this.instance.get(url, { headers, params }), + raise_error, + ); + } + + async post({ + url, + headers, + body, + raise_error = true, + }: RequestOptions): Promise> { + return this.requestWrapper( + () => this.instance.post(url, body, { headers }), + raise_error, + ); + } + + async put({ + url, + headers, + body, + raise_error = true, + }: RequestOptions): Promise> { + return this.requestWrapper( + () => this.instance.put(url, body, { headers }), + raise_error, + ); + } + + async patch({ + url, + headers, + body, + raise_error = true, + }: RequestOptions): Promise> { + return this.requestWrapper( + () => this.instance.patch(url, body, { headers }), + raise_error, + ); + } + + async delete({ + url, + headers, + params, + raise_error = true, + }: RequestOptions): Promise> { + return this.requestWrapper( + () => this.instance.delete(url, { headers, params }), + raise_error, + ); + } +} + +export const apiClient = new ApiClient(); +export type { ApiResponse, RequestOptions }; diff --git a/src/lib/chitragupta-logger/chitragupta.d.ts b/src/lib/chitragupta-logger/chitragupta.d.ts new file mode 100644 index 0000000..49887b2 --- /dev/null +++ b/src/lib/chitragupta-logger/chitragupta.d.ts @@ -0,0 +1,11 @@ +declare module "chitragupta" { + interface FormatterOptions { + level: string; + message: string; + meta: any; + } + + export class Chitragupta { + static jsonLogFormatter(options: FormatterOptions): string; + } +} diff --git a/src/lib/chitragupta-logger/logger.ts b/src/lib/chitragupta-logger/logger.ts new file mode 100644 index 0000000..9854c62 --- /dev/null +++ b/src/lib/chitragupta-logger/logger.ts @@ -0,0 +1,33 @@ +import * as winston from "winston"; +import { TransformableInfo } from "logform"; +import { Chitragupta } from "chitragupta"; + +interface ChitraguptaOptions { + level: string; + message: string; + meta: any; +} + +const jsonLogFormatter = winston.format.printf( + (info: TransformableInfo): string => { + const { level, message, ...meta } = info; + + const options: ChitraguptaOptions = { + level, + message: message as string, + meta, + }; + + return Chitragupta.jsonLogFormatter(options); + }, +); + +const logger: winston.Logger = winston.createLogger({ + transports: [ + new winston.transports.Console({ + format: jsonLogFormatter, + }), + ], +}); + +export default logger; diff --git a/src/lib/device-cache.ts b/src/lib/device-cache.ts index 5131bb5..55010a4 100644 --- a/src/lib/device-cache.ts +++ b/src/lib/device-cache.ts @@ -1,6 +1,7 @@ import fs from "fs"; import os from "os"; import path from "path"; +import { apiClient } from "./apiClient.js"; const CACHE_DIR = path.join(os.homedir(), ".browserstack", "combined_cache"); const CACHE_FILE = path.join(CACHE_DIR, "data.json"); @@ -48,7 +49,7 @@ export async function getDevicesAndBrowsers( } } - const liveRes = await fetch(URLS[type]); + const liveRes = await apiClient.get({ url: URLS[type], raise_error: false }); if (!liveRes.ok) { throw new Error( @@ -56,10 +57,8 @@ export async function getDevicesAndBrowsers( ); } - const data = await liveRes.json(); - cache = { - [type]: data, + [type]: liveRes.data, }; fs.writeFileSync(CACHE_FILE, JSON.stringify(cache), "utf8"); diff --git a/src/lib/get-auth.ts b/src/lib/get-auth.ts new file mode 100644 index 0000000..074e141 --- /dev/null +++ b/src/lib/get-auth.ts @@ -0,0 +1,10 @@ +import { BrowserStackConfig } from "../lib/types.js"; + +export function getBrowserStackAuth(config: BrowserStackConfig): string { + const username = config["browserstack-username"]; + const accessKey = config["browserstack-access-key"]; + if (!username || !accessKey) { + throw new Error("BrowserStack credentials not set on server.authHeaders"); + } + return `${username}:${accessKey}`; +} diff --git a/src/lib/instrumentation.ts b/src/lib/instrumentation.ts index e6eccb4..59e5924 100644 --- a/src/lib/instrumentation.ts +++ b/src/lib/instrumentation.ts @@ -1,5 +1,5 @@ import logger from "../logger.js"; -import config from "../config.js"; +import { getBrowserStackAuth } from "./get-auth.js"; import { createRequire } from "module"; const require = createRequire(import.meta.url); const packageJson = require("../../package.json"); @@ -21,12 +21,8 @@ export function trackMCP( toolName: string, clientInfo: { name?: string; version?: string }, error?: unknown, + config?: any, ): void { - if (config.DEV_MODE) { - logger.info("Tracking MCP is disabled in dev mode"); - return; - } - const instrumentationEndpoint = "https://api.browserstack.com/sdk/v1/event"; const isSuccess = !error; const mcpClient = clientInfo?.name || "unknown"; @@ -58,13 +54,17 @@ export function trackMCP( error instanceof Error ? error.constructor.name : "Unknown"; } + let authHeader = undefined; + if (config) { + const authString = getBrowserStackAuth(config); + authHeader = `Basic ${Buffer.from(authString).toString("base64")}`; + } + axios .post(instrumentationEndpoint, event, { headers: { "Content-Type": "application/json", - Authorization: `Basic ${Buffer.from( - `${config.browserstackUsername}:${config.browserstackAccessKey}`, - ).toString("base64")}`, + ...(authHeader ? { Authorization: authHeader } : {}), }, timeout: 2000, }) diff --git a/src/lib/local.ts b/src/lib/local.ts index 8308d5b..0654a01 100644 --- a/src/lib/local.ts +++ b/src/lib/local.ts @@ -74,6 +74,8 @@ export async function killExistingBrowserStackLocalProcesses() { } export async function ensureLocalBinarySetup( + username: string, + password: string, localIdentifier?: string, ): Promise { logger.info( @@ -104,8 +106,8 @@ export async function ensureLocalBinarySetup( // Use a single options object from config and extend with required fields const bsLocalArgs: Record = { ...(config.browserstackLocalOptions || {}), - key: config.browserstackAccessKey, - username: config.browserstackUsername, + key: password, + username, }; if (localIdentifier) { diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..6de14c2 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,4 @@ +export type BrowserStackConfig = { + "browserstack-username": string; + "browserstack-access-key": string; +}; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 00cf097..e19532c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,4 +1,5 @@ import sharp from "sharp"; +import type { ApiResponse } from "./apiClient.js"; export function sanitizeUrlParam(param: string): string { // Remove any characters that could be used for command injection @@ -24,7 +25,10 @@ export async function maybeCompressBase64(base64: string): Promise { return compressedBuffer.toString("base64"); } -export async function assertOkResponse(response: Response, action: string) { +export async function assertOkResponse( + response: Response | ApiResponse, + action: string, +) { if (!response.ok) { if (response.status === 404) { throw new Error(`Invalid session ID for ${action}`); diff --git a/src/logger.ts b/src/logger.ts index 8020128..7e533ae 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,8 +1,12 @@ import { pino } from "pino"; +import config from "./config.js"; +import chitraguptaLogger from "./lib/chitragupta-logger/logger.js"; -let logger: pino.Logger; +let logger: any; -if (process.env.NODE_ENV === "development") { +if (config.REMOTE_MCP) { + logger = chitraguptaLogger; +} else if (process.env.NODE_ENV === "development") { logger = pino({ level: "debug", transport: { diff --git a/src/oninitialized.ts b/src/oninitialized.ts index a3f2e4f..ec3c74c 100644 --- a/src/oninitialized.ts +++ b/src/oninitialized.ts @@ -1,8 +1,7 @@ -import config from "./config.js"; import { trackMCP } from "./lib/instrumentation.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -export function setupOnInitialized(server: McpServer) { +export function setupOnInitialized(server: McpServer, config?: any) { const nodeVersion = process.versions.node; // Check for Node.js version @@ -12,13 +11,7 @@ export function setupOnInitialized(server: McpServer) { ); } - // Check for BrowserStack credentials - if (!config.browserstackUsername || !config.browserstackAccessKey) { - throw new Error( - "BrowserStack credentials are missing. Please provide a valid username and access key.", - ); - } server.server.oninitialized = () => { - trackMCP("started", server.server.getClientVersion()!); + trackMCP("started", server.server.getClientVersion()!, undefined, config); }; } diff --git a/src/server-factory.ts b/src/server-factory.ts new file mode 100644 index 0000000..d471467 --- /dev/null +++ b/src/server-factory.ts @@ -0,0 +1,46 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createRequire } from "module"; +const require = createRequire(import.meta.url); +const packageJson = require("../package.json"); +import logger from "./logger.js"; +import addSDKTools from "./tools/bstack-sdk.js"; +import addBrowserLiveTools from "./tools/live.js"; +import addAccessibilityTools from "./tools/accessibility.js"; +import addTestManagementTools from "./tools/testmanagement.js"; +import addAppAutomationTools from "./tools/appautomate.js"; +import addFailureLogsTools from "./tools/getFailureLogs.js"; +import addAutomateTools from "./tools/automate.js"; +import addSelfHealTools from "./tools/selfheal.js"; +import addAppLiveTools from "./tools/applive.js"; +import { setupOnInitialized } from "./oninitialized.js"; +import { BrowserStackConfig } from "./lib/types.js"; + +function registerTools(server: McpServer, config: BrowserStackConfig) { + addAccessibilityTools(server, config); + addSDKTools(server, config); + addAppLiveTools(server, config); + addBrowserLiveTools(server, config); + addTestManagementTools(server, config); + addAppAutomationTools(server, config); + addFailureLogsTools(server, config); + addAutomateTools(server, config); + addSelfHealTools(server, config); +} + +export function createMcpServer(config: BrowserStackConfig): McpServer { + logger.info( + "Creating BrowserStack MCP Server, version %s", + packageJson.version, + ); + + // Create an MCP server + const server: McpServer = new McpServer({ + name: "BrowserStack MCP Server", + version: packageJson.version, + }); + + setupOnInitialized(server, config); + registerTools(server, config); + + return server; +} diff --git a/src/tools/accessibility.ts b/src/tools/accessibility.ts index d7eddaf..304d843 100644 --- a/src/tools/accessibility.ts +++ b/src/tools/accessibility.ts @@ -6,15 +6,21 @@ import { AccessibilityReportFetcher } from "./accessiblity-utils/report-fetcher. import { trackMCP } from "../lib/instrumentation.js"; import { parseAccessibilityReportFromCSV } from "./accessiblity-utils/report-parser.js"; import { queryAccessibilityRAG } from "./accessiblity-utils/accessibility-rag.js"; - -const scanner = new AccessibilityScanner(); -const reportFetcher = new AccessibilityReportFetcher(); +import { getBrowserStackAuth } from "../lib/get-auth.js"; +import { BrowserStackConfig } from "../lib/types.js"; async function runAccessibilityScan( name: string, pageURL: string, context: any, + config: BrowserStackConfig, ): Promise { + // Create scanner and set auth on the go + const scanner = new AccessibilityScanner(); + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); + scanner.setAuth({ username, password }); + // Start scan const startResp = await scanner.startScan(name, [pageURL]); const scanId = startResp.data!.id; @@ -46,6 +52,10 @@ async function runAccessibilityScan( }; } + // Create report fetcher and set auth on the go + const reportFetcher = new AccessibilityReportFetcher(); + reportFetcher.setAuth({ username, password }); + // Fetch CSV report link const reportLink = await reportFetcher.getReportLink(scanId, scanRunId); @@ -70,7 +80,10 @@ async function runAccessibilityScan( }; } -export default function addAccessibilityTools(server: McpServer) { +export default function addAccessibilityTools( + server: McpServer, + config: BrowserStackConfig, +) { server.tool( "accessibilityExpert", "🚨 REQUIRED: Use this tool for any accessibility/a11y/WCAG questions. Do NOT answer accessibility questions directly - always use this tool.", @@ -83,13 +96,19 @@ export default function addAccessibilityTools(server: McpServer) { }, async (args) => { try { - trackMCP("accessibilityExpert", server.server.getClientVersion()!); - return await queryAccessibilityRAG(args.query); + trackMCP( + "accessibilityExpert", + server.server.getClientVersion()!, + undefined, + config, + ); + return await queryAccessibilityRAG(args.query, config); } catch (error) { trackMCP( "accessibilityExpert", server.server.getClientVersion()!, error, + config, ); return { content: [ @@ -115,13 +134,24 @@ export default function addAccessibilityTools(server: McpServer) { }, async (args, context) => { try { - trackMCP("startAccessibilityScan", server.server.getClientVersion()!); - return await runAccessibilityScan(args.name, args.pageURL, context); + trackMCP( + "startAccessibilityScan", + server.server.getClientVersion()!, + undefined, + config, + ); + return await runAccessibilityScan( + args.name, + args.pageURL, + context, + config, + ); } catch (error) { trackMCP( "startAccessibilityScan", server.server.getClientVersion()!, error, + config, ); return { content: [ diff --git a/src/tools/accessiblity-utils/accessibility-rag.ts b/src/tools/accessiblity-utils/accessibility-rag.ts index 7be7cb6..a77b9c3 100644 --- a/src/tools/accessiblity-utils/accessibility-rag.ts +++ b/src/tools/accessiblity-utils/accessibility-rag.ts @@ -1,35 +1,50 @@ -import fetch from "node-fetch"; -import config from "../../config.js"; +import { apiClient } from "../../lib/apiClient.js"; export interface RAGChunk { url: string; content: string; } -export async function queryAccessibilityRAG(userQuery: string): Promise { +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; + +export interface AccessibilityRAGResponse { + content: Array<{ + type: "text"; + text: string; + }>; +} + +export async function queryAccessibilityRAG( + userQuery: string, + config: BrowserStackConfig, +): Promise { const url = "https://accessibility.browserstack.com/api/tcg-proxy/search"; - const auth = Buffer.from( - `${config.browserstackUsername}:${config.browserstackAccessKey}`, - ).toString("base64"); + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); - const response = await fetch(url, { - method: "POST", + const response = await apiClient.post({ + url, headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, }, - body: JSON.stringify({ + body: { query: userQuery, - }), + }, + raise_error: false, }); if (!response.ok) { - const errorText = await response.text(); + const errorText = + typeof response.data === "string" + ? response.data + : JSON.stringify(response.data); throw new Error(`RAG endpoint error: ${response.status} ${errorText}`); } - const responseData = (await response.json()) as any; + const responseData = response.data as any; if (!responseData.success) { throw new Error("Something went wrong: " + responseData.message); @@ -39,8 +54,21 @@ export async function queryAccessibilityRAG(userQuery: string): Promise { let parsedData; try { parsedData = JSON.parse(responseData.data); - } catch { - throw new Error("Failed to parse RAG response data as JSON"); + } catch (err) { + throw new Error( + "Failed to parse RAG response data as JSON: " + + (err instanceof Error ? err.message : String(err)), + ); + } + + if ( + !parsedData || + !parsedData.data || + !Array.isArray(parsedData.data.chunks) + ) { + throw new Error( + "RAG response data is missing expected 'data.chunks' array", + ); } const chunks: RAGChunk[] = parsedData.data.chunks; diff --git a/src/tools/accessiblity-utils/report-fetcher.ts b/src/tools/accessiblity-utils/report-fetcher.ts index 498ea20..b5ad029 100644 --- a/src/tools/accessiblity-utils/report-fetcher.ts +++ b/src/tools/accessiblity-utils/report-fetcher.ts @@ -1,5 +1,4 @@ -import axios from "axios"; -import config from "../../config.js"; +import { apiClient } from "../../lib/apiClient.js"; interface ReportInitResponse { success: true; @@ -14,34 +13,47 @@ interface ReportResponse { } export class AccessibilityReportFetcher { - private auth = { - username: config.browserstackUsername, - password: config.browserstackAccessKey, - }; + private auth: { username: string; password: string } | undefined; + + public setAuth(auth: { username: string; password: string }): void { + this.auth = auth; + } async getReportLink(scanId: string, scanRunId: string): Promise { // Initiate CSV link generation const initUrl = `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?scan_run_id=${scanRunId}`; - const initResp = await axios.get(initUrl, { - auth: this.auth, + + let basicAuthHeader = undefined; + if (this.auth) { + const { username, password } = this.auth; + basicAuthHeader = + "Basic " + Buffer.from(`${username}:${password}`).toString("base64"); + } + const initResp = await apiClient.get({ + url: initUrl, + headers: basicAuthHeader ? { Authorization: basicAuthHeader } : undefined, }); - if (!initResp.data.success) { + const initData: ReportInitResponse = initResp.data; + if (!initData.success) { throw new Error( - `Failed to initiate report: ${initResp.data.error || initResp.data.data.message}`, + `Failed to initiate report: ${initData.error || initData.data.message}`, ); } - const taskId = initResp.data.data.task_id; + const taskId = initData.data.task_id; // Fetch the generated CSV link const reportUrl = `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?task_id=${encodeURIComponent( taskId, )}`; - const reportResp = await axios.get(reportUrl, { - auth: this.auth, + // Use apiClient for the report link request as well + const reportResp = await apiClient.get({ + url: reportUrl, + headers: basicAuthHeader ? { Authorization: basicAuthHeader } : undefined, }); - if (!reportResp.data.success) { - throw new Error(`Failed to fetch report: ${reportResp.data.error}`); + const reportData: ReportResponse = reportResp.data; + if (!reportData.success) { + throw new Error(`Failed to fetch report: ${reportData.error}`); } - return reportResp.data.data.reportLink; + return reportData.data.reportLink; } } diff --git a/src/tools/accessiblity-utils/report-parser.ts b/src/tools/accessiblity-utils/report-parser.ts index ffef7d6..0510b44 100644 --- a/src/tools/accessiblity-utils/report-parser.ts +++ b/src/tools/accessiblity-utils/report-parser.ts @@ -1,4 +1,4 @@ -import fetch from "node-fetch"; +import { apiClient } from "../../lib/apiClient.js"; import { parse } from "csv-parse/sync"; type SimplifiedAccessibilityIssue = { @@ -30,9 +30,10 @@ export async function parseAccessibilityReportFromCSV( { maxCharacterLength = 5000, nextPage = 0 }: PaginationOptions = {}, ): Promise { // 1) Download & parse - const res = await fetch(reportLink); + const res = await apiClient.get({ url: reportLink }); if (!res.ok) throw new Error(`Failed to download report: ${res.statusText}`); - const text = await res.text(); + const text = + typeof res.data === "string" ? res.data : JSON.stringify(res.data); const all: SimplifiedAccessibilityIssue[] = parse(text, { columns: true, skip_empty_lines: true, diff --git a/src/tools/accessiblity-utils/scanner.ts b/src/tools/accessiblity-utils/scanner.ts index 3c4974e..640da0b 100644 --- a/src/tools/accessiblity-utils/scanner.ts +++ b/src/tools/accessiblity-utils/scanner.ts @@ -1,11 +1,12 @@ -import axios from "axios"; -import config from "../../config.js"; +import { apiClient } from "../../lib/apiClient.js"; +import { randomUUID } from "node:crypto"; import logger from "../../logger.js"; import { isLocalURL, ensureLocalBinarySetup, killExistingBrowserStackLocalProcesses, } from "../../lib/local.js"; +import config from "../../config.js"; export interface AccessibilityScanResponse { success: boolean; @@ -20,18 +21,24 @@ export interface AccessibilityScanStatus { } export class AccessibilityScanner { - private auth = { - username: config.browserstackUsername, - password: config.browserstackAccessKey, - }; + private auth: { username: string; password: string } | undefined; + + public setAuth(auth: { username: string; password: string }): void { + this.auth = auth; + } async startScan( name: string, urlList: string[], ): Promise { + if (!this.auth?.username || !this.auth?.password) { + throw new Error( + "BrowserStack credentials are not set for AccessibilityScanner.", + ); + } // Check if any URL is local const hasLocal = urlList.some(isLocalURL); - const localIdentifier = crypto.randomUUID(); + const localIdentifier = randomUUID(); const localHosts = new Set(["127.0.0.1", "localhost", "0.0.0.0"]); const BS_LOCAL_DOMAIN = "bs-local.com"; @@ -41,8 +48,18 @@ export class AccessibilityScanner { ); } + if (config.REMOTE_MCP && hasLocal) { + throw new Error( + "Local URLs are not supported in this remote mcp. Please use a public URL.", + ); + } + if (hasLocal) { - await ensureLocalBinarySetup(localIdentifier); + await ensureLocalBinarySetup( + this.auth.username, + this.auth.password, + localIdentifier, + ); } else { await killExistingBrowserStackLocalProcesses(); } @@ -79,28 +96,35 @@ export class AccessibilityScanner { } try { - const { data } = await axios.post( - "https://api-accessibility.browserstack.com/api/website-scanner/v1/scans", - requestBody, - { auth: this.auth }, - ); + const response = await apiClient.post({ + url: "https://api-accessibility.browserstack.com/api/website-scanner/v1/scans", + headers: { + Authorization: + "Basic " + + Buffer.from(`${this.auth.username}:${this.auth.password}`).toString( + "base64", + ), + "Content-Type": "application/json", + }, + body: requestBody, + }); + const data = response.data; if (!data.success) throw new Error(`Unable to start scan: ${data.errors?.join(", ")}`); return data; - } catch (err) { - if (axios.isAxiosError(err) && err.response?.data) { - if (err.response.status === 422) { - throw new Error( - "A scan with this name already exists. please update the name and run again.", - ); - } - const msg = - (err.response.data as any).error || - (err.response.data as any).message || - err.message; - throw new Error(`Failed to start scan: ${msg}`); + } catch (err: any) { + // apiClient throws generic errors, try to extract message + if (err?.response?.status === 422) { + throw new Error( + "A scan with this name already exists. please update the name and run again.", + ); } - throw err; + const msg = + err?.response?.data?.error || + err?.response?.data?.message || + err?.message || + String(err); + throw new Error(`Failed to start scan: ${msg}`); } } @@ -109,19 +133,23 @@ export class AccessibilityScanner { scanRunId: string, ): Promise { try { - const { data } = await axios.get( - `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/${scanRunId}/status`, - { auth: this.auth }, - ); + const response = await apiClient.get({ + url: `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/${scanRunId}/status`, + headers: { + Authorization: + "Basic " + + Buffer.from( + `${this.auth?.username}:${this.auth?.password}`, + ).toString("base64"), + }, + }); + const data = response.data; if (!data.success) throw new Error(`Failed to get status: ${data.errors?.join(", ")}`); return data; - } catch (err) { - if (axios.isAxiosError(err) && err.response?.data) { - const msg = (err.response.data as any).message || err.message; - throw new Error(`Failed to get scan status: ${msg}`); - } - throw err; + } catch (err: any) { + const msg = err?.response?.data?.message || err?.message || String(err); + throw new Error(`Failed to get scan status: ${msg}`); } } diff --git a/src/tools/appautomate-utils/appautomate.ts b/src/tools/appautomate-utils/appautomate.ts index 0083893..eb47f54 100644 --- a/src/tools/appautomate-utils/appautomate.ts +++ b/src/tools/appautomate-utils/appautomate.ts @@ -1,13 +1,8 @@ import fs from "fs"; -import axios from "axios"; -import config from "../../config.js"; import FormData from "form-data"; +import { apiClient } from "../../lib/apiClient.js"; import { customFuzzySearch } from "../../lib/fuzzy.js"; - -const auth = { - username: config.browserstackUsername, - password: config.browserstackAccessKey, -}; +import { BrowserStackConfig } from "../../lib/types.js"; interface Device { device: string; @@ -129,7 +124,11 @@ export function validateArgs(args: { /** * Uploads an application file to AppAutomate and returns the app URL */ -export async function uploadApp(appPath: string): Promise { +export async function uploadApp( + appPath: string, + username: string, + password: string, +): Promise { const filePath = appPath; if (!fs.existsSync(filePath)) { @@ -139,19 +138,20 @@ export async function uploadApp(appPath: string): Promise { const formData = new FormData(); formData.append("file", fs.createReadStream(filePath)); - const response = await axios.post( - "https://api-cloud.browserstack.com/app-automate/upload", - formData, - { - headers: formData.getHeaders(), - auth, + const response = await apiClient.post({ + url: "https://api-cloud.browserstack.com/app-automate/upload", + headers: { + ...formData.getHeaders(), + Authorization: + "Basic " + Buffer.from(`${username}:${password}`).toString("base64"), }, - ); + body: formData, + }); if (response.data.app_url) { return response.data.app_url; } else { - throw new Error(`Failed to upload app: ${response.data}`); + throw new Error(`Failed to upload app: ${JSON.stringify(response.data)}`); } } @@ -160,6 +160,7 @@ async function uploadFileToBrowserStack( filePath: string, endpoint: string, responseKey: string, + config: BrowserStackConfig, ): Promise { if (!fs.existsSync(filePath)) { throw new Error(`File not found at path: ${filePath}`); @@ -168,9 +169,19 @@ async function uploadFileToBrowserStack( const formData = new FormData(); formData.append("file", fs.createReadStream(filePath)); - const response = await axios.post(endpoint, formData, { - headers: formData.getHeaders(), - auth, + const authHeader = + "Basic " + + Buffer.from( + `${config["browserstack-username"]}:${config["browserstack-access-key"]}`, + ).toString("base64"); + + const response = await apiClient.post({ + url: endpoint, + headers: { + ...formData.getHeaders(), + Authorization: authHeader, + }, + body: formData, }); if (response.data[responseKey]) { @@ -181,42 +192,54 @@ async function uploadFileToBrowserStack( } //Uploads an Android app (.apk or .aab) to BrowserStack Espresso endpoint and returns the app_url -export async function uploadEspressoApp(appPath: string): Promise { +export async function uploadEspressoApp( + appPath: string, + config: BrowserStackConfig, +): Promise { return uploadFileToBrowserStack( appPath, "https://api-cloud.browserstack.com/app-automate/espresso/v2/app", "app_url", + config, ); } //Uploads an Espresso test suite (.apk) to BrowserStack and returns the test_suite_url export async function uploadEspressoTestSuite( testSuitePath: string, + config: BrowserStackConfig, ): Promise { return uploadFileToBrowserStack( testSuitePath, "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite", "test_suite_url", + config, ); } //Uploads an iOS app (.ipa) to BrowserStack XCUITest endpoint and returns the app_url -export async function uploadXcuiApp(appPath: string): Promise { +export async function uploadXcuiApp( + appPath: string, + config: BrowserStackConfig, +): Promise { return uploadFileToBrowserStack( appPath, "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app", "app_url", + config, ); } //Uploads an XCUITest test suite (.zip) to BrowserStack and returns the test_suite_url export async function uploadXcuiTestSuite( testSuitePath: string, + config: BrowserStackConfig, ): Promise { return uploadFileToBrowserStack( testSuitePath, "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite", "test_suite_url", + config, ); } @@ -227,18 +250,26 @@ export async function triggerEspressoBuild( devices: string[], project: string, ): Promise { - const response = await axios.post( - "https://api-cloud.browserstack.com/app-automate/espresso/v2/build", - { + const auth = { + username: process.env.BROWSERSTACK_USERNAME || "", + password: process.env.BROWSERSTACK_ACCESS_KEY || "", + }; + + const response = await apiClient.post({ + url: "https://api-cloud.browserstack.com/app-automate/espresso/v2/build", + headers: { + Authorization: + "Basic " + + Buffer.from(`${auth.username}:${auth.password}`).toString("base64"), + "Content-Type": "application/json", + }, + body: { app: app_url, testSuite: test_suite_url, devices, project, }, - { - auth, - }, - ); + }); if (response.data.build_id) { return response.data.build_id; @@ -255,19 +286,28 @@ export async function triggerXcuiBuild( test_suite_url: string, devices: string[], project: string, + config: BrowserStackConfig, ): Promise { - const response = await axios.post( - "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/build", - { + const auth = { + username: config["browserstack-username"], + password: config["browserstack-access-key"], + }; + + const response = await apiClient.post({ + url: "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/build", + headers: { + Authorization: + "Basic " + + Buffer.from(`${auth.username}:${auth.password}`).toString("base64"), + "Content-Type": "application/json", + }, + body: { app: app_url, testSuite: test_suite_url, devices, project, }, - { - auth, - }, - ); + }); if (response.data.build_id) { return response.data.build_id; diff --git a/src/tools/appautomate.ts b/src/tools/appautomate.ts index 90031bd..954d5fe 100644 --- a/src/tools/appautomate.ts +++ b/src/tools/appautomate.ts @@ -2,7 +2,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import logger from "../logger.js"; -import config from "../config.js"; +import { getBrowserStackAuth } from "../lib/get-auth.js"; +import { BrowserStackConfig } from "../lib/types.js"; import { trackMCP } from "../lib/instrumentation.js"; import { maybeCompressBase64 } from "../lib/utils.js"; import { remote } from "webdriverio"; @@ -54,11 +55,12 @@ async function takeAppScreenshot(args: { desiredPlatformVersion: string; appPath: string; desiredPhone: string; + config: BrowserStackConfig; }): Promise { let driver; try { validateArgs(args); - const { desiredPlatform, desiredPhone, appPath } = args; + const { desiredPlatform, desiredPhone, appPath, config } = args; let { desiredPlatformVersion } = args; const platforms = ( @@ -93,8 +95,10 @@ async function takeAppScreenshot(args: { `Device "${desiredPhone}" with version ${desiredPlatformVersion} not found.`, ); } + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); - const app_url = await uploadApp(appPath); + const app_url = await uploadApp(appPath, username, password); logger.info(`App uploaded. URL: ${app_url}`); const capabilities = { @@ -104,8 +108,8 @@ async function takeAppScreenshot(args: { "appium:app": app_url, "appium:autoGrantPermissions": true, "bstack:options": { - userName: config.browserstackUsername, - accessKey: config.browserstackAccessKey, + userName: username, + accessKey: password, appiumVersion: "2.0.1", }, }; @@ -151,19 +155,23 @@ async function takeAppScreenshot(args: { } //Runs AppAutomate tests on BrowserStack by uploading app and test suite, then triggering a test run. -async function runAppTestsOnBrowserStack(args: { - appPath: string; - testSuitePath: string; - devices: string[]; - project: string; - detectedAutomationFramework: string; -}): Promise { +async function runAppTestsOnBrowserStack( + args: { + appPath: string; + testSuitePath: string; + devices: string[]; + project: string; + detectedAutomationFramework: string; + }, + config: BrowserStackConfig, +): Promise { switch (args.detectedAutomationFramework) { case AppTestPlatform.ESPRESSO: { try { - const app_url = await uploadEspressoApp(args.appPath); + const app_url = await uploadEspressoApp(args.appPath, config); const test_suite_url = await uploadEspressoTestSuite( args.testSuitePath, + config, ); const build_id = await triggerEspressoBuild( app_url, @@ -187,13 +195,17 @@ async function runAppTestsOnBrowserStack(args: { } case AppTestPlatform.XCUITEST: { try { - const app_url = await uploadXcuiApp(args.appPath); - const test_suite_url = await uploadXcuiTestSuite(args.testSuitePath); + const app_url = await uploadXcuiApp(args.appPath, config); + const test_suite_url = await uploadXcuiTestSuite( + args.testSuitePath, + config, + ); const build_id = await triggerXcuiBuild( app_url, test_suite_url, args.devices, args.project, + config, ); return { content: [ @@ -215,8 +227,10 @@ async function runAppTestsOnBrowserStack(args: { } } -// Registers automation tools with the MCP server. -export default function addAppAutomationTools(server: McpServer) { +export default function addAppAutomationTools( + server: McpServer, + config: BrowserStackConfig, +) { 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.", @@ -242,10 +256,20 @@ export default function addAppAutomationTools(server: McpServer) { }, async (args) => { try { - trackMCP("takeAppScreenshot", server.server.getClientVersion()!); - return await takeAppScreenshot(args); + trackMCP( + "takeAppScreenshot", + server.server.getClientVersion()!, + undefined, + config, + ); + return await takeAppScreenshot({ ...args, config }); } catch (error) { - trackMCP("takeAppScreenshot", server.server.getClientVersion()!, error); + trackMCP( + "takeAppScreenshot", + server.server.getClientVersion()!, + error, + config, + ); const errorMessage = error instanceof Error ? error.message : "Unknown error"; return { @@ -309,13 +333,16 @@ export default function addAppAutomationTools(server: McpServer) { trackMCP( "runAppTestsOnBrowserStack", server.server.getClientVersion()!, + undefined, + config, ); - return await runAppTestsOnBrowserStack(args); + return await runAppTestsOnBrowserStack(args, config); } catch (error) { trackMCP( "runAppTestsOnBrowserStack", server.server.getClientVersion()!, error, + config, ); const errorMessage = error instanceof Error ? error.message : "Unknown error"; diff --git a/src/tools/applive-utils/start-session.ts b/src/tools/applive-utils/start-session.ts index 5ba4b5f..7b7ac8b 100644 --- a/src/tools/applive-utils/start-session.ts +++ b/src/tools/applive-utils/start-session.ts @@ -1,14 +1,17 @@ import logger from "../../logger.js"; -import childProcess from "child_process"; import { getDevicesAndBrowsers, BrowserStackProducts, } from "../../lib/device-cache.js"; import { sanitizeUrlParam } from "../../lib/utils.js"; import { uploadApp } from "./upload-app.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { findDeviceByName } from "./device-search.js"; import { pickVersion } from "./version-utils.js"; import { DeviceEntry } from "./types.js"; +import childProcess from "child_process"; +import { BrowserStackConfig } from "../../lib/types.js"; +import envConfig from "../../config.js"; interface StartSessionArgs { appPath: string; @@ -17,12 +20,20 @@ interface StartSessionArgs { desiredPlatformVersion: string; } +interface StartSessionOptions { + config: BrowserStackConfig; +} + /** * Start an App Live session: filter, select, upload, and open. */ -export async function startSession(args: StartSessionArgs): Promise { +export async function startSession( + args: StartSessionArgs, + options: StartSessionOptions, +): Promise { const { appPath, desiredPlatform, desiredPhone, desiredPlatformVersion } = args; + const { config } = options; // 1) Fetch devices for APP_LIVE const data = await getDevicesAndBrowsers(BrowserStackProducts.APP_LIVE); @@ -61,7 +72,9 @@ export async function startSession(args: StartSessionArgs): Promise { } // 6) Upload app - const { app_url } = await uploadApp(appPath); + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); + const { app_url } = await uploadApp(appPath, username, password); logger.info(`App uploaded: ${app_url}`); if (!app_url) { @@ -82,7 +95,10 @@ export async function startSession(args: StartSessionArgs): Promise { }); const launchUrl = `https://app-live.browserstack.com/dashboard#${params.toString()}&device=${deviceParam}`; - openBrowser(launchUrl); + if (!envConfig.REMOTE_MCP) { + openBrowser(launchUrl); + } + return launchUrl + note; } diff --git a/src/tools/applive-utils/upload-app.ts b/src/tools/applive-utils/upload-app.ts index a6dc756..e370864 100644 --- a/src/tools/applive-utils/upload-app.ts +++ b/src/tools/applive-utils/upload-app.ts @@ -1,13 +1,16 @@ -import axios, { AxiosError } from "axios"; +import { apiClient } from "../../lib/apiClient.js"; import FormData from "form-data"; import fs from "fs"; -import config from "../../config.js"; interface UploadResponse { app_url: string; } -export async function uploadApp(filePath: string): Promise { +export async function uploadApp( + filePath: string, + username: string, + password: string, +): Promise { if (!fs.existsSync(filePath)) { throw new Error(`File not found at path: ${filePath}`); } @@ -16,27 +19,20 @@ export async function uploadApp(filePath: string): Promise { formData.append("file", fs.createReadStream(filePath)); try { - const response = await axios.post( - "https://api-cloud.browserstack.com/app-live/upload", - formData, - { - headers: { - ...formData.getHeaders(), - }, - auth: { - username: config.browserstackUsername, - password: config.browserstackAccessKey, - }, + const response = await apiClient.post({ + url: "https://api-cloud.browserstack.com/app-live/upload", + headers: { + ...formData.getHeaders(), + Authorization: + "Basic " + Buffer.from(`${username}:${password}`).toString("base64"), }, - ); + body: formData, + }); return response.data; - } catch (error: unknown) { - if (error instanceof AxiosError) { - throw new Error( - `Failed to upload app: ${error.response?.data?.message || error.message}`, - ); - } - throw error; + } catch (error: any) { + const msg = + error?.response?.data?.message || error?.message || String(error); + throw new Error(`Failed to upload app: ${msg}`); } } diff --git a/src/tools/applive.ts b/src/tools/applive.ts index 28312cd..89a43f3 100644 --- a/src/tools/applive.ts +++ b/src/tools/applive.ts @@ -5,16 +5,20 @@ import fs from "fs"; import { startSession } from "./applive-utils/start-session.js"; import logger from "../logger.js"; import { trackMCP } from "../lib/instrumentation.js"; +import { BrowserStackConfig } from "../lib/types.js"; /** * Launches an App Live Session on BrowserStack. */ -export async function startAppLiveSession(args: { - desiredPlatform: string; - desiredPlatformVersion: string; - appPath: string; - desiredPhone: string; -}): Promise { +export async function startAppLiveSession( + args: { + desiredPlatform: string; + desiredPlatformVersion: string; + appPath: string; + desiredPhone: string; + }, + config: BrowserStackConfig, +): Promise { if (!args.desiredPlatform) { throw new Error("You must provide a desiredPlatform."); } @@ -46,12 +50,15 @@ export async function startAppLiveSession(args: { throw new Error("The app path does not exist or is not readable."); } - const launchUrl = await startSession({ - appPath: args.appPath, - desiredPlatform: args.desiredPlatform as "android" | "ios", - desiredPhone: args.desiredPhone, - desiredPlatformVersion: args.desiredPlatformVersion, - }); + const launchUrl = await startSession( + { + appPath: args.appPath, + desiredPlatform: args.desiredPlatform as "android" | "ios", + desiredPhone: args.desiredPhone, + desiredPlatformVersion: args.desiredPlatformVersion, + }, + { config }, + ); return { content: [ @@ -63,7 +70,10 @@ export async function startAppLiveSession(args: { }; } -export default function addAppLiveTools(server: McpServer) { +export default function addAppLiveTools( + server: McpServer, + config: BrowserStackConfig, +) { server.tool( "runAppLiveSession", "Use this tool when user wants to manually check their app on a particular mobile device using BrowserStack's cloud infrastructure. Can be used to debug crashes, slow performance, etc.", @@ -91,11 +101,21 @@ export default function addAppLiveTools(server: McpServer) { }, async (args) => { try { - trackMCP("runAppLiveSession", server.server.getClientVersion()!); - return await startAppLiveSession(args); + trackMCP( + "runAppLiveSession", + server.server.getClientVersion()!, + undefined, + config, + ); + return await startAppLiveSession(args, config); } catch (error) { logger.error("App live session failed: %s", error); - trackMCP("runAppLiveSession", server.server.getClientVersion()!, error); + trackMCP( + "runAppLiveSession", + server.server.getClientVersion()!, + error, + config, + ); return { content: [ { diff --git a/src/tools/automate-utils/fetch-screenshots.ts b/src/tools/automate-utils/fetch-screenshots.ts index eabbc7a..5574f0b 100644 --- a/src/tools/automate-utils/fetch-screenshots.ts +++ b/src/tools/automate-utils/fetch-screenshots.ts @@ -1,28 +1,35 @@ -import config from "../../config.js"; import { assertOkResponse, maybeCompressBase64 } from "../../lib/utils.js"; import { SessionType } from "../../lib/constants.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; +import { apiClient } from "../../lib/apiClient.js"; -//Extracts screenshot URLs from BrowserStack session logs async function extractScreenshotUrls( sessionId: string, sessionType: SessionType, + config: BrowserStackConfig, ): Promise { - const credentials = `${config.browserstackUsername}:${config.browserstackAccessKey}`; - const auth = Buffer.from(credentials).toString("base64"); + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); const baseUrl = `https://api.browserstack.com/${sessionType === SessionType.Automate ? "automate" : "app-automate"}`; const url = `${baseUrl}/sessions/${sessionId}/logs`; - const response = await fetch(url, { + const response = await apiClient.get({ + url, headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, }, + raise_error: false, }); await assertOkResponse(response, "Session"); - const text = await response.text(); + const text = + typeof response.data === "string" + ? response.data + : JSON.stringify(response.data); const urls: string[] = []; const SCREENSHOT_PATTERN = /REQUEST.*GET.*\/screenshot/; @@ -52,9 +59,9 @@ async function convertUrlsToBase64( ): Promise> { const screenshots = await Promise.all( urls.map(async (url) => { - const response = await fetch(url); - const arrayBuffer = await response.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString("base64"); + const response = await apiClient.get({ url }); + // Axios returns response.data as a Buffer for binary data + const base64 = Buffer.from(response.data).toString("base64"); // Compress the base64 image if needed const compressedBase64 = await maybeCompressBase64(base64); @@ -73,8 +80,9 @@ async function convertUrlsToBase64( export async function fetchAutomationScreenshots( sessionId: string, sessionType: SessionType = SessionType.Automate, + config: BrowserStackConfig, ) { - const urls = await extractScreenshotUrls(sessionId, sessionType); + const urls = await extractScreenshotUrls(sessionId, sessionType, config); if (urls.length === 0) { return []; } diff --git a/src/tools/automate.ts b/src/tools/automate.ts index b0f1f73..340eff5 100644 --- a/src/tools/automate.ts +++ b/src/tools/automate.ts @@ -5,16 +5,21 @@ import { fetchAutomationScreenshots } from "./automate-utils/fetch-screenshots.j import { SessionType } from "../lib/constants.js"; import { trackMCP } from "../lib/instrumentation.js"; import logger from "../logger.js"; +import { BrowserStackConfig } from "../lib/types.js"; // Tool function that fetches and processes screenshots from BrowserStack Automate session -export async function fetchAutomationScreenshotsTool(args: { - sessionId: string; - sessionType: SessionType; -}): Promise { +export async function fetchAutomationScreenshotsTool( + args: { + sessionId: string; + sessionType: SessionType; + }, + config: BrowserStackConfig, +): Promise { try { const screenshots = await fetchAutomationScreenshots( args.sessionId, args.sessionType, + config, ); if (screenshots.length === 0) { @@ -53,7 +58,10 @@ export async function fetchAutomationScreenshotsTool(args: { } //Registers the fetchAutomationScreenshots tool with the MCP server -export default function addAutomationTools(server: McpServer) { +export default function addAutomationTools( + server: McpServer, + config: BrowserStackConfig, +) { server.tool( "fetchAutomationScreenshots", "Fetch and process screenshots from a BrowserStack Automate session", @@ -70,13 +78,16 @@ export default function addAutomationTools(server: McpServer) { trackMCP( "fetchAutomationScreenshots", server.server.getClientVersion()!, + undefined, + config, ); - return await fetchAutomationScreenshotsTool(args); + return await fetchAutomationScreenshotsTool(args, config); } catch (error) { trackMCP( "fetchAutomationScreenshots", server.server.getClientVersion()!, error, + config, ); const errorMessage = error instanceof Error ? error.message : "Unknown error"; diff --git a/src/tools/bstack-sdk.ts b/src/tools/bstack-sdk.ts index bc89a22..83999b1 100644 --- a/src/tools/bstack-sdk.ts +++ b/src/tools/bstack-sdk.ts @@ -23,6 +23,8 @@ import { formatPercyInstructions, getPercyInstructions, } from "./sdk-utils/percy/instructions.js"; +import { getBrowserStackAuth } from "../lib/get-auth.js"; +import { BrowserStackConfig } from "../lib/types.js"; /** * BrowserStack SDK hooks into your test framework to seamlessly run tests on BrowserStack. @@ -34,13 +36,19 @@ export async function bootstrapProjectWithSDK({ detectedLanguage, desiredPlatforms, enablePercy, + config, }: { detectedBrowserAutomationFramework: SDKSupportedBrowserAutomationFramework; detectedTestingFramework: SDKSupportedTestingFramework; detectedLanguage: SDKSupportedLanguage; desiredPlatforms: string[]; enablePercy: boolean; + config: BrowserStackConfig; }): Promise { + // Get credentials from config + const authString = getBrowserStackAuth(config); + const [username, accessKey] = authString.split(":"); + // Handle frameworks with unique setup instructions that don't use browserstack.yml if ( detectedBrowserAutomationFramework === "cypress" || @@ -50,6 +58,8 @@ export async function bootstrapProjectWithSDK({ detectedBrowserAutomationFramework, detectedTestingFramework, detectedLanguage, + username, + accessKey, ); if (enablePercy) { @@ -77,6 +87,8 @@ export async function bootstrapProjectWithSDK({ const sdkSetupCommand = getSDKPrefixCommand( detectedLanguage, detectedTestingFramework, + username, + accessKey, ); const ymlInstructions = generateBrowserStackYMLInstructions( @@ -89,6 +101,8 @@ export async function bootstrapProjectWithSDK({ detectedBrowserAutomationFramework, detectedTestingFramework, detectedLanguage, + username, + accessKey, ); let combinedInstructions = ""; @@ -149,7 +163,10 @@ function formatFinalInstructions(combinedInstructions: string): CallToolResult { }; } -export default function addSDKTools(server: McpServer) { +export default function addSDKTools( + server: McpServer, + config: BrowserStackConfig, +) { server.tool( "runTestsOnBrowserStack", "Use this tool to get instructions for running tests on BrowserStack and BrowserStack Percy. It sets up the BrowserStack SDK and runs your test cases on BrowserStack.", @@ -189,7 +206,12 @@ export default function addSDKTools(server: McpServer) { async (args) => { try { - trackMCP("runTestsOnBrowserStack", server.server.getClientVersion()!); + trackMCP( + "runTestsOnBrowserStack", + server.server.getClientVersion()!, + undefined, + config, + ); return await bootstrapProjectWithSDK({ detectedBrowserAutomationFramework: @@ -202,12 +224,14 @@ export default function addSDKTools(server: McpServer) { desiredPlatforms: args.desiredPlatforms, enablePercy: args.enablePercy, + config, }); } catch (error) { trackMCP( "runTestsOnBrowserStack", server.server.getClientVersion()!, error, + config, ); return { diff --git a/src/tools/failurelogs-utils/app-automate.ts b/src/tools/failurelogs-utils/app-automate.ts index 7aff74a..85d33a4 100644 --- a/src/tools/failurelogs-utils/app-automate.ts +++ b/src/tools/failurelogs-utils/app-automate.ts @@ -1,28 +1,34 @@ -import config from "../../config.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { filterLinesByKeywords, validateLogResponse } from "./utils.js"; - -const auth = Buffer.from( - `${config.browserstackUsername}:${config.browserstackAccessKey}`, -).toString("base64"); +import { BrowserStackConfig } from "../../lib/types.js"; +import { apiClient } from "../../lib/apiClient.js"; // DEVICE LOGS export async function retrieveDeviceLogs( sessionId: string, buildId: string, + config: BrowserStackConfig, ): Promise { const url = `https://api.browserstack.com/app-automate/builds/${buildId}/sessions/${sessionId}/deviceLogs`; + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); - const response = await fetch(url, { + const response = await apiClient.get({ + url, headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, }, + raise_error: false, }); const validationError = validateLogResponse(response, "device logs"); if (validationError) return validationError.message!; - const logText = await response.text(); + const logText = + typeof response.data === "string" + ? response.data + : JSON.stringify(response.data); const logs = filterDeviceFailures(logText); return logs.length > 0 ? `Device Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` @@ -33,20 +39,28 @@ export async function retrieveDeviceLogs( export async function retrieveAppiumLogs( sessionId: string, buildId: string, + config: BrowserStackConfig, ): Promise { const url = `https://api.browserstack.com/app-automate/builds/${buildId}/sessions/${sessionId}/appiumlogs`; + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); - const response = await fetch(url, { + const response = await apiClient.get({ + url, headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, }, + raise_error: false, }); const validationError = validateLogResponse(response, "Appium logs"); if (validationError) return validationError.message!; - const logText = await response.text(); + const logText = + typeof response.data === "string" + ? response.data + : JSON.stringify(response.data); const logs = filterAppiumFailures(logText); return logs.length > 0 ? `Appium Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` @@ -57,20 +71,28 @@ export async function retrieveAppiumLogs( export async function retrieveCrashLogs( sessionId: string, buildId: string, + config: BrowserStackConfig, ): Promise { const url = `https://api.browserstack.com/app-automate/builds/${buildId}/sessions/${sessionId}/crashlogs`; + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); - const response = await fetch(url, { + const response = await apiClient.get({ + url, headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, }, + raise_error: false, }); const validationError = validateLogResponse(response, "crash logs"); if (validationError) return validationError.message!; - const logText = await response.text(); + const logText = + typeof response.data === "string" + ? response.data + : JSON.stringify(response.data); const logs = filterCrashFailures(logText); return logs.length > 0 ? `Crash Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` diff --git a/src/tools/failurelogs-utils/automate.ts b/src/tools/failurelogs-utils/automate.ts index e8b5e95..9f2cf29 100644 --- a/src/tools/failurelogs-utils/automate.ts +++ b/src/tools/failurelogs-utils/automate.ts @@ -1,33 +1,35 @@ -import config from "../../config.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { HarEntry, HarFile, filterLinesByKeywords, validateLogResponse, } from "./utils.js"; - -const auth = Buffer.from( - `${config.browserstackUsername}:${config.browserstackAccessKey}`, -).toString("base64"); +import { BrowserStackConfig } from "../../lib/types.js"; +import { apiClient } from "../../lib/apiClient.js"; // NETWORK LOGS export async function retrieveNetworkFailures( sessionId: string, + config: BrowserStackConfig, ): Promise { const url = `https://api.browserstack.com/automate/sessions/${sessionId}/networklogs`; + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); - const response = await fetch(url, { - method: "GET", + const response = await apiClient.get({ + url, headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, }, + raise_error: false, }); const validationError = validateLogResponse(response, "network logs"); if (validationError) return validationError.message!; - const networklogs: HarFile = await response.json(); + const networklogs: HarFile = response.data; const failureEntries: HarEntry[] = networklogs.log.entries.filter( (entry: HarEntry) => entry.response.status === 0 || @@ -61,20 +63,28 @@ export async function retrieveNetworkFailures( // SESSION LOGS export async function retrieveSessionFailures( sessionId: string, + config: BrowserStackConfig, ): Promise { const url = `https://api.browserstack.com/automate/sessions/${sessionId}/logs`; + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); - const response = await fetch(url, { + const response = await apiClient.get({ + url, headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, }, + raise_error: false, }); const validationError = validateLogResponse(response, "session logs"); if (validationError) return validationError.message!; - const logText = await response.text(); + const logText = + typeof response.data === "string" + ? response.data + : JSON.stringify(response.data); const logs = filterSessionFailures(logText); return logs.length > 0 ? `Session Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` @@ -84,20 +94,28 @@ export async function retrieveSessionFailures( // CONSOLE LOGS export async function retrieveConsoleFailures( sessionId: string, + config: BrowserStackConfig, ): Promise { const url = `https://api.browserstack.com/automate/sessions/${sessionId}/consolelogs`; + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); - const response = await fetch(url, { + const response = await apiClient.get({ + url, headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, }, + raise_error: false, }); const validationError = validateLogResponse(response, "console logs"); if (validationError) return validationError.message!; - const logText = await response.text(); + const logText = + typeof response.data === "string" + ? response.data + : JSON.stringify(response.data); const logs = filterConsoleFailures(logText); return logs.length > 0 ? `Console Failures (${logs.length} found):\n${JSON.stringify(logs, null, 2)}` diff --git a/src/tools/failurelogs-utils/utils.ts b/src/tools/failurelogs-utils/utils.ts index 68f617b..3cc8084 100644 --- a/src/tools/failurelogs-utils/utils.ts +++ b/src/tools/failurelogs-utils/utils.ts @@ -1,3 +1,5 @@ +import type { ApiResponse } from "../../lib/apiClient.js"; + export interface LogResponse { logs?: any[]; message?: string; @@ -26,7 +28,7 @@ export interface HarEntry { } export function validateLogResponse( - response: Response, + response: Response | ApiResponse, logType: string, ): LogResponse | null { if (!response.ok) { diff --git a/src/tools/getFailureLogs.ts b/src/tools/getFailureLogs.ts index e6e8b7d..782e43d 100644 --- a/src/tools/getFailureLogs.ts +++ b/src/tools/getFailureLogs.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import logger from "../logger.js"; import { trackMCP } from "../lib/instrumentation.js"; +import { BrowserStackConfig } from "../lib/types.js"; import { retrieveNetworkFailures, @@ -26,12 +27,15 @@ type LogType = AutomateLogType | AppAutomateLogType; type SessionTypeValues = SessionType; // Main log fetcher function -export async function getFailureLogs(args: { - sessionId: string; - buildId?: string; - logTypes: LogType[]; - sessionType: SessionTypeValues; -}): Promise { +export async function getFailureLogs( + args: { + sessionId: string; + buildId?: string; + logTypes: LogType[]; + sessionType: SessionTypeValues; + }, + config: BrowserStackConfig, +): Promise { const results: CallToolResult["content"] = []; const errors: string[] = []; let validLogTypes: LogType[] = []; @@ -97,37 +101,49 @@ export async function getFailureLogs(args: { for (const logType of validLogTypes) { switch (logType) { case AutomateLogType.NetworkLogs: { - response = await retrieveNetworkFailures(args.sessionId); + response = await retrieveNetworkFailures(args.sessionId, config); results.push({ type: "text", text: response }); break; } case AutomateLogType.SessionLogs: { - response = await retrieveSessionFailures(args.sessionId); + response = await retrieveSessionFailures(args.sessionId, config); results.push({ type: "text", text: response }); break; } case AutomateLogType.ConsoleLogs: { - response = await retrieveConsoleFailures(args.sessionId); + response = await retrieveConsoleFailures(args.sessionId, config); results.push({ type: "text", text: response }); break; } case AppAutomateLogType.DeviceLogs: { - response = await retrieveDeviceLogs(args.sessionId, args.buildId!); + response = await retrieveDeviceLogs( + args.sessionId, + args.buildId!, + config, + ); results.push({ type: "text", text: response }); break; } case AppAutomateLogType.AppiumLogs: { - response = await retrieveAppiumLogs(args.sessionId, args.buildId!); + response = await retrieveAppiumLogs( + args.sessionId, + args.buildId!, + config, + ); results.push({ type: "text", text: response }); break; } case AppAutomateLogType.CrashLogs: { - response = await retrieveCrashLogs(args.sessionId, args.buildId!); + response = await retrieveCrashLogs( + args.sessionId, + args.buildId!, + config, + ); results.push({ type: "text", text: response }); break; } @@ -149,7 +165,10 @@ export async function getFailureLogs(args: { } // Register tool with the MCP server -export default function registerGetFailureLogs(server: McpServer) { +export default function registerGetFailureLogs( + server: McpServer, + config: BrowserStackConfig, +) { server.tool( "getFailureLogs", "Fetch various types of logs from a BrowserStack session. Supports both automate and app-automate sessions.", @@ -185,11 +204,21 @@ export default function registerGetFailureLogs(server: McpServer) { }, async (args) => { try { - trackMCP("getFailureLogs", server.server.getClientVersion()!); - return await getFailureLogs(args); + trackMCP( + "getFailureLogs", + server.server.getClientVersion()!, + undefined, + config, + ); + return await getFailureLogs(args, config); } catch (error) { const message = error instanceof Error ? error.message : String(error); - trackMCP("getFailureLogs", server.server.getClientVersion()!, error); + trackMCP( + "getFailureLogs", + server.server.getClientVersion()!, + error, + config, + ); logger.error("Failed to fetch logs: %s", message); return { content: [ diff --git a/src/tools/live-utils/start-session.ts b/src/tools/live-utils/start-session.ts index 1efb776..04960fa 100644 --- a/src/tools/live-utils/start-session.ts +++ b/src/tools/live-utils/start-session.ts @@ -15,13 +15,26 @@ import { killExistingBrowserStackLocalProcesses, } from "../../lib/local.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; +import envConfig from "../../config.js"; + /** * Prepares local tunnel setup based on URL type */ -async function prepareLocalTunnel(url: string): Promise { +async function prepareLocalTunnel( + url: string, + username: string, + password: string, +): Promise { const isLocal = isLocalURL(url); + if (isLocal && envConfig.REMOTE_MCP) { + throw new Error( + "Local URLs are not supported in this remote mcp. Please use a public URL.", + ); + } if (isLocal) { - await ensureLocalBinarySetup(); + await ensureLocalBinarySetup(username, password); } else { await killExistingBrowserStackLocalProcesses(); } @@ -33,13 +46,24 @@ async function prepareLocalTunnel(url: string): Promise { */ export async function startBrowserSession( args: DesktopSearchArgs | MobileSearchArgs, + config: BrowserStackConfig, ): Promise { const entry = args.platformType === PlatformType.DESKTOP ? await filterDesktop(args as DesktopSearchArgs) : await filterMobile(args as MobileSearchArgs); - const isLocal = await prepareLocalTunnel(args.url); + // Get credentials from config + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); + + if (!username || !password) { + throw new Error( + "BrowserStack credentials are not set. Please configure them in the server settings.", + ); + } + + const isLocal = await prepareLocalTunnel(args.url, username, password); const url = args.platformType === PlatformType.DESKTOP @@ -49,8 +73,9 @@ export async function startBrowserSession( isLocal, ) : buildMobileUrl(args as MobileSearchArgs, entry as MobileEntry, isLocal); - - openBrowser(url); + if (!envConfig.REMOTE_MCP) { + openBrowser(url); + } return entry.notes ? `${url}, ${entry.notes}` : url; } diff --git a/src/tools/live.ts b/src/tools/live.ts index 02f9eae..cc313c7 100644 --- a/src/tools/live.ts +++ b/src/tools/live.ts @@ -5,6 +5,7 @@ import logger from "../logger.js"; import { startBrowserSession } from "./live-utils/start-session.js"; import { PlatformType } from "./live-utils/types.js"; import { trackMCP } from "../lib/instrumentation.js"; +import { BrowserStackConfig } from "../lib/types.js"; // Define the schema shape const LiveArgsShape = { @@ -41,20 +42,24 @@ const LiveArgsSchema = z.object(LiveArgsShape); */ async function launchDesktopSession( args: z.infer, + config: BrowserStackConfig, ): Promise { if (!args.desiredBrowser) throw new Error("You must provide a desiredBrowser"); if (!args.desiredBrowserVersion) throw new Error("You must provide a desiredBrowserVersion"); - return startBrowserSession({ - platformType: PlatformType.DESKTOP, - url: args.desiredURL, - os: args.desiredOS, - osVersion: args.desiredOSVersion, - browser: args.desiredBrowser, - browserVersion: args.desiredBrowserVersion, - }); + return startBrowserSession( + { + platformType: PlatformType.DESKTOP, + url: args.desiredURL, + os: args.desiredOS, + osVersion: args.desiredOSVersion, + browser: args.desiredBrowser, + browserVersion: args.desiredBrowserVersion, + }, + config, + ); } /** @@ -62,31 +67,35 @@ async function launchDesktopSession( */ async function launchMobileSession( args: z.infer, + config: BrowserStackConfig, ): Promise { if (!args.desiredDevice) throw new Error("You must provide a desiredDevice"); - return startBrowserSession({ - platformType: PlatformType.MOBILE, - browser: args.desiredBrowser, - url: args.desiredURL, - os: args.desiredOS, - osVersion: args.desiredOSVersion, - device: args.desiredDevice, - }); + return startBrowserSession( + { + platformType: PlatformType.MOBILE, + browser: args.desiredBrowser, + url: args.desiredURL, + os: args.desiredOS, + osVersion: args.desiredOSVersion, + device: args.desiredDevice, + }, + config, + ); } /** * Handles the core logic for running a browser session */ -async function runBrowserSession(rawArgs: any) { +async function runBrowserSession(rawArgs: any, config: BrowserStackConfig) { // Validate and narrow const args = LiveArgsSchema.parse(rawArgs); // Branch desktop vs mobile and delegate const launchUrl = args.platformType === PlatformType.DESKTOP - ? await launchDesktopSession(args) - : await launchMobileSession(args); + ? await launchDesktopSession(args, config) + : await launchMobileSession(args, config); return { content: [ @@ -98,21 +107,30 @@ async function runBrowserSession(rawArgs: any) { }; } -export default function addBrowserLiveTools(server: McpServer) { +export default function addBrowserLiveTools( + server: McpServer, + config: BrowserStackConfig, +) { server.tool( "runBrowserLiveSession", "Launch a BrowserStack Live session (desktop or mobile).", LiveArgsShape, async (args) => { try { - trackMCP("runBrowserLiveSession", server.server.getClientVersion()!); - return await runBrowserSession(args); + trackMCP( + "runBrowserLiveSession", + server.server.getClientVersion()!, + undefined, + config, + ); + return await runBrowserSession(args, config); } catch (error) { logger.error("Live session failed: %s", error); trackMCP( "runBrowserLiveSession", server.server.getClientVersion()!, error, + config, ); return { content: [ diff --git a/src/tools/observability.ts b/src/tools/observability.ts index b5ad127..ae0e8bb 100644 --- a/src/tools/observability.ts +++ b/src/tools/observability.ts @@ -4,14 +4,25 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { getLatestO11YBuildInfo } from "../lib/api.js"; import { trackMCP } from "../lib/instrumentation.js"; import logger from "../logger.js"; +import { BrowserStackConfig } from "../lib/types.js"; export async function getFailuresInLastRun( buildName: string, projectName: string, + config: BrowserStackConfig, ): Promise { - const buildsData = await getLatestO11YBuildInfo(buildName, projectName); + const buildsData = await getLatestO11YBuildInfo( + buildName, + projectName, + config, + ); - const observabilityUrl = buildsData.observability_url; + if (!buildsData.data) { + throw new Error( + "No observability URL found in build data, this is likely because the build is not yet available on BrowserStack Observability.", + ); + } + const observabilityUrl = buildsData.data.observability_url; if (!observabilityUrl) { throw new Error( "No observability URL found in build data, this is likely because the build is not yet available on BrowserStack Observability.", @@ -19,13 +30,13 @@ export async function getFailuresInLastRun( } let overview = "No overview available"; - if (buildsData.unique_errors?.overview?.insight) { - overview = buildsData.unique_errors.overview.insight; + if (buildsData.data.unique_errors?.overview?.insight) { + overview = buildsData.data.unique_errors.overview.insight; } let details = "No error details available"; - if (buildsData.unique_errors?.top_unique_errors?.length > 0) { - details = buildsData.unique_errors.top_unique_errors + if (buildsData.data.unique_errors?.top_unique_errors?.length > 0) { + details = buildsData.data.unique_errors.top_unique_errors .map((error: any) => error.error) .filter(Boolean) .join("\n"); @@ -41,7 +52,10 @@ export async function getFailuresInLastRun( }; } -export default function addObservabilityTools(server: McpServer) { +export default function addObservabilityTools( + server: McpServer, + config: BrowserStackConfig, +) { server.tool( "getFailuresInLastRun", "Use this tool to debug failures in the last run of the test suite on BrowserStack. Use only when browserstack.yml file is present in the project root.", @@ -59,14 +73,24 @@ export default function addObservabilityTools(server: McpServer) { }, async (args) => { try { - trackMCP("getFailuresInLastRun", server.server.getClientVersion()!); - return await getFailuresInLastRun(args.buildName, args.projectName); + trackMCP( + "getFailuresInLastRun", + server.server.getClientVersion()!, + undefined, + config, + ); + return await getFailuresInLastRun( + args.buildName, + args.projectName, + config, + ); } catch (error) { logger.error("Failed to get failures in the last run: %s", error); trackMCP( "getFailuresInLastRun", server.server.getClientVersion()!, error, + config, ); return { content: [ diff --git a/src/tools/sdk-utils/commands.ts b/src/tools/sdk-utils/commands.ts index 6865551..28ff9a5 100644 --- a/src/tools/sdk-utils/commands.ts +++ b/src/tools/sdk-utils/commands.ts @@ -25,6 +25,8 @@ const GRADLE_SETUP_INSTRUCTIONS = ` export function getSDKPrefixCommand( language: SDKSupportedLanguage, framework: string, + username: string, + accessKey: string, ): string { switch (language) { case "nodejs": @@ -36,7 +38,7 @@ npm i -D browserstack-node-sdk@latest ---STEP--- Run the following command to setup browserstack sdk: \`\`\`bash -npx setup --username ${process.env.BROWSERSTACK_USERNAME} --key ${process.env.BROWSERSTACK_ACCESS_KEY} +npx setup --username ${username} --key ${accessKey} \`\`\` ---STEP--- Edit the browserstack.yml file that was created in the project root to add your desired platforms and browsers.`; @@ -50,8 +52,8 @@ Edit the browserstack.yml file that was created in the project root to add your : `mvn archetype:generate -B -DarchetypeGroupId=com.browserstack \\ -DarchetypeArtifactId=browserstack-sdk-archetype-integrate -DarchetypeVersion=1.0 \\ -DgroupId=com.browserstack -DartifactId=browserstack-sdk-archetype-integrate -Dversion=1.0 \\ --DBROWSERSTACK_USERNAME="${process.env.BROWSERSTACK_USERNAME}" \\ --DBROWSERSTACK_ACCESS_KEY="${process.env.BROWSERSTACK_ACCESS_KEY}" \\ +-DBROWSERSTACK_USERNAME="${username}" \\ +-DBROWSERSTACK_ACCESS_KEY="${accessKey}" \\ -DBROWSERSTACK_FRAMEWORK="${mavenFramework}"`; const platformLabel = isWindows ? "Windows" : "macOS/Linux"; diff --git a/src/tools/sdk-utils/constants.ts b/src/tools/sdk-utils/constants.ts index 801eb53..15e3c02 100644 --- a/src/tools/sdk-utils/constants.ts +++ b/src/tools/sdk-utils/constants.ts @@ -1,11 +1,10 @@ import { ConfigMapping } from "./types.js"; -import config from "../../config.js"; /** * ---------- PYTHON INSTRUCTIONS ---------- */ -const pythonInstructions = ` +const pythonInstructions = (username: string, accessKey: string) => ` ---STEP--- Install the BrowserStack SDK: @@ -17,7 +16,7 @@ python3 -m pip install browserstack-sdk Setup the BrowserStack SDK with your credentials: \`\`\`bash -browserstack-sdk setup --username "${config.browserstackUsername}" --key "${config.browserstackAccessKey}" +browserstack-sdk setup --username "${username}" --key "${accessKey}" \`\`\` ---STEP--- @@ -28,10 +27,12 @@ browserstack-sdk python \`\`\` `; -const generatePythonFrameworkInstructions = (framework: string) => ` +const generatePythonFrameworkInstructions = + (framework: string) => (username: string, accessKey: string) => ` ---STEP--- Install the BrowserStack SDK: + \`\`\`bash python3 -m pip install browserstack-sdk \`\`\` @@ -40,7 +41,7 @@ python3 -m pip install browserstack-sdk Setup the BrowserStack SDK with framework-specific configuration: \`\`\`bash -browserstack-sdk setup --framework "${framework}" --username "${config.browserstackUsername}" --key "${config.browserstackAccessKey}" +browserstack-sdk setup --framework "${framework}" --username "${username}" --key "${accessKey}" \`\`\` ---STEP--- @@ -62,7 +63,7 @@ const pytestInstructions = generatePythonFrameworkInstructions("pytest"); const argsInstruction = '-javaagent:"${com.browserstack:browserstack-java-sdk:jar}"'; -const javaInstructions = ` +const javaInstructions = (username: string, accessKey: string) => ` ---STEP--- Add the BrowserStack Java SDK dependency to your \`pom.xml\`: @@ -88,8 +89,8 @@ dependencies { Export your BrowserStack credentials as environment variables: \`\`\`bash -export BROWSERSTACK_USERNAME=${config.browserstackUsername} -export BROWSERSTACK_ACCESS_KEY=${config.browserstackAccessKey} +export BROWSERSTACK_USERNAME=${username} +export BROWSERSTACK_ACCESS_KEY=${accessKey} \`\`\` ---STEP--- @@ -109,7 +110,7 @@ gradle clean test * ---------- CSharp INSTRUCTIONS ---------- */ -const csharpCommonInstructions = ` +const csharpCommonInstructions = (username: string, accessKey: string) => ` ---STEP--- Install BrowserStack TestAdapter NuGet package: @@ -128,7 +129,7 @@ dotnet build Set up BrowserStack SDK with your credentials: \`\`\`bash -dotnet browserstack-sdk setup --userName ${config.browserstackUsername} --accessKey ${config.browserstackAccessKey} +dotnet browserstack-sdk setup --userName ${username} --accessKey ${accessKey} \`\`\` ---STEP--- @@ -172,7 +173,10 @@ Run the tests: \`\`\` `; -const csharpPlaywrightCommonInstructions = ` +const csharpPlaywrightCommonInstructions = ( + username: string, + accessKey: string, +) => ` ---STEP--- Install BrowserStack TestAdapter NuGet package: @@ -191,7 +195,7 @@ dotnet build Set up BrowserStack SDK with your credentials: \`\`\`bash -dotnet browserstack-sdk setup --userName ${config.browserstackUsername} --accessKey ${config.browserstackAccessKey} +dotnet browserstack-sdk setup --userName ${username} --accessKey ${accessKey} \`\`\` ---STEP--- @@ -252,7 +256,7 @@ Run the tests: * ---------- NODEJS INSTRUCTIONS ---------- */ -const nodejsInstructions = ` +const nodejsInstructions = (username: string, accessKey: string) => ` ---STEP--- Ensure \`browserstack-node-sdk\` is present in package.json with the latest version: @@ -273,13 +277,17 @@ Add new scripts to package.json for running tests on BrowserStack: Export BrowserStack credentials as environment variables: Set the following environment variables before running tests. +\`\`\`bash +export BROWSERSTACK_USERNAME=${username} +export BROWSERSTACK_ACCESS_KEY=${accessKey} +\`\`\` `; /** * ---------- EXPORT CONFIG ---------- */ -const webdriverioInstructions = ` +const webdriverioInstructions = (username: string, accessKey: string) => ` ---STEP--- Set BrowserStack Credentials: @@ -287,14 +295,14 @@ Export your BrowserStack username and access key as environment variables. For macOS/Linux: \`\`\`bash -export BROWSERSTACK_USERNAME= ${config.browserstackUsername} -export BROWSERSTACK_ACCESS_KEY= ${config.browserstackAccessKey} +export BROWSERSTACK_USERNAME=${username} +export BROWSERSTACK_ACCESS_KEY=${accessKey} \`\`\` For Windows PowerShell: \`\`\`powershell -$env:BROWSERSTACK_USERNAME=${config.browserstackUsername} -$env:BROWSERSTACK_ACCESS_KEY=${config.browserstackAccessKey} +$env:BROWSERSTACK_USERNAME=${username} +$env:BROWSERSTACK_ACCESS_KEY=${accessKey} \`\`\` ---STEP--- @@ -385,7 +393,7 @@ Run your tests: You can now run your tests on BrowserStack using your standard WebdriverIO command. `; -const cypressInstructions = ` +const cypressInstructions = (username: string, accessKey: string) => ` ---STEP--- Install the BrowserStack Cypress CLI: @@ -414,8 +422,8 @@ Open the generated \`browserstack.json\` file and update it with your BrowserSta \`\`\`json { "auth": { - "username": "${config.browserstackUsername}", - "access_key": "${config.browserstackAccessKey}" + "username": "${username}", + "access_key": "${accessKey}" }, "browsers": [ { diff --git a/src/tools/sdk-utils/instructions.ts b/src/tools/sdk-utils/instructions.ts index 861a8d9..1d87d20 100644 --- a/src/tools/sdk-utils/instructions.ts +++ b/src/tools/sdk-utils/instructions.ts @@ -10,6 +10,8 @@ export const getInstructionsForProjectConfiguration = ( detectedBrowserAutomationFramework: SDKSupportedBrowserAutomationFramework, detectedTestingFramework: SDKSupportedTestingFramework, detectedLanguage: SDKSupportedLanguage, + username: string, + accessKey: string, ) => { const configuration = SUPPORTED_CONFIGURATIONS[detectedLanguage]; @@ -33,9 +35,11 @@ export const getInstructionsForProjectConfiguration = ( ); } - return configuration[detectedBrowserAutomationFramework][ - detectedTestingFramework - ].instructions; + const instructionFunction = + configuration[detectedBrowserAutomationFramework][detectedTestingFramework] + .instructions; + + return instructionFunction(username, accessKey); }; export function generateBrowserStackYMLInstructions( diff --git a/src/tools/sdk-utils/types.ts b/src/tools/sdk-utils/types.ts index 9aa9045..7a82790 100644 --- a/src/tools/sdk-utils/types.ts +++ b/src/tools/sdk-utils/types.ts @@ -44,7 +44,10 @@ export type ConfigMapping = Record< Record< SDKSupportedBrowserAutomationFrameworkEnum, Partial< - Record + Record< + SDKSupportedTestingFrameworkEnum, + { instructions: (username: string, accessKey: string) => string } + > > > > diff --git a/src/tools/selfheal-utils/selfheal.ts b/src/tools/selfheal-utils/selfheal.ts index f9b837b..a1464cd 100644 --- a/src/tools/selfheal-utils/selfheal.ts +++ b/src/tools/selfheal-utils/selfheal.ts @@ -1,5 +1,4 @@ import { assertOkResponse } from "../../lib/utils.js"; -import config from "../../config.js"; interface SelectorMapping { originalSelector: string; @@ -10,12 +9,20 @@ interface SelectorMapping { }; } -export async function getSelfHealSelectors(sessionId: string) { - const credentials = `${config.browserstackUsername}:${config.browserstackAccessKey}`; - const auth = Buffer.from(credentials).toString("base64"); +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; +import { apiClient } from "../../lib/apiClient.js"; + +export async function getSelfHealSelectors( + sessionId: string, + config: BrowserStackConfig, +) { + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); const url = `https://api.browserstack.com/automate/sessions/${sessionId}/logs`; - const response = await fetch(url, { + const response = await apiClient.get({ + url, headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, @@ -23,7 +30,10 @@ export async function getSelfHealSelectors(sessionId: string) { }); await assertOkResponse(response, "session logs"); - const logText = await response.text(); + const logText = + typeof response.data === "string" + ? response.data + : JSON.stringify(response.data); return extractHealedSelectors(logText); } diff --git a/src/tools/selfheal.ts b/src/tools/selfheal.ts index 83cd568..0bfe1c5 100644 --- a/src/tools/selfheal.ts +++ b/src/tools/selfheal.ts @@ -4,13 +4,15 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { getSelfHealSelectors } from "./selfheal-utils/selfheal.js"; import logger from "../logger.js"; import { trackMCP } from "../lib/instrumentation.js"; +import { BrowserStackConfig } from "../lib/types.js"; // Tool function that fetches self-healing selectors -export async function fetchSelfHealSelectorTool(args: { - sessionId: string; -}): Promise { +export async function fetchSelfHealSelectorTool( + args: { sessionId: string }, + config: BrowserStackConfig, +): Promise { try { - const selectors = await getSelfHealSelectors(args.sessionId); + const selectors = await getSelfHealSelectors(args.sessionId, config); return { content: [ { @@ -28,7 +30,10 @@ export async function fetchSelfHealSelectorTool(args: { } // Registers the fetchSelfHealSelector tool with the MCP server -export default function addSelfHealTools(server: McpServer) { +export default function addSelfHealTools( + server: McpServer, + config: BrowserStackConfig, +) { server.tool( "fetchSelfHealedSelectors", "Retrieves AI-generated, self-healed selectors for a BrowserStack Automate session to resolve flaky tests caused by dynamic DOM changes.", @@ -37,13 +42,19 @@ export default function addSelfHealTools(server: McpServer) { }, async (args) => { try { - trackMCP("fetchSelfHealedSelectors", server.server.getClientVersion()!); - return await fetchSelfHealSelectorTool(args); + trackMCP( + "fetchSelfHealedSelectors", + server.server.getClientVersion()!, + undefined, + config, + ); + return await fetchSelfHealSelectorTool(args, config); } catch (error) { trackMCP( "fetchSelfHealedSelectors", server.server.getClientVersion()!, error, + config, ); const errorMessage = error instanceof Error ? error.message : "Unknown error"; diff --git a/src/tools/testmanagement-utils/TCG-utils/api.ts b/src/tools/testmanagement-utils/TCG-utils/api.ts index 8fb836d..4298add 100644 --- a/src/tools/testmanagement-utils/TCG-utils/api.ts +++ b/src/tools/testmanagement-utils/TCG-utils/api.ts @@ -1,4 +1,4 @@ -import axios from "axios"; +import { apiClient } from "../../../lib/apiClient.js"; import { TCG_TRIGGER_URL, TCG_POLL_URL, @@ -12,17 +12,20 @@ import { CreateTestCasesFromFileArgs, } from "./types.js"; import { createTestCasePayload } from "./helpers.js"; -import config from "../../../config.js"; +import { getBrowserStackAuth } from "../../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; /** * Fetch default and custom form fields for a project. */ export async function fetchFormFields( projectId: string, + config: BrowserStackConfig, ): Promise<{ default_fields: any; custom_fields: any }> { - const res = await axios.get(FORM_FIELDS_URL(projectId), { + const res = await apiClient.get({ + url: FORM_FIELDS_URL(projectId), headers: { - "API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`, + "API-TOKEN": getBrowserStackAuth(config), }, }); return res.data; @@ -37,10 +40,16 @@ export async function triggerTestCaseGeneration( folderId: string, projectId: string, source: string, + config: BrowserStackConfig, ): Promise { - const res = await axios.post( - TCG_TRIGGER_URL, - { + const res = await apiClient.post({ + url: TCG_TRIGGER_URL, + headers: { + "API-TOKEN": getBrowserStackAuth(config), + "Content-Type": "application/json", + "request-source": source, + }, + body: { document, documentId, folderId, @@ -48,16 +57,9 @@ export async function triggerTestCaseGeneration( source, webhookUrl: `https://test-management.browserstack.com/api/v1/projects/${projectId}/folder/${folderId}/webhooks/tcg`, }, - { - headers: { - "API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`, - "Content-Type": "application/json", - "request-source": source, - }, - }, - ); + }); if (res.status !== 200) { - throw new Error(`Trigger failed: ${res.statusText}`); + throw new Error(`Trigger failed: ${res.statusText || res.status}`); } return res.data["x-bstack-traceRequestId"]; } @@ -71,26 +73,25 @@ export async function fetchTestCaseDetails( projectId: string, testCaseIds: string[], source: string, + config: BrowserStackConfig, ): Promise { if (testCaseIds.length === 0) { throw new Error("No testCaseIds provided to fetchTestCaseDetails"); } - const res = await axios.post( - FETCH_DETAILS_URL, - { + const res = await apiClient.post({ + url: FETCH_DETAILS_URL, + headers: { + "API-TOKEN": getBrowserStackAuth(config), + "request-source": source, + "Content-Type": "application/json", + }, + body: { document_id: documentId, folder_id: folderId, project_id: projectId, test_case_ids: testCaseIds, }, - { - headers: { - "API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`, - "request-source": source, - "Content-Type": "application/json", - }, - }, - ); + }); if (res.data.data.success !== true) { throw new Error(`Fetch details failed: ${res.data.data.message}`); } @@ -102,6 +103,7 @@ export async function fetchTestCaseDetails( */ export async function pollTestCaseDetails( traceRequestId: string, + config: BrowserStackConfig, ): Promise> { const detailMap: Record = {}; let done = false; @@ -110,17 +112,13 @@ export async function pollTestCaseDetails( // add a bit of jitter to avoid synchronized polling storms await new Promise((r) => setTimeout(r, 10000 + Math.random() * 5000)); - const poll = await axios.post( - `${TCG_POLL_URL}?x-bstack-traceRequestId=${encodeURIComponent( - traceRequestId, - )}`, - {}, - { - headers: { - "API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`, - }, + const poll = await apiClient.post({ + url: `${TCG_POLL_URL}?x-bstack-traceRequestId=${encodeURIComponent(traceRequestId)}`, + headers: { + "API-TOKEN": getBrowserStackAuth(config), }, - ); + body: {}, + }); if (!poll.data.data.success) { throw new Error(`Polling failed: ${poll.data.data.message}`); @@ -153,6 +151,7 @@ export async function pollScenariosTestDetails( context: any, documentId: number, source: string, + config: BrowserStackConfig, ): Promise> { const { folderId, projectReferenceId } = args; const scenariosMap: Record = {}; @@ -163,19 +162,17 @@ export async function pollScenariosTestDetails( await new Promise((resolve, reject) => { const intervalId = setInterval(async () => { try { - const poll = await axios.post( - `${TCG_POLL_URL}?x-bstack-traceRequestId=${encodeURIComponent(traceId)}`, - {}, - { - headers: { - "API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`, - }, + const poll = await apiClient.post({ + url: `${TCG_POLL_URL}?x-bstack-traceRequestId=${encodeURIComponent(traceId)}`, + headers: { + "API-TOKEN": getBrowserStackAuth(config), }, - ); + body: {}, + }); if (poll.status !== 200) { clearInterval(intervalId); - reject(new Error(`Polling error: ${poll.statusText}`)); + reject(new Error(`Polling error: ${poll.statusText || poll.status}`)); return; } @@ -212,8 +209,9 @@ export async function pollScenariosTestDetails( projectReferenceId, ids, source, + config, ); - detailPromises.push(pollTestCaseDetails(reqId)); + detailPromises.push(pollTestCaseDetails(reqId, config)); scenariosMap[sc.id] ||= { id: sc.id, @@ -275,6 +273,7 @@ export async function bulkCreateTestCases( traceId: string, context: any, documentId: number, + config: BrowserStackConfig, ): Promise { const results: Record = {}; const total = Object.keys(scenariosMap).length; @@ -300,16 +299,14 @@ export async function bulkCreateTestCases( }; try { - const resp = await axios.post( - BULK_CREATE_URL(projectId, folderId), - payload, - { - headers: { - "API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`, - "Content-Type": "application/json", - }, + const resp = await apiClient.post({ + url: BULK_CREATE_URL(projectId, folderId), + headers: { + "API-TOKEN": getBrowserStackAuth(config), + "Content-Type": "application/json", }, - ); + body: payload, + }); results[id] = resp.data; await context.sendNotification({ method: "notifications/progress", @@ -342,17 +339,21 @@ export async function bulkCreateTestCases( export async function projectIdentifierToId( projectId: string, + config: BrowserStackConfig, ): Promise { const url = `https://test-management.browserstack.com/api/v1/projects/?q=${projectId}`; - const response = await axios.get(url, { + const response = await apiClient.get({ + url, headers: { - "API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`, + "API-TOKEN": getBrowserStackAuth(config), accept: "application/json, text/plain, */*", }, }); if (response.data.success !== true) { - throw new Error(`Failed to fetch project ID: ${response.statusText}`); + throw new Error( + `Failed to fetch project ID: ${response.statusText || response.status}`, + ); } for (const project of response.data.projects) { if (project.identifier === projectId) { @@ -365,19 +366,21 @@ export async function projectIdentifierToId( export async function testCaseIdentifierToDetails( projectId: string, testCaseIdentifier: string, + config: BrowserStackConfig, ): Promise<{ testCaseId: string; folderId: string }> { const url = `https://test-management.browserstack.com/api/v1/projects/${projectId}/test-cases/search?q[query]=${testCaseIdentifier}`; - const response = await axios.get(url, { + const response = await apiClient.get({ + url, headers: { - "API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`, + "API-TOKEN": getBrowserStackAuth(config), accept: "application/json, text/plain, */*", }, }); if (response.data.success !== true) { throw new Error( - `Failed to fetch test case details: ${response.statusText}`, + `Failed to fetch test case details: ${response.statusText || response.status}`, ); } diff --git a/src/tools/testmanagement-utils/TCG-utils/helpers.ts b/src/tools/testmanagement-utils/TCG-utils/helpers.ts index 40a690c..1a25328 100644 --- a/src/tools/testmanagement-utils/TCG-utils/helpers.ts +++ b/src/tools/testmanagement-utils/TCG-utils/helpers.ts @@ -1,4 +1,5 @@ import { DefaultFieldMaps } from "./types.js"; +import { randomUUID } from "node:crypto"; /** * Build mappings for default fields for priority, status, and case type. @@ -60,7 +61,7 @@ export function createTestCasePayload( rich_text_id: null, scenario: scenarioId, test_case_count: tc.test_case_count || 1, - uuid: tc.uuid || crypto.randomUUID?.() || "unknown-uuid", + uuid: tc.uuid || randomUUID() || "unknown-uuid", "x-bstack-traceRequestId": traceId, }, }), diff --git a/src/tools/testmanagement-utils/add-test-result.ts b/src/tools/testmanagement-utils/add-test-result.ts index b8be68c..c41db3b 100644 --- a/src/tools/testmanagement-utils/add-test-result.ts +++ b/src/tools/testmanagement-utils/add-test-result.ts @@ -1,8 +1,8 @@ -import axios from "axios"; -import config from "../../config.js"; +import { apiClient } from "../../lib/apiClient.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { formatAxiosError } from "../../lib/error.js"; +import { BrowserStackConfig } from "../../lib/types.js"; /** * Schema for adding a test result to a test run. @@ -33,6 +33,7 @@ export type AddTestResultArgs = z.infer; */ export async function addTestResult( rawArgs: AddTestResultArgs, + config: BrowserStackConfig, ): Promise { try { const args = AddTestResultSchema.parse(rawArgs); @@ -45,12 +46,17 @@ export async function addTestResult( test_case_id: args.test_case_id, } as any; - const response = await axios.post(url, body, { - auth: { - username: config.browserstackUsername, - password: config.browserstackAccessKey, + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); + + const response = await apiClient.post({ + url, + headers: { + "Content-Type": "application/json", + Authorization: + "Basic " + Buffer.from(`${username}:${password}`).toString("base64"), }, - headers: { "Content-Type": "application/json" }, + body, }); const data = response.data; @@ -70,6 +76,18 @@ export async function addTestResult( ], }; } catch (err: any) { - return formatAxiosError(err, "Failed to add test result to test run"); + const msg = + err?.response?.data?.message || + err?.response?.data?.error || + err?.message || + String(err); + return { + content: [ + { + type: "text", + text: `Failed to add test result to test run: ${msg}`, + }, + ], + }; } } diff --git a/src/tools/testmanagement-utils/create-lca-steps.ts b/src/tools/testmanagement-utils/create-lca-steps.ts index 4016750..2f6e4a0 100644 --- a/src/tools/testmanagement-utils/create-lca-steps.ts +++ b/src/tools/testmanagement-utils/create-lca-steps.ts @@ -1,13 +1,13 @@ import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import axios from "axios"; -import config from "../../config.js"; -import { formatAxiosError } from "../../lib/error.js"; +import { apiClient } from "../../lib/apiClient.js"; import { projectIdentifierToId, testCaseIdentifierToDetails, } from "./TCG-utils/api.js"; import { pollLCAStatus } from "./poll-lca-status.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; /** * Schema for creating LCA steps for a test case @@ -65,15 +65,20 @@ export type CreateLCAStepsArgs = z.infer; export async function createLCASteps( args: CreateLCAStepsArgs, context: any, + config: BrowserStackConfig, ): Promise { try { // Get the project ID from identifier - const projectId = await projectIdentifierToId(args.project_identifier); + const projectId = await projectIdentifierToId( + args.project_identifier, + config, + ); // Get the test case ID and folder ID from identifier const { testCaseId, folderId } = await testCaseIdentifierToDetails( projectId, args.test_case_identifier, + config, ); const url = `https://test-management.browserstack.com/api/v1/projects/${projectId}/test-cases/${testCaseId}/lcnc`; @@ -88,74 +93,61 @@ export async function createLCASteps( webhook_path: `https://test-management.browserstack.com/api/v1/projects/${projectId}/test-cases/${testCaseId}/webhooks/lcnc`, }; - const response = await axios.post(url, payload, { + await apiClient.post({ + url, headers: { - "API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`, + "API-TOKEN": getBrowserStackAuth(config), accept: "application/json, text/plain, */*", "Content-Type": "application/json", }, + body: payload, }); - if (response.status >= 200 && response.status < 300) { - // Check if user wants to wait for completion - if (!args.wait_for_completion) { + // Check if user wants to wait for completion + if (!args.wait_for_completion) { + return { + content: [ + { + type: "text", + text: `LCA steps creation initiated for test case ${args.test_case_identifier} (ID: ${testCaseId})`, + }, + { + type: "text", + text: "LCA build started. Check the BrowserStack Lowcode Automation UI for completion status.", + }, + ], + }; + } + + // Start polling for LCA build completion + try { + const max_wait_minutes = 10; // Maximum wait time in minutes + const maxWaitMs = max_wait_minutes * 60 * 1000; + const lcaResult = await pollLCAStatus( + projectId, + folderId, + testCaseId, + context, + maxWaitMs, // max wait time + 2 * 60 * 1000, // 2 minutes initial wait + 10 * 1000, // 10 seconds interval + config, + ); + + if (lcaResult && lcaResult.status === "done") { return { content: [ { type: "text", - text: `LCA steps creation initiated for test case ${args.test_case_identifier} (ID: ${testCaseId})`, + text: `Successfully created LCA steps for test case ${args.test_case_identifier} (ID: ${testCaseId})`, }, { type: "text", - text: "LCA build started. Check the BrowserStack Lowcode Automation UI for completion status.", + text: `LCA build completed! Resource URL: ${lcaResult.resource_path}`, }, ], }; - } - - // Start polling for LCA build completion - try { - const max_wait_minutes = 10; // Maximum wait time in minutes - const maxWaitMs = max_wait_minutes * 60 * 1000; - const lcaResult = await pollLCAStatus( - projectId, - folderId, - testCaseId, - context, - maxWaitMs, // max wait time - 2 * 60 * 1000, // 2 minutes initial wait - 10 * 1000, // 10 seconds interval - ); - - if (lcaResult && lcaResult.status === "done") { - return { - content: [ - { - type: "text", - text: `Successfully created LCA steps for test case ${args.test_case_identifier} (ID: ${testCaseId})`, - }, - { - type: "text", - text: `LCA build completed! Resource URL: ${lcaResult.resource_path}`, - }, - ], - }; - } else { - return { - content: [ - { - type: "text", - text: `LCA steps creation initiated for test case ${args.test_case_identifier} (ID: ${testCaseId})`, - }, - { - type: "text", - text: `Warning: LCA build did not complete within ${max_wait_minutes} minutes. You can check the status later in the BrowserStack Test Management UI.`, - }, - ], - }; - } - } catch (pollError) { - console.error("Error during LCA polling:", pollError); + } else { return { content: [ { @@ -164,15 +156,27 @@ export async function createLCASteps( }, { type: "text", - text: "Warning: Error occurred while polling for LCA build completion. Check the BrowserStack Test Management UI for status.", + text: `Warning: LCA build did not complete within ${max_wait_minutes} minutes. You can check the status later in the BrowserStack Test Management UI.`, }, ], }; } - } else { - throw new Error(`Unexpected response: ${JSON.stringify(response.data)}`); + } catch (pollError) { + console.error("Error during LCA polling:", pollError); + return { + content: [ + { + type: "text", + text: `LCA steps creation initiated for test case ${args.test_case_identifier} (ID: ${testCaseId})`, + }, + { + type: "text", + text: "Warning: Error occurred while polling for LCA build completion. Check the BrowserStack Test Management UI for status.", + }, + ], + }; } - } catch (error) { + } catch (error: any) { // Add more specific error handling if (error instanceof Error) { if (error.message.includes("not found")) { @@ -188,6 +192,19 @@ export async function createLCASteps( }; } } - return formatAxiosError(error, "Failed to create LCA steps"); + const msg = + error?.response?.data?.message || + error?.response?.data?.error || + error?.message || + String(error); + return { + content: [ + { + type: "text", + text: `Failed to create LCA steps: ${msg}`, + }, + ], + isError: true, + }; } } diff --git a/src/tools/testmanagement-utils/create-project-folder.ts b/src/tools/testmanagement-utils/create-project-folder.ts index ef83570..df0e17a 100644 --- a/src/tools/testmanagement-utils/create-project-folder.ts +++ b/src/tools/testmanagement-utils/create-project-folder.ts @@ -1,9 +1,10 @@ -import axios from "axios"; -import config from "../../config.js"; +import { apiClient } from "../../lib/apiClient.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { formatAxiosError } from "../../lib/error.js"; // or correct path +import { formatAxiosError } from "../../lib/error.js"; import { projectIdentifierToId } from "../testmanagement-utils/TCG-utils/api.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; // Schema for combined project/folder creation export const CreateProjFoldSchema = z.object({ @@ -37,6 +38,7 @@ type CreateProjFoldArgs = z.infer; */ export async function createProjectOrFolder( args: CreateProjFoldArgs, + config: BrowserStackConfig, ): Promise { const { project_name, @@ -53,22 +55,28 @@ export async function createProjectOrFolder( ); } + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); + let projId = project_identifier; // Step 1: Create project if project_name provided if (project_name) { try { - const res = await axios.post( - "https://test-management.browserstack.com/api/v2/projects", - { project: { name: project_name, description: project_description } }, - { - auth: { - username: config.browserstackUsername, - password: config.browserstackAccessKey, - }, - headers: { "Content-Type": "application/json" }, + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); + const res = await apiClient.post({ + url: "https://test-management.browserstack.com/api/v2/projects", + headers: { + "Content-Type": "application/json", + Authorization: + "Basic " + + Buffer.from(`${username}:${password}`).toString("base64"), + }, + body: { + project: { name: project_name, description: project_description }, }, - ); + }); if (!res.data.success) { throw new Error( @@ -78,8 +86,8 @@ export async function createProjectOrFolder( // Project created successfully projId = res.data.project.identifier; - } catch (err) { - return formatAxiosError(err, "Failed to create project.."); + } catch (err: any) { + return formatAxiosError(err, "Failed to create project."); } } // Step 2: Create folder if folder_name provided @@ -87,25 +95,24 @@ export async function createProjectOrFolder( if (!projId) throw new Error("Cannot create folder without project_identifier."); try { - const res = await axios.post( - `https://test-management.browserstack.com/api/v2/projects/${encodeURIComponent( + const res = await apiClient.post({ + url: `https://test-management.browserstack.com/api/v2/projects/${encodeURIComponent( projId, )}/folders`, - { + headers: { + "Content-Type": "application/json", + Authorization: + "Basic " + + Buffer.from(`${username}:${password}`).toString("base64"), + }, + body: { folder: { name: folder_name, description: folder_description, parent_id, }, }, - { - auth: { - username: config.browserstackUsername, - password: config.browserstackAccessKey, - }, - headers: { "Content-Type": "application/json" }, - }, - ); + }); if (!res.data.success) { throw new Error(`Failed to create folder: ${JSON.stringify(res.data)}`); @@ -113,7 +120,7 @@ export async function createProjectOrFolder( // Folder created successfully const folder = res.data.folder; - const projectId = await projectIdentifierToId(projId); + const projectId = await projectIdentifierToId(projId, config); return { content: [ @@ -127,7 +134,7 @@ export async function createProjectOrFolder( }, ], }; - } catch (err) { + } catch (err: any) { return formatAxiosError(err, "Failed to create folder."); } } diff --git a/src/tools/testmanagement-utils/create-testcase.ts b/src/tools/testmanagement-utils/create-testcase.ts index bb9029a..446d54d 100644 --- a/src/tools/testmanagement-utils/create-testcase.ts +++ b/src/tools/testmanagement-utils/create-testcase.ts @@ -1,9 +1,9 @@ -import axios from "axios"; -import config from "../../config.js"; +import { apiClient } from "../../lib/apiClient.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { formatAxiosError } from "../../lib/error.js"; // or correct +import { formatAxiosError } from "../../lib/error.js"; import { projectIdentifierToId } from "./TCG-utils/api.js"; +import { BrowserStackConfig } from "../../lib/types.js"; interface TestCaseStep { step: string; @@ -138,25 +138,28 @@ export function sanitizeArgs(args: any) { return cleaned; } +import { getBrowserStackAuth } from "../../lib/get-auth.js"; + export async function createTestCase( params: TestCaseCreateRequest, + config: BrowserStackConfig, ): Promise { const body = { test_case: params }; + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); try { - const response = await axios.post( - `https://test-management.browserstack.com/api/v2/projects/${encodeURIComponent( + const response = await apiClient.post({ + url: `https://test-management.browserstack.com/api/v2/projects/${encodeURIComponent( params.project_identifier, )}/folders/${encodeURIComponent(params.folder_id)}/test-cases`, - body, - { - auth: { - username: config.browserstackUsername, - password: config.browserstackAccessKey, - }, - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Authorization: + "Basic " + Buffer.from(`${username}:${password}`).toString("base64"), }, - ); + body, + }); const { data } = response.data; if (!data.success) { @@ -175,7 +178,10 @@ export async function createTestCase( } const tc = data.test_case; - const projectId = await projectIdentifierToId(params.project_identifier); + const projectId = await projectIdentifierToId( + params.project_identifier, + config, + ); return { content: [ diff --git a/src/tools/testmanagement-utils/create-testrun.ts b/src/tools/testmanagement-utils/create-testrun.ts index 2b8b4c6..45edaab 100644 --- a/src/tools/testmanagement-utils/create-testrun.ts +++ b/src/tools/testmanagement-utils/create-testrun.ts @@ -1,8 +1,9 @@ -import axios from "axios"; -import config from "../../config.js"; +import { apiClient } from "../../lib/apiClient.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { formatAxiosError } from "../../lib/error.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; /** * Schema for creating a test run. @@ -53,6 +54,7 @@ export type CreateTestRunArgs = z.infer; */ export async function createTestRun( rawArgs: CreateTestRunArgs, + config: BrowserStackConfig, ): Promise { try { const inputArgs = { @@ -68,17 +70,17 @@ export async function createTestRun( args.project_identifier, )}/test-runs`; - const response = await axios.post( + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); + const response = await apiClient.post({ url, - { test_run: args.test_run }, - { - auth: { - username: config.browserstackUsername, - password: config.browserstackAccessKey, - }, - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Authorization: + "Basic " + Buffer.from(`${username}:${password}`).toString("base64"), }, - ); + body: { test_run: args.test_run }, + }); const data = response.data; if (!data.success) { diff --git a/src/tools/testmanagement-utils/list-testcases.ts b/src/tools/testmanagement-utils/list-testcases.ts index f882830..98f95aa 100644 --- a/src/tools/testmanagement-utils/list-testcases.ts +++ b/src/tools/testmanagement-utils/list-testcases.ts @@ -1,8 +1,9 @@ -import axios from "axios"; -import config from "../../config.js"; +import { apiClient } from "../../lib/apiClient.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { formatAxiosError } from "../../lib/error.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; /** * Schema for listing test cases with optional filters. @@ -38,6 +39,7 @@ export type ListTestCasesArgs = z.infer; */ export async function listTestCases( args: ListTestCasesArgs, + config: BrowserStackConfig, ): Promise { try { // Build query string @@ -51,10 +53,13 @@ export async function listTestCases( args.project_identifier, )}/test-cases?${params.toString()}`; - const resp = await axios.get(url, { - auth: { - username: config.browserstackUsername, - password: config.browserstackAccessKey, + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); + const resp = await apiClient.get({ + url, + headers: { + Authorization: + "Basic " + Buffer.from(`${username}:${password}`).toString("base64"), }, }); diff --git a/src/tools/testmanagement-utils/list-testruns.ts b/src/tools/testmanagement-utils/list-testruns.ts index 5bad0bc..1d3390b 100644 --- a/src/tools/testmanagement-utils/list-testruns.ts +++ b/src/tools/testmanagement-utils/list-testruns.ts @@ -1,8 +1,9 @@ -import axios from "axios"; -import config from "../../config.js"; +import { apiClient } from "../../lib/apiClient.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { formatAxiosError } from "../../lib/error.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; /** * Schema for listing test runs with optional filters. @@ -26,6 +27,7 @@ type ListTestRunsArgs = z.infer; */ export async function listTestRuns( args: ListTestRunsArgs, + config: BrowserStackConfig, ): Promise { try { const params = new URLSearchParams(); @@ -38,10 +40,13 @@ export async function listTestRuns( args.project_identifier, )}/test-runs?` + params.toString(); - const resp = await axios.get(url, { - auth: { - username: config.browserstackUsername, - password: config.browserstackAccessKey, + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); + const resp = await apiClient.get({ + url, + headers: { + Authorization: + "Basic " + Buffer.from(`${username}:${password}`).toString("base64"), }, }); diff --git a/src/tools/testmanagement-utils/poll-lca-status.ts b/src/tools/testmanagement-utils/poll-lca-status.ts index cdaf971..3a5e4fb 100644 --- a/src/tools/testmanagement-utils/poll-lca-status.ts +++ b/src/tools/testmanagement-utils/poll-lca-status.ts @@ -1,5 +1,6 @@ -import axios from "axios"; -import config from "../../config.js"; +import { apiClient } from "../../lib/apiClient.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; /** * Interface for the test case response structure @@ -36,6 +37,7 @@ export async function pollLCAStatus( maxWaitTimeMs: number = 10 * 60 * 1000, // 10 minutes default initialWaitMs: number = 2 * 60 * 1000, // 2 minutes initial wait pollIntervalMs: number = 10 * 1000, // 10 seconds interval + config: BrowserStackConfig, ): Promise<{ resource_path: string; status: string } | null> { const url = `https://test-management.browserstack.com/api/v1/projects/${projectId}/folder/${folderId}/test-cases/${testCaseId}`; @@ -91,20 +93,24 @@ export async function pollLCAStatus( // Set up polling interval const intervalId = setInterval(async () => { try { - const response = await axios.get(url, { + const authString = getBrowserStackAuth(config); + const response = await apiClient.get({ + url, headers: { - "API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`, + "API-TOKEN": authString, accept: "application/json, text/plain, */*", }, }); - if (response.data.data.success && response.data.data.test_case) { - const testCase = response.data.data.test_case; + const responseData: TestCaseResponse = response.data; + + if (responseData.data.success && responseData.data.test_case) { + const testCase = responseData.data.test_case; // Check lcnc_build_map in both possible locations const lcncBuildMap = testCase.lcnc_build_map || - response.data.data.metadata?.lcnc_build_map; + responseData.data.metadata?.lcnc_build_map; if (lcncBuildMap) { if (lcncBuildMap.status === "done") { diff --git a/src/tools/testmanagement-utils/testcase-from-file.ts b/src/tools/testmanagement-utils/testcase-from-file.ts index 41b1834..96bf835 100644 --- a/src/tools/testmanagement-utils/testcase-from-file.ts +++ b/src/tools/testmanagement-utils/testcase-from-file.ts @@ -13,10 +13,12 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { signedUrlMap } from "../../lib/inmemory-store.js"; import logger from "../../logger.js"; import { projectIdentifierToId } from "./TCG-utils/api.js"; +import { BrowserStackConfig } from "../../lib/types.js"; export async function createTestCasesFromFile( args: CreateTestCasesFromFileArgs, context: any, + config: BrowserStackConfig, ): Promise { logger.info( `createTestCasesFromFile called with projectId: ${args.projectReferenceId}, folderId: ${args.folderId}`, @@ -25,10 +27,12 @@ export async function createTestCasesFromFile( if (args.projectReferenceId.startsWith("PR-")) { args.projectReferenceId = await projectIdentifierToId( args.projectReferenceId, + config, ); } const { default_fields, custom_fields } = await fetchFormFields( args.projectReferenceId, + config, ); const fieldMaps = buildDefaultFieldMaps(default_fields); const booleanFieldId = findBooleanFieldId(custom_fields); @@ -57,6 +61,7 @@ export async function createTestCasesFromFile( args.folderId, args.projectReferenceId, source, + config, ); const scenariosMap = await pollScenariosTestDetails( @@ -65,6 +70,7 @@ export async function createTestCasesFromFile( context, documentId, source, + config, ); const resultString = await bulkCreateTestCases( @@ -76,6 +82,7 @@ export async function createTestCasesFromFile( traceId, context, documentId, + config, ); signedUrlMap.delete(args.documentId); diff --git a/src/tools/testmanagement-utils/update-testrun.ts b/src/tools/testmanagement-utils/update-testrun.ts index ed8c8d0..0c56af7 100644 --- a/src/tools/testmanagement-utils/update-testrun.ts +++ b/src/tools/testmanagement-utils/update-testrun.ts @@ -1,8 +1,9 @@ -import axios from "axios"; -import config from "../../config.js"; +import { apiClient } from "../../lib/apiClient.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { formatAxiosError } from "../../lib/error.js"; +import { BrowserStackConfig } from "../../lib/types.js"; /** * Schema for updating a test run with partial fields. @@ -35,6 +36,7 @@ type UpdateTestRunArgs = z.infer; */ export async function updateTestRun( args: UpdateTestRunArgs, + config: BrowserStackConfig, ): Promise { try { const body = { test_run: args.test_run }; @@ -42,11 +44,17 @@ export async function updateTestRun( args.project_identifier, )}/test-runs/${encodeURIComponent(args.test_run_id)}/update`; - const resp = await axios.patch(url, body, { - auth: { - username: config.browserstackUsername, - password: config.browserstackAccessKey, + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); + + const resp = await apiClient.patch({ + url, + headers: { + Authorization: + "Basic " + Buffer.from(`${username}:${password}`).toString("base64"), + "Content-Type": "application/json", }, + body, }); const data = resp.data; diff --git a/src/tools/testmanagement-utils/upload-file.ts b/src/tools/testmanagement-utils/upload-file.ts index 3f76c2e..e8e81fc 100644 --- a/src/tools/testmanagement-utils/upload-file.ts +++ b/src/tools/testmanagement-utils/upload-file.ts @@ -1,13 +1,14 @@ import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import axios from "axios"; +import { apiClient } from "../../lib/apiClient.js"; import FormData from "form-data"; import fs from "fs"; import path from "path"; import { v4 as uuidv4 } from "uuid"; -import config from "../../config.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { signedUrlMap } from "../../lib/inmemory-store.js"; import { projectIdentifierToId } from "./TCG-utils/api.js"; +import { BrowserStackConfig } from "../../lib/types.js"; /** * Schema for the upload file tool @@ -28,6 +29,7 @@ export const UploadFileSchema = z.object({ */ export async function uploadFile( args: z.infer, + config: BrowserStackConfig, ): Promise { const { project_identifier, file_path } = args; @@ -46,19 +48,24 @@ export async function uploadFile( }; } // Get the project ID - const projectIdResponse = await projectIdentifierToId(project_identifier); + const projectIdResponse = await projectIdentifierToId( + project_identifier, + config, + ); const formData = new FormData(); formData.append("attachments[]", fs.createReadStream(file_path)); const uploadUrl = `https://test-management.browserstack.com/api/v1/projects/${projectIdResponse}/generic/attachments/ai_uploads`; - const response = await axios.post(uploadUrl, formData, { + const response = await apiClient.post({ + url: uploadUrl, headers: { ...formData.getHeaders(), - "API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`, + "API-TOKEN": getBrowserStackAuth(config), accept: "application/json, text/plain, */*", }, + body: formData, }); if ( diff --git a/src/tools/testmanagement.ts b/src/tools/testmanagement.ts index 8c72c22..c5e9c3a 100644 --- a/src/tools/testmanagement.ts +++ b/src/tools/testmanagement.ts @@ -14,8 +14,6 @@ import { CreateTestCaseSchema, } from "./testmanagement-utils/create-testcase.js"; -let serverInstance: McpServer; - import { listTestCases, ListTestCasesSchema, @@ -53,6 +51,7 @@ import { createLCASteps, CreateLCAStepsSchema, } from "./testmanagement-utils/create-lca-steps.js"; +import { BrowserStackConfig } from "../lib/types.js"; //TODO: Moving the traceMCP and catch block to the parent(server) function @@ -61,19 +60,24 @@ import { */ export async function createProjectOrFolderTool( args: z.infer, + config: BrowserStackConfig, + server: McpServer, ): Promise { try { trackMCP( "createProjectOrFolder", - serverInstance.server.getClientVersion()!, + server.server.getClientVersion()!, + undefined, + config, ); - return await createProjectOrFolder(args); + return await createProjectOrFolder(args, config); } catch (err) { logger.error("Failed to create project/folder: %s", err); trackMCP( "createProjectOrFolder", - serverInstance.server.getClientVersion()!, + server.server.getClientVersion()!, err, + config, ); return { content: [ @@ -95,15 +99,22 @@ export async function createProjectOrFolderTool( */ export async function createTestCaseTool( args: TestCaseCreateRequest, + config: BrowserStackConfig, + server: McpServer, ): Promise { // Sanitize input arguments const cleanedArgs = sanitizeArgs(args); try { - trackMCP("createTestCase", serverInstance.server.getClientVersion()!); - return await createTestCaseAPI(cleanedArgs); + trackMCP( + "createTestCase", + server.server.getClientVersion()!, + undefined, + config, + ); + return await createTestCaseAPI(cleanedArgs, config); } catch (err) { logger.error("Failed to create test case: %s", err); - trackMCP("createTestCase", serverInstance.server.getClientVersion()!, err); + trackMCP("createTestCase", server.server.getClientVersion()!, err, config); return { content: [ { @@ -125,12 +136,19 @@ export async function createTestCaseTool( export async function listTestCasesTool( args: z.infer, + config: BrowserStackConfig, + server: McpServer, ): Promise { try { - trackMCP("listTestCases", serverInstance.server.getClientVersion()!); - return await listTestCases(args); + trackMCP( + "listTestCases", + server.server.getClientVersion()!, + undefined, + config, + ); + return await listTestCases(args, config); } catch (err) { - trackMCP("listTestCases", serverInstance.server.getClientVersion()!, err); + trackMCP("listTestCases", server.server.getClientVersion()!, err, config); return { content: [ { @@ -151,12 +169,19 @@ export async function listTestCasesTool( */ export async function createTestRunTool( args: z.infer, + config: BrowserStackConfig, + server: McpServer, ): Promise { try { - trackMCP("createTestRun", serverInstance.server.getClientVersion()!); - return await createTestRun(args); + trackMCP( + "createTestRun", + server.server.getClientVersion()!, + undefined, + config, + ); + return await createTestRun(args, config); } catch (err) { - trackMCP("createTestRun", serverInstance.server.getClientVersion()!, err); + trackMCP("createTestRun", server.server.getClientVersion()!, err, config); return { content: [ { @@ -177,12 +202,19 @@ export async function createTestRunTool( */ export async function listTestRunsTool( args: z.infer, + config: BrowserStackConfig, + server: McpServer, ): Promise { try { - trackMCP("listTestRuns", serverInstance.server.getClientVersion()!); - return await listTestRuns(args); + trackMCP( + "listTestRuns", + server.server.getClientVersion()!, + undefined, + config, + ); + return await listTestRuns(args, config); } catch (err) { - trackMCP("listTestRuns", serverInstance.server.getClientVersion()!, err); + trackMCP("listTestRuns", server.server.getClientVersion()!, err, config); return { content: [ { @@ -205,12 +237,19 @@ export async function listTestRunsTool( */ export async function updateTestRunTool( args: z.infer, + config: BrowserStackConfig, + server: McpServer, ): Promise { try { - trackMCP("updateTestRun", serverInstance.server.getClientVersion()!); - return await updateTestRun(args); + trackMCP( + "updateTestRun", + server.server.getClientVersion()!, + undefined, + config, + ); + return await updateTestRun(args, config); } catch (err) { - trackMCP("updateTestRun", serverInstance.server.getClientVersion()!, err); + trackMCP("updateTestRun", server.server.getClientVersion()!, err, config); return { content: [ { @@ -231,12 +270,19 @@ export async function updateTestRunTool( */ export async function addTestResultTool( args: z.infer, + config: BrowserStackConfig, + server: McpServer, ): Promise { try { - trackMCP("addTestResult", serverInstance.server.getClientVersion()!); - return await addTestResult(args); + trackMCP( + "addTestResult", + server.server.getClientVersion()!, + undefined, + config, + ); + return await addTestResult(args, config); } catch (err) { - trackMCP("addTestResult", serverInstance.server.getClientVersion()!, err); + trackMCP("addTestResult", server.server.getClientVersion()!, err, config); return { content: [ { @@ -257,18 +303,23 @@ export async function addTestResultTool( */ export async function uploadProductRequirementFileTool( args: z.infer, + config: BrowserStackConfig, + server: McpServer, ): Promise { try { trackMCP( "uploadProductRequirementFile", - serverInstance.server.getClientVersion()!, + server.server.getClientVersion()!, + undefined, + config, ); - return await uploadFile(args); + return await uploadFile(args, config); } catch (err) { trackMCP( "uploadProductRequirementFile", - serverInstance.server.getClientVersion()!, + server.server.getClientVersion()!, err, + config, ); return { content: [ @@ -291,19 +342,18 @@ export async function uploadProductRequirementFileTool( export async function createTestCasesFromFileTool( args: z.infer, context: any, + config: BrowserStackConfig, + server: McpServer, ): Promise { try { trackMCP( "createTestCasesFromFile", - serverInstance.server.getClientVersion()!, + server.server.getClientVersion()!, + undefined, ); - return await createTestCasesFromFile(args, context); + return await createTestCasesFromFile(args, context, config); } catch (err) { - trackMCP( - "createTestCasesFromFile", - serverInstance.server.getClientVersion()!, - err, - ); + trackMCP("createTestCasesFromFile", server.server.getClientVersion()!, err); return { content: [ { @@ -325,12 +375,19 @@ export async function createTestCasesFromFileTool( export async function createLCAStepsTool( args: z.infer, context: any, + config: BrowserStackConfig, + server: McpServer, ): Promise { try { - trackMCP("createLCASteps", serverInstance.server.getClientVersion()!); - return await createLCASteps(args, context); + trackMCP( + "createLCASteps", + server.server.getClientVersion()!, + undefined, + config, + ); + return await createLCASteps(args, context, config); } catch (err) { - trackMCP("createLCASteps", serverInstance.server.getClientVersion()!, err); + trackMCP("createLCASteps", server.server.getClientVersion()!, err, config); return { content: [ { @@ -349,71 +406,74 @@ export async function createLCAStepsTool( /** * Registers both project/folder and test-case tools. */ -export default function addTestManagementTools(server: McpServer) { - serverInstance = server; +export default function addTestManagementTools( + server: McpServer, + config: BrowserStackConfig, +) { server.tool( "createProjectOrFolder", "Create a project and/or folder in BrowserStack Test Management.", CreateProjFoldSchema.shape, - createProjectOrFolderTool, + (args) => createProjectOrFolderTool(args, config, server), ); server.tool( "createTestCase", "Use this tool to create a test case in BrowserStack Test Management.", CreateTestCaseSchema.shape, - createTestCaseTool, + (args) => createTestCaseTool(args, config, server), ); server.tool( "listTestCases", "List test cases in a project with optional filters (status, priority, custom fields, etc.)", ListTestCasesSchema.shape, - listTestCasesTool, + (args) => listTestCasesTool(args, config, server), ); server.tool( "createTestRun", "Create a test run in BrowserStack Test Management.", CreateTestRunSchema.shape, - createTestRunTool, + (args) => createTestRunTool(args, config, server), ); server.tool( "listTestRuns", "List test runs in a project with optional filters (date ranges, assignee, state, etc.)", ListTestRunsSchema.shape, - listTestRunsTool, + (args) => listTestRunsTool(args, config, server), ); server.tool( "updateTestRun", "Update a test run in BrowserStack Test Management.", UpdateTestRunSchema.shape, - updateTestRunTool, + (args) => updateTestRunTool(args, config, server), ); server.tool( "addTestResult", "Add a test result to a specific test run via BrowserStack Test Management API.", AddTestResultSchema.shape, - addTestResultTool, + (args) => addTestResultTool(args, config, server), ); 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, + (args) => uploadProductRequirementFileTool(args, config, server), ); server.tool( "createTestCasesFromFile", "Generate test cases from a file in BrowserStack Test Management using the Test Case Generator AI Agent.", CreateTestCasesFromFileSchema.shape, - createTestCasesFromFileTool, + (args, context) => + createTestCasesFromFileTool(args, context, config, server), ); 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, + (args, context) => createLCAStepsTool(args, context, config, server), ); } diff --git a/tests/tools/applive.test.ts b/tests/tools/applive.test.ts index bf2db5f..3870bd6 100644 --- a/tests/tools/applive.test.ts +++ b/tests/tools/applive.test.ts @@ -52,58 +52,64 @@ describe('startAppLiveSession', () => { desiredPhone: 'iPhone 12 Pro' }; + const mockConfig = { + getClientVersion: () => "test-version", + "browserstack-username": "test-username", + "browserstack-access-key": "test-access-key" + }; + it('should successfully start an Android app live session', async () => { - const result = await startAppLiveSession(validAndroidArgs); + const result = await startAppLiveSession(validAndroidArgs, mockConfig); expect(startSession).toHaveBeenCalledWith({ appPath: '/path/to/app.apk', desiredPlatform: 'android', desiredPhone: validAndroidArgs.desiredPhone, desiredPlatformVersion: validAndroidArgs.desiredPlatformVersion - }); - expect(result.content[0].text).toContain('Successfully started a session'); + }, { config: mockConfig }); + expect(result.content?.[0]?.text).toContain('Successfully started a session'); }); it('should successfully start an iOS app live session', async () => { - const result = await startAppLiveSession(validiOSArgs); + const result = await startAppLiveSession(validiOSArgs, mockConfig); expect(startSession).toHaveBeenCalledWith({ appPath: '/path/to/app.ipa', desiredPlatform: 'ios', desiredPhone: validiOSArgs.desiredPhone, desiredPlatformVersion: validiOSArgs.desiredPlatformVersion - }); - expect(result.content[0].text).toContain('Successfully started a session'); + }, { config: mockConfig }); + expect(result.content?.[0]?.text).toContain('Successfully started a session'); }); it('should fail if platform is not provided', async () => { const args = { ...validAndroidArgs, desiredPlatform: '' }; - await expect(startAppLiveSession(args)).rejects.toThrow('You must provide a desiredPlatform'); + await expect(startAppLiveSession(args, mockConfig)).rejects.toThrow('You must provide a desiredPlatform'); }); it('should fail if app path is not provided', async () => { const args = { ...validAndroidArgs, appPath: '' }; - await expect(startAppLiveSession(args)).rejects.toThrow('You must provide a appPath'); + await expect(startAppLiveSession(args, mockConfig)).rejects.toThrow('You must provide a appPath'); }); it('should fail if phone is not provided', async () => { const args = { ...validAndroidArgs, desiredPhone: '' }; - await expect(startAppLiveSession(args)).rejects.toThrow('You must provide a desiredPhone'); + await expect(startAppLiveSession(args, mockConfig)).rejects.toThrow('You must provide a desiredPhone'); }); it('should fail if Android app path does not end with .apk', async () => { const args = { ...validAndroidArgs, appPath: '/path/to/app.ipa' }; - await expect(startAppLiveSession(args)).rejects.toThrow('You must provide a valid Android app path'); + await expect(startAppLiveSession(args, mockConfig)).rejects.toThrow('You must provide a valid Android app path'); }); it('should fail if iOS app path does not end with .ipa', async () => { const args = { ...validiOSArgs, appPath: '/path/to/app.apk' }; - await expect(startAppLiveSession(args)).rejects.toThrow('You must provide a valid iOS app path'); + await expect(startAppLiveSession(args, mockConfig)).rejects.toThrow('You must provide a valid iOS app path'); }); it('should fail if app file does not exist', async () => { (fs.existsSync as Mock).mockReturnValue(false); - await expect(startAppLiveSession(validAndroidArgs)).rejects.toThrow('The app path does not exist'); + await expect(startAppLiveSession(validAndroidArgs, mockConfig)).rejects.toThrow('The app path does not exist'); expect(logger.error).toHaveBeenCalled(); }); @@ -111,12 +117,12 @@ describe('startAppLiveSession', () => { (fs.accessSync as Mock).mockImplementation(() => { throw new Error('EACCES: permission denied'); }); - await expect(startAppLiveSession(validAndroidArgs)).rejects.toThrow('The app path does not exist or is not readable'); + await expect(startAppLiveSession(validAndroidArgs, mockConfig)).rejects.toThrow('The app path does not exist or is not readable'); expect(logger.error).toHaveBeenCalled(); }); it('should handle session start failure', async () => { (startSession as Mock).mockRejectedValue(new Error('Session start failed')); - await expect(startAppLiveSession(validAndroidArgs)).rejects.toThrow('Session start failed'); + await expect(startAppLiveSession(validAndroidArgs, mockConfig)).rejects.toThrow('Session start failed'); }); }); diff --git a/tests/tools/getFailureLogs.test.ts b/tests/tools/getFailureLogs.test.ts index 9a2763c..cf4a293 100644 --- a/tests/tools/getFailureLogs.test.ts +++ b/tests/tools/getFailureLogs.test.ts @@ -65,48 +65,52 @@ describe('BrowserStack Failure Logs', () => { describe('getFailureLogs - Input Validation', () => { it('should throw error if sessionId is not provided', async () => { + const mockServer = { server: { getClientVersion: () => "test-version" } }; await expect(getFailureLogs({ sessionId: '', logTypes: ['networkLogs'], sessionType: 'automate' - })).rejects.toThrow('Session ID is required'); + }, mockServer)).rejects.toThrow('Session ID is required'); }); it('should throw error if buildId is not provided for app-automate session', async () => { + const mockServer = { server: { getClientVersion: () => "test-version" } }; await expect(getFailureLogs({ sessionId: 'test-session', logTypes: ['deviceLogs'], sessionType: 'app-automate' - })).rejects.toThrow('Build ID is required for app-automate sessions'); + }, mockServer)).rejects.toThrow('Build ID is required for app-automate sessions'); }); it('should return error for invalid log types', async () => { + const mockServer = { server: { getClientVersion: () => "test-version" } }; const result = await getFailureLogs({ sessionId: 'test-session', logTypes: ['invalidLogType'] as any, sessionType: 'automate' - }); + }, mockServer); - expect(result.content[0].isError).toBe(true); - expect(result.content[0].text).toContain('Invalid log type'); + expect(result.content?.[0]?.isError).toBe(true); + expect(result.content?.[0]?.text).toContain('Invalid log type'); }); it('should return error when mixing session types', async () => { + const mockServer = { server: { getClientVersion: () => "test-version" } }; const automateResult = await getFailureLogs({ sessionId: 'test-session', logTypes: ['deviceLogs'], sessionType: 'automate' - }); + }, mockServer); const appAutomateResult = await getFailureLogs({ sessionId: 'test-session', buildId: 'test-build', logTypes: ['networkLogs'], sessionType: 'app-automate' - }); + }, mockServer); - expect(automateResult.content[0].isError).toBe(true); - expect(appAutomateResult.content[0].isError).toBe(true); + expect(automateResult.content?.[0]?.isError).toBe(true); + expect(appAutomateResult.content?.[0]?.isError).toBe(true); }); }); @@ -137,39 +141,42 @@ describe('BrowserStack Failure Logs', () => { }); it('should fetch network logs successfully', async () => { + const mockServer = { server: { getClientVersion: () => "test-version" } }; const result = await getFailureLogs({ sessionId: mockSessionId, logTypes: ['networkLogs'], sessionType: 'automate' - }); + }, mockServer); - expect(automate.retrieveNetworkFailures).toHaveBeenCalledWith(mockSessionId); - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('Network Failures (1 found)'); + expect(automate.retrieveNetworkFailures).toHaveBeenCalledWith(mockSessionId, expect.anything()); + expect(result.content?.[0]?.type).toBe('text'); + expect(result.content?.[0]?.text).toContain('Network Failures (1 found)'); }); it('should fetch session logs successfully', async () => { + const mockServer = { server: { getClientVersion: () => "test-version" } }; const result = await getFailureLogs({ sessionId: mockSessionId, logTypes: ['sessionLogs'], sessionType: 'automate' - }); + }, mockServer); - expect(automate.retrieveSessionFailures).toHaveBeenCalledWith(mockSessionId); - expect(result.content[0].text).toContain('Session Failures (1 found)'); - expect(result.content[0].text).toContain('[ERROR] Test failed'); + expect(automate.retrieveSessionFailures).toHaveBeenCalledWith(mockSessionId, expect.anything()); + expect(result.content?.[0]?.text).toContain('Session Failures (1 found)'); + expect(result.content?.[0]?.text).toContain('[ERROR] Test failed'); }); it('should fetch console logs successfully', async () => { + const mockServer = { server: { getClientVersion: () => "test-version" } }; const result = await getFailureLogs({ sessionId: mockSessionId, logTypes: ['consoleLogs'], sessionType: 'automate' - }); + }, mockServer); - expect(automate.retrieveConsoleFailures).toHaveBeenCalledWith(mockSessionId); - expect(result.content[0].text).toContain('Console Failures (1 found)'); - expect(result.content[0].text).toContain('Uncaught TypeError'); + expect(automate.retrieveConsoleFailures).toHaveBeenCalledWith(mockSessionId, expect.anything()); + expect(result.content?.[0]?.text).toContain('Console Failures (1 found)'); + expect(result.content?.[0]?.text).toContain('Uncaught TypeError'); }); }); @@ -187,42 +194,45 @@ describe('BrowserStack Failure Logs', () => { }); it('should fetch device logs successfully', async () => { + const mockServer = { server: { getClientVersion: () => "test-version" } }; const result = await getFailureLogs({ sessionId: mockSessionId, buildId: mockBuildId, logTypes: ['deviceLogs'], sessionType: 'app-automate' - }); + }, mockServer); - expect(appAutomate.retrieveDeviceLogs).toHaveBeenCalledWith(mockSessionId, mockBuildId); - expect(result.content[0].text).toContain('Device Failures (1 found)'); - expect(result.content[0].text).toContain('Fatal Exception'); + expect(appAutomate.retrieveDeviceLogs).toHaveBeenCalledWith(mockSessionId, mockBuildId, expect.anything()); + expect(result.content?.[0]?.text).toContain('Device Failures (1 found)'); + expect(result.content?.[0]?.text).toContain('Fatal Exception'); }); it('should fetch appium logs successfully', async () => { + const mockServer = { server: { getClientVersion: () => "test-version" } }; const result = await getFailureLogs({ sessionId: mockSessionId, buildId: mockBuildId, logTypes: ['appiumLogs'], sessionType: 'app-automate' - }); + }, mockServer); - expect(appAutomate.retrieveAppiumLogs).toHaveBeenCalledWith(mockSessionId, mockBuildId); - expect(result.content[0].text).toContain('Appium Failures (1 found)'); - expect(result.content[0].text).toContain('Element not found'); + expect(appAutomate.retrieveAppiumLogs).toHaveBeenCalledWith(mockSessionId, mockBuildId, expect.anything()); + expect(result.content?.[0]?.text).toContain('Appium Failures (1 found)'); + expect(result.content?.[0]?.text).toContain('Element not found'); }); it('should fetch crash logs successfully', async () => { + const mockServer = { server: { getClientVersion: () => "test-version" } }; const result = await getFailureLogs({ sessionId: mockSessionId, buildId: mockBuildId, logTypes: ['crashLogs'], sessionType: 'app-automate' - }); + }, mockServer); - expect(appAutomate.retrieveCrashLogs).toHaveBeenCalledWith(mockSessionId, mockBuildId); - expect(result.content[0].text).toContain('Crash Failures (1 found)'); - expect(result.content[0].text).toContain('signal 11'); + expect(appAutomate.retrieveCrashLogs).toHaveBeenCalledWith(mockSessionId, mockBuildId, expect.anything()); + expect(result.content?.[0]?.text).toContain('Crash Failures (1 found)'); + expect(result.content?.[0]?.text).toContain('signal 11'); }); }); @@ -230,13 +240,14 @@ describe('BrowserStack Failure Logs', () => { it('should handle empty log responses', async () => { vi.mocked(automate.retrieveNetworkFailures).mockResolvedValue('No network failures found'); + const mockServer = { server: { getClientVersion: () => "test-version" } }; const result = await getFailureLogs({ sessionId: mockSessionId, logTypes: ['networkLogs'], sessionType: 'automate' - }); + }, mockServer); - expect(result.content[0].text).toBe('No network failures found'); + expect(result.content?.[0]?.text).toBe('No network failures found'); }); }); @@ -313,4 +324,4 @@ console.error('Uncaught TypeError') expect(appAutomate.filterCrashFailures('')).toEqual([]); }); }); -}); \ No newline at end of file +}); diff --git a/tests/tools/observability.test.ts b/tests/tools/observability.test.ts index 78e3cac..4b8207e 100644 --- a/tests/tools/observability.test.ts +++ b/tests/tools/observability.test.ts @@ -17,88 +17,108 @@ describe('getFailuresInLastRun', () => { }); const validBuildData = { - observability_url: 'https://observability.browserstack.com/123', - unique_errors: { - overview: { - insight: 'Test insight message' - }, - top_unique_errors: [ - { error: 'Error 1' }, - { error: 'Error 2' } - ] + data: { + observability_url: 'https://observability.browserstack.com/123', + unique_errors: { + overview: { + insight: 'Test insight message' + }, + top_unique_errors: [ + { error: 'Error 1' }, + { error: 'Error 2' } + ] + } } }; + const mockConfig = { + "browserstack-username": "fakeuser", + "browserstack-access-key": "fakekey", + getClientVersion: () => "test-version" + }; + it('should successfully retrieve failures for a valid build', async () => { (getLatestO11YBuildInfo as Mock).mockResolvedValue(validBuildData); - const result = await getFailuresInLastRun('test-build', 'test-project'); + const result = await getFailuresInLastRun('test-build', 'test-project', mockConfig); - expect(getLatestO11YBuildInfo).toHaveBeenCalledWith('test-build', 'test-project'); - expect(result.content[0].text).toContain('https://observability.browserstack.com/123'); - expect(result.content[0].text).toContain('Test insight message'); - expect(result.content[0].text).toContain('Error 1'); - expect(result.content[0].text).toContain('Error 2'); + expect(getLatestO11YBuildInfo).toHaveBeenCalledWith('test-build', 'test-project', mockConfig); + expect(result.content).toBeDefined(); + expect(result.content![0].text).toContain('https://observability.browserstack.com/123'); + expect(result.content![0].text).toContain('Test insight message'); + expect(result.content![0].text).toContain('Error 1'); + expect(result.content![0].text).toContain('Error 2'); }); it('should handle missing observability URL', async () => { (getLatestO11YBuildInfo as Mock).mockResolvedValue({ - ...validBuildData, - observability_url: null + data: { + ...validBuildData.data, + observability_url: null + } }); - await expect(getFailuresInLastRun('test-build', 'test-project')) + await expect(getFailuresInLastRun('test-build', 'test-project', mockConfig)) .rejects.toThrow('No observability URL found in build data'); }); it('should handle missing overview insight', async () => { (getLatestO11YBuildInfo as Mock).mockResolvedValue({ - ...validBuildData, - unique_errors: { - ...validBuildData.unique_errors, - overview: {} + data: { + ...validBuildData.data, + unique_errors: { + ...validBuildData.data.unique_errors, + overview: {} + } } }); - const result = await getFailuresInLastRun('test-build', 'test-project'); - expect(result.content[0].text).toContain('No overview available'); + const result = await getFailuresInLastRun('test-build', 'test-project', mockConfig); + expect(result.content).toBeDefined(); + expect(result.content![0].text).toContain('No overview available'); }); it('should handle missing error details', async () => { (getLatestO11YBuildInfo as Mock).mockResolvedValue({ - ...validBuildData, - unique_errors: { - ...validBuildData.unique_errors, - top_unique_errors: [] + data: { + ...validBuildData.data, + unique_errors: { + ...validBuildData.data.unique_errors, + top_unique_errors: [] + } } }); - const result = await getFailuresInLastRun('test-build', 'test-project'); - expect(result.content[0].text).toContain('No error details available'); + const result = await getFailuresInLastRun('test-build', 'test-project', mockConfig); + expect(result.content).toBeDefined(); + expect(result.content![0].text).toContain('No error details available'); }); it('should handle API errors', async () => { (getLatestO11YBuildInfo as Mock).mockRejectedValue(new Error('API Error')); - await expect(getFailuresInLastRun('test-build', 'test-project')) + await expect(getFailuresInLastRun('test-build', 'test-project', mockConfig)) .rejects.toThrow('API Error'); }); it('should handle empty build data', async () => { (getLatestO11YBuildInfo as Mock).mockResolvedValue({}); - await expect(getFailuresInLastRun('test-build', 'test-project')) + await expect(getFailuresInLastRun('test-build', 'test-project', mockConfig)) .rejects.toThrow('No observability URL found in build data'); }); it('should handle partial build data', async () => { (getLatestO11YBuildInfo as Mock).mockResolvedValue({ - observability_url: 'https://observability.browserstack.com/123', - unique_errors: {} + data: { + observability_url: 'https://observability.browserstack.com/123', + unique_errors: {} + } }); - const result = await getFailuresInLastRun('test-build', 'test-project'); - expect(result.content[0].text).toContain('No overview available'); - expect(result.content[0].text).toContain('No error details available'); + const result = await getFailuresInLastRun('test-build', 'test-project', mockConfig); + expect(result.content).toBeDefined(); + expect(result.content![0].text).toContain('No overview available'); + expect(result.content![0].text).toContain('No error details available'); }); -}); \ No newline at end of file +}); diff --git a/tests/tools/testmanagement.test.ts b/tests/tools/testmanagement.test.ts index 94747c5..d52efca 100644 --- a/tests/tools/testmanagement.test.ts +++ b/tests/tools/testmanagement.test.ts @@ -7,7 +7,8 @@ import { updateTestRunTool, uploadProductRequirementFileTool, createTestCasesFromFileTool, - createLCAStepsTool + createLCAStepsTool, + listTestCasesTool } from '../../src/tools/testmanagement'; import addTestManagementTools from '../../src/tools/testmanagement'; import { createProjectOrFolder } from '../../src/tools/testmanagement-utils/create-project-folder'; @@ -19,12 +20,12 @@ import { listTestRuns } from '../../src/tools/testmanagement-utils/list-testruns import { updateTestRun } from '../../src/tools/testmanagement-utils/update-testrun'; import { createTestCasesFromFile } from '../../src/tools/testmanagement-utils/testcase-from-file'; import { createLCASteps } from '../../src/tools/testmanagement-utils/create-lca-steps'; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import axios from 'axios'; import { beforeEach, it, expect, describe, Mocked} from 'vitest'; import { vi, Mock } from 'vitest'; -import fs from 'fs'; import { signedUrlMap } from '../../src/lib/inmemory-store'; +import { uploadFile } from '../../src/tools/testmanagement-utils/upload-file'; + // Mock dependencies vi.mock('../../src/tools/testmanagement-utils/create-project-folder', () => ({ @@ -78,18 +79,35 @@ vi.mock('../../src/tools/testmanagement-utils/add-test-result', () => ({ vi.mock('fs'); vi.mock('../../src/lib/inmemory-store', () => ({ signedUrlMap: new Map() })); +vi.mock('../../src/lib/get-auth', () => ({ + getBrowserStackAuth: vi.fn(() => 'fake-user:fake-key') +})); +vi.mock('../../src/tools/testmanagement-utils/TCG-utils/api', () => ({ + projectIdentifierToId: vi.fn(() => Promise.resolve('999')) +})); +vi.mock('form-data', () => { + return { + default: vi.fn().mockImplementation(() => ({ + append: vi.fn(), + getHeaders: vi.fn(() => ({ 'content-type': 'multipart/form-data' })) + })) + }; +}); -const mockServer = { - tool: vi.fn(), - server: { - getClientVersion: vi.fn(() => ({ - name: 'vi-client', - version: '1.0.0', - })), +const mockConfig = { + getClientVersion: vi.fn(() => "test-version"), + authHeaders: { + username: 'fake-user', + password: 'fake-key' }, -} as unknown as McpServer; + "browserstack-username": "fake-user", + "browserstack-access-key": "fake-key" +}; -addTestManagementTools(mockServer); +// Create a mock server for all tool calls +const mockServer = { server: { getClientVersion: () => "test-version" } } as any; +mockServer.tool = vi.fn(); +addTestManagementTools(mockServer, mockConfig); vi.mock('../../src/tools/testmanagement-utils/create-testrun', () => ({ createTestRun: vi.fn(), @@ -115,6 +133,13 @@ vi.mock('../../src/tools/testmanagement-utils/update-testrun', () => ({ shape: {}, }, })); +vi.mock('../../src/tools/testmanagement-utils/list-testcases', () => ({ + listTestCases: vi.fn(), + ListTestCasesSchema: { + parse: (args: any) => args, + shape: {}, + }, +})); const mockedAxios = axios as Mocked; @@ -151,28 +176,22 @@ describe('createTestCaseTool', () => { it('should successfully create a test case', async () => { (createTestCase as Mock).mockResolvedValue(mockCallToolResult); - - const result = await createTestCaseTool(validArgs); - + const result = await createTestCaseTool(validArgs, mockConfig, mockServer); expect(sanitizeArgs).toHaveBeenCalledWith(validArgs); - expect(createTestCase).toHaveBeenCalledWith(validArgs); + expect(createTestCase).toHaveBeenCalledWith(validArgs, mockConfig); expect(result).toBe(mockCallToolResult); }); it('should handle API errors while creating test case', async () => { (createTestCase as Mock).mockRejectedValue(new Error('API Error')); - - const result = await createTestCaseTool(validArgs); - + const result = await createTestCaseTool(validArgs, mockConfig, mockServer); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toContain('Failed to create test case: API Error'); }); it('should handle unknown error while creating test case', async () => { (createTestCase as Mock).mockRejectedValue('unexpected'); - - const result = await createTestCaseTool(validArgs); - + const result = await createTestCaseTool(validArgs, mockConfig, mockServer); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toContain('Unknown error'); }); @@ -204,38 +223,29 @@ describe('createProjectOrFolderTool', () => { it('should successfully create a project', async () => { (createProjectOrFolder as Mock).mockResolvedValue(mockProjectResponse); - - const result = await createProjectOrFolderTool(validProjectArgs); - - expect(createProjectOrFolder).toHaveBeenCalledWith(validProjectArgs); + const result = await createProjectOrFolderTool(validProjectArgs, mockConfig, mockServer); + expect(createProjectOrFolder).toHaveBeenCalledWith(validProjectArgs, mockConfig); expect(result.content?.[0]?.text).toContain('Project created with identifier=proj-123'); }); it('should successfully create a folder', async () => { (createProjectOrFolder as Mock).mockResolvedValue(mockFolderResponse); - - const result = await createProjectOrFolderTool(validFolderArgs); - - expect(createProjectOrFolder).toHaveBeenCalledWith(validFolderArgs); + const result = await createProjectOrFolderTool(validFolderArgs, mockConfig, mockServer); + expect(createProjectOrFolder).toHaveBeenCalledWith(validFolderArgs, mockConfig); expect(result.content?.[0]?.text).toContain('Folder created: ID=fold-123'); }); it('should handle error while creating project or folder', async () => { (createProjectOrFolder as Mock).mockRejectedValue(new Error('Failed to create project/folder')); - - const result = await createProjectOrFolderTool(validProjectArgs); - + const result = await createProjectOrFolderTool(validProjectArgs, mockConfig, mockServer); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toContain( 'Failed to create project/folder: Failed to create project/folder. Please open an issue on GitHub if the problem persists' ); }); - it('should handle unknown error while creating project or folder', async () => { (createProjectOrFolder as Mock).mockRejectedValue('some unknown error'); - - const result = await createProjectOrFolderTool(validProjectArgs); - + const result = await createProjectOrFolderTool(validProjectArgs, mockConfig, mockServer); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toContain( 'Failed to create project/folder: Unknown error. Please open an issue on GitHub if the problem persists' @@ -254,25 +264,28 @@ describe('listTestCases util', () => { ]; it('should return formatted summary and raw JSON on success', async () => { - mockedAxios.get.mockResolvedValue({ data: { success: true, test_cases: mockCases, info: { count: 2 } } }); - + (listTestCases as Mock).mockResolvedValue({ + content: [ + { type: 'text', text: 'Found 2 test case(s):\n\n• TC-1: Test One [functional | high]\n• TC-2: Test Two [regression | medium]' }, + { type: 'text', text: JSON.stringify(mockCases, null, 2) }, + ], + isError: false, + }); const args = { project_identifier: 'PR-1', status: 'active', p: 1 }; - const result = await listTestCases(args as any); - - expect(axios.get).toHaveBeenCalledWith( - expect.stringContaining('/projects/PR-1/test-cases?'), - expect.objectContaining({ auth: expect.any(Object) }) - ); + const result = await listTestCasesTool(args as any, mockConfig, mockServer); expect(result.content?.[0]?.text).toContain('Found 2 test case(s):'); expect(result.content?.[0]?.text).toContain('TC-1: Test One [functional | high]'); expect(result.content?.[1]?.text).toBe(JSON.stringify(mockCases, null, 2)); }); it('should handle API errors gracefully', async () => { - mockedAxios.get.mockRejectedValue(new Error('Failed to list test cases: Network Error')); - - const result = await listTestCases({ project_identifier: 'PR-1' } as any); - + (listTestCases as Mock).mockResolvedValue({ + content: [ + { type: 'text', text: 'Failed to list test cases: Network Error', isError: true }, + ], + isError: true, + }); + const result = await listTestCasesTool({ project_identifier: 'PR-1' } as any, mockConfig, mockServer); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toContain('Failed to list test cases: Network Error'); }); @@ -298,33 +311,52 @@ describe('createTestRunTool', () => { it('should successfully create a test run', async () => { (createTestRun as Mock).mockResolvedValue(successRunResult); - - const result = await createTestRunTool(validRunArgs as any); - - expect(createTestRun).toHaveBeenCalledWith(validRunArgs); + const runArgs = { + project_identifier: validRunArgs.project_identifier, + test_run: { + name: validRunArgs.run_name, + test_cases: validRunArgs.test_cases, + environment: validRunArgs.environment, + metadata: validRunArgs.metadata + } + }; + const result = await createTestRunTool(runArgs, mockConfig, mockServer); expect(result).toBe(successRunResult); }); it('should handle API errors while creating test run', async () => { (createTestRun as Mock).mockRejectedValue(new Error('API Error')); - - const result = await createTestRunTool(validRunArgs as any); - + const runArgs = { + project_identifier: validRunArgs.project_identifier, + test_run: { + name: validRunArgs.run_name, + test_cases: validRunArgs.test_cases, + environment: validRunArgs.environment, + metadata: validRunArgs.metadata + } + }; + const result = await createTestRunTool(runArgs, mockConfig, mockServer); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toContain('Failed to create test run: API Error'); }); it('should handle unknown error while creating test run', async () => { (createTestRun as Mock).mockRejectedValue('unexpected'); - - const result = await createTestRunTool(validRunArgs as any); - + const runArgs = { + project_identifier: validRunArgs.project_identifier, + test_run: { + name: validRunArgs.run_name, + test_cases: validRunArgs.test_cases, + environment: validRunArgs.environment, + metadata: validRunArgs.metadata + } + }; + const result = await createTestRunTool(runArgs, mockConfig, mockServer); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toContain('Unknown error'); }); }); - describe('listTestRunsTool', () => { beforeEach(() => { vi.clearAllMocks(); @@ -344,9 +376,7 @@ describe('listTestRunsTool', () => { ], isError: false, }); - - const result = await listTestRunsTool({ project_identifier: projectId } as any); - expect(listTestRuns).toHaveBeenCalledWith({ project_identifier: projectId }); + const result = await listTestRunsTool({ project_identifier: projectId }, mockConfig, mockServer); expect(result.isError).toBe(false); expect(result.content?.[0]?.text).toContain('Found 2 test run(s):'); expect(result.content?.[1]?.text).toBe(JSON.stringify(mockRuns, null, 2)); @@ -354,7 +384,7 @@ describe('listTestRunsTool', () => { it('should handle errors', async () => { (listTestRuns as Mock).mockRejectedValue(new Error('Network Error')); - const result = await listTestRunsTool({ project_identifier: projectId } as any); + const result = await listTestRunsTool({ project_identifier: projectId }, mockConfig, mockServer); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toContain('Failed to list test runs: Network Error'); }); @@ -380,9 +410,15 @@ describe('updateTestRunTool', () => { ], isError: false, }); - - const result = await updateTestRunTool(args as any); - expect(updateTestRun).toHaveBeenCalledWith(args); + const updateArgs = { + project_identifier: args.project_identifier, + test_run_id: args.test_run_id, + test_run: { + name: args.test_run.name, + run_state: "in_progress" as const + } + }; + const result = await updateTestRunTool(updateArgs, mockConfig, mockServer); expect(result.isError).toBe(false); expect(result.content?.[0]?.text).toContain(`Successfully updated test run ${args.test_run_id}`); expect(result.content?.[1]?.text).toBe(JSON.stringify(updated, null, 2)); @@ -390,14 +426,20 @@ describe('updateTestRunTool', () => { it('should handle errors', async () => { (updateTestRun as Mock).mockRejectedValue(new Error('API Error')); - const result = await updateTestRunTool(args as any); + const updateArgs = { + project_identifier: args.project_identifier, + test_run_id: args.test_run_id, + test_run: { + name: args.test_run.name, + run_state: "in_progress" as const + } + }; + const result = await updateTestRunTool(updateArgs, mockConfig, mockServer); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toContain('Failed to update test run: API Error'); }); }); - - describe('addTestResultTool', () => { beforeEach(() => { vi.clearAllMocks(); @@ -423,27 +465,14 @@ describe('addTestResultTool', () => { it('should successfully add a test result', async () => { (addTestResult as Mock).mockResolvedValue(successAddResult); - - const result = await addTestResultTool(validArgs as any); - - expect(addTestResult).toHaveBeenCalledWith(validArgs); - expect(result).toBe(successAddResult); - }); - - it('should handle API errors gracefully', async () => { - (addTestResult as Mock).mockRejectedValue(new Error('Network Error')); - - const result = await addTestResultTool(validArgs as any); - - expect(result.isError).toBe(true); - expect(result.content?.[0]?.text).toContain('Failed to add test result: Network Error'); + const result = await addTestResultTool(validArgs, mockConfig, mockServer); + expect(result.isError).toBe(false); + expect(result.content?.[0]?.text).toContain('Successfully added test result to test run run-456'); }); it('should handle unknown errors gracefully', async () => { (addTestResult as Mock).mockRejectedValue('unexpected'); - - const result = await addTestResultTool(validArgs as any); - + const result = await addTestResultTool(validArgs, mockConfig, mockServer); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toContain('Unknown error'); }); @@ -459,53 +488,52 @@ const mockContext = { sendNotification: vi.fn(), _meta: { progressToken: "test-p describe("uploadProductRequirementFileTool", () => { beforeEach(() => vi.resetAllMocks()); - it("returns error when file does not exist", async () => { - (fs.existsSync as Mock).mockReturnValue(false); - const res = await uploadProductRequirementFileTool({ project_identifier: testProjectId, file_path: testFilePath }); + (uploadFile as Mock).mockResolvedValue({ + content: [ + { type: 'text', text: 'File /tmp/sample.pdf does not exist.', isError: true }, + ], + isError: true, + }); + const res = await uploadProductRequirementFileTool({ project_identifier: testProjectId, file_path: testFilePath }, mockConfig, mockServer); expect(res.isError).toBe(true); expect(res.content?.[0]?.text).toContain("does not exist"); }); - it("uploads file and returns metadata", async () => { - (fs.existsSync as Mock).mockReturnValue(true); - (fs.createReadStream as Mock).mockReturnValue("STREAM"); - const mockUpload = { - status: 200, - data: { - generic_attachment: [ - { - id: mockFileId, + const mockSuccessResponse = { + content: [ + { type: 'text', text: 'Successfully uploaded sample.pdf to BrowserStack Test Management.' }, + { + type: 'text', + text: JSON.stringify([{ name: "sample.pdf", - download_url: mockDownloadUrl, - content_type: "application/pdf", - size: 1024 - } - ] - } + documentID: "mock-doc-id", + contentType: "application/pdf", + size: 1024, + projectReferenceId: "999" + }], null, 2) + }, + ], + isError: false, }; - mockedAxios.get.mockResolvedValue({ data: { success: true, projects: [{ identifier: testProjectId, id: "999" }] } }); - mockedAxios.post.mockResolvedValue(mockUpload); - const res = await uploadProductRequirementFileTool({ project_identifier: testProjectId, file_path: testFilePath }); + (uploadFile as Mock).mockResolvedValue(mockSuccessResponse); + const res = await uploadProductRequirementFileTool({ project_identifier: testProjectId, file_path: testFilePath }, mockConfig, mockServer); + expect(uploadFile).toHaveBeenCalledWith({ project_identifier: testProjectId, file_path: testFilePath }, mockConfig); expect(res.isError ?? false).toBe(false); expect(res.content?.[1]?.text).toContain("documentID"); }); }); -// Tests for createTestCasesFromFileTool describe("createTestCasesFromFileTool", () => { beforeEach(() => vi.resetAllMocks()); - it("returns error when document is not in signedUrlMap", async () => { signedUrlMap.clear(); (createTestCasesFromFile as Mock).mockRejectedValue(new Error("Re-Upload the file")); - const args = { documentId: testDocumentId, folderId: testFolderId, projectReferenceId: testProjectId }; - const res = await createTestCasesFromFileTool(args as any, mockContext); + const res = await createTestCasesFromFileTool(args as any, mockContext, mockConfig, mockServer); expect(res.isError).toBe(true); expect(res.content?.[0]?.text).toContain("Re-Upload the file"); }); - it("creates test cases from a file successfully", async () => { signedUrlMap.set(testDocumentId, { fileId: mockFileId, downloadUrl: mockDownloadUrl }); mockedAxios.get.mockResolvedValueOnce({ @@ -535,14 +563,12 @@ describe("createTestCasesFromFileTool", () => { content: [{ type: "text", text: "Total of 5 test cases created in 2 scenarios." }], isError: false }); - - const res = await createTestCasesFromFileTool(args as any, mockContext); + const res = await createTestCasesFromFileTool(args as any, mockContext, mockConfig, mockServer); expect(res.isError ?? false).toBe(false); expect(res.content?.[0]?.text).toContain("test cases created"); }); }); -// Tests for createLCAStepsTool describe("createLCAStepsTool", () => { beforeEach(() => vi.resetAllMocks()); @@ -584,9 +610,9 @@ describe("createLCAStepsTool", () => { (createLCASteps as Mock).mockResolvedValue(mockResponse); - const result = await createLCAStepsTool(validArgs as any, mockContext); + const result = await createLCAStepsTool(validArgs as any, mockContext, mockConfig, mockServer); - expect(createLCASteps).toHaveBeenCalledWith(validArgs, mockContext); + expect(createLCASteps).toHaveBeenCalledWith(validArgs, mockContext, mockConfig); expect(result).toBe(mockResponse); expect(result.isError).toBe(false); }); @@ -594,7 +620,7 @@ describe("createLCAStepsTool", () => { it("handles errors when creating LCA steps", async () => { (createLCASteps as Mock).mockRejectedValue(new Error("API Error")); - const result = await createLCAStepsTool(validArgs as any, mockContext); + const result = await createLCAStepsTool(validArgs as any, mockContext, mockConfig, mockServer); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toContain("Failed to create LCA steps: API Error"); @@ -603,7 +629,7 @@ describe("createLCAStepsTool", () => { it("handles unknown errors when creating LCA steps", async () => { (createLCASteps as Mock).mockRejectedValue("unexpected error"); - const result = await createLCAStepsTool(validArgs as any, mockContext); + const result = await createLCAStepsTool(validArgs as any, mockContext, mockConfig, mockServer); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toContain("Failed to create LCA steps: Unknown error"); @@ -621,9 +647,8 @@ describe("createLCAStepsTool", () => { (createLCASteps as Mock).mockResolvedValue(mockResponse); - const result = await createLCAStepsTool(argsWithoutWait as any, mockContext); + const result = await createLCAStepsTool(argsWithoutWait as any, mockContext, mockConfig, mockServer); - expect(createLCASteps).toHaveBeenCalledWith(argsWithoutWait, mockContext); expect(result.content?.[1]?.text).toContain("Check the BrowserStack Test Management UI"); }); @@ -639,9 +664,23 @@ describe("createLCAStepsTool", () => { (createLCASteps as Mock).mockResolvedValue(mockResponse); - const result = await createLCAStepsTool(argsWithCustomWait as any, mockContext); + const result = await createLCAStepsTool(argsWithCustomWait as any, mockContext, mockConfig, mockServer); - expect(createLCASteps).toHaveBeenCalledWith(argsWithCustomWait, mockContext); expect(result.content?.[1]?.text).toContain("within 5 minutes"); }); }); + +vi.mock('../../src/tools/testmanagement-utils/upload-file', () => { + const uploadFile = vi.fn(); + return { + uploadFile, + UploadFileSchema: { + parse: (args: any) => args, + shape: {}, + }, + __esModule: true, + default: { uploadFile }, + }; +}); + +// Get the mocked uploadFile diff --git a/tsconfig.json b/tsconfig.json index 18bffef..d923b93 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,12 +4,13 @@ "module": "NodeNext", "strict": true, "esModuleInterop": true, + "isolatedModules": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "isolatedModules": true, "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "declaration": true }, "include": ["src/**/*"], "exclude": ["node_modules"]