diff --git a/.github/workflows/mcp-registry-publish.yml b/.github/workflows/mcp-registry-publish.yml new file mode 100644 index 00000000..44e2c34f --- /dev/null +++ b/.github/workflows/mcp-registry-publish.yml @@ -0,0 +1,41 @@ +name: Publish to MCP Registry + +on: + workflow_dispatch: + push: + tags: ["v*"] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "lts/*" + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test --if-present + + - name: Build package + run: npm run build --if-present + + - name: Install MCP Publisher + run: | + curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.1.0/mcp-publisher_1.1.0_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher + + - name: Login to MCP Registry + run: ./mcp-publisher login github-oidc + + - name: Publish to MCP Registry + run: ./mcp-publisher publish diff --git a/README.md b/README.md index 8d65f89c..e84d6b2a 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,21 @@ Manage, execute, debug tests, and even fix code using plain English prompts. #### Reduced context switching: Stay in flow—keep all project context in one place and trigger actions directly from your IDE or LLM. -## ⚡️ One Click MCP Setup +## ⚡️ One Click MCP Setup -[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](http://mcp.browserstack.com/one-click-setup?client=vscode)   [![Install in Cursor](https://img.shields.io/badge/Cursor-Install_Server-24bfa5?style=flat-square&color=000000&logo=visualstudiocode&logoColor=white)](http://mcp.browserstack.com/one-click-setup?client=cursor) +Click on the buttons below to install MCP in your respective IDE: +Install in VS Code   Install in Cursor + +#### Note : Ensure you are using Node version >= `18.0` +- Check your node version using `node --version`. Recommended version: `v22.15.0` (LTS) +- To Upgrade Node : +- 1. On macOS `(Homebrew) - brew update && brew upgrade node or if using (nvm) - nvm install 22.15.0 && nvm use 22.15.0 && nvm alias default 22.15.0` +- 2. On Windows `(nvm-windows) : nvm install 22.15.0 && nvm use 22.15.0` +- 👉 Or directly download the Node.js LTS Installer + +. + ## 💡 Usage Examples ### 📱 Manual App Testing @@ -142,10 +153,15 @@ Generate test cases from PRDs, convert manual tests to low-code automation, and ## 🛠️ Installation +### 📋 Prerequisites for MCP Setup +#### Note : Ensure you are using Node version >= `18.0` +- Check your node version using `node --version`. Recommended version: `v22.15.0` (LTS) + ### **One Click MCP Setup** -[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](http://mcp.browserstack.com/one-click-setup?client=vscode)   [![Install in Cursor](https://img.shields.io/badge/Cursor-Install_Server-24bfa5?style=flat-square&color=000000&logo=visualstudiocode&logoColor=white)](http://mcp.browserstack.com/one-click-setup?client=cursor) +Click on the buttons below to install MCP in your respective IDE: +Install in VS Code   Install in Cursor ### **Alternate ways to Setup MCP server** @@ -158,7 +174,9 @@ Generate test cases from PRDs, convert manual tests to low-code automation, and - Once you have an account (and purchased appropriate plan), note down your `username` and `access_key` from [Account Settings](https://www.browserstack.com/accounts/profile/details). -2. Ensure you are using Node version >= `18.0`. Check your node version using `node --version`. Recommended version: `v22.15.0` (LTS) +2. #### Note : Ensure you are using Node version >= `18.0` + - Check your node version using `node --version`. Recommended version: `v22.15.0` (LTS) + 3. **Install the MCP Server** @@ -404,14 +422,14 @@ As of now we support 20 tools. **Prompt example** ```text - Take a screenshot of my app on Google Pixel 6 with Android 14 while testing on App Automate. App file path: /Users/xyz/app-debug.apk + Take a screenshot of my app on Google Pixel 6 with Android 12 while testing on App Automate. App file path: /Users/xyz/app-debug.apk ``` 15. `runAppTestsOnBrowserStack` — Run automated mobile tests (Espresso/XCUITest, etc.) on real devices. **Prompt example** ```text - Run Espresso tests from /tests/checkout.zip on Galaxy S21 and Pixel 6 with Android 14. App path is /apps/beta-release.apk under project 'Checkout Flow' + Run Espresso tests from /tests/checkout.zip on Galaxy S21 and Pixel 6 with Android 12. App path is /apps/beta-release.apk under project 'Checkout Flow' ``` --- @@ -456,7 +474,79 @@ As of now we support 20 tools. ```text Upload PRD from /Users/xyz/Desktop/login-flow.pdf and use BrowserStack AI to generate test cases ``` +## 🚀 Remote MCP Server + +Remote MCP comes with all the functionalities of an MCP server without the hassles of complex setup or local installation. + +### Key benefits: + +- ✅ Works seamlessly in enterprise networks without worrying about firewalls or binaries or where local installation is not allowed. + +- ✅ Secure OAuth integration – no password sharing or manual credential handling. + +### Limitations: + +- ❌ No Local Testing support (cannot test apps behind VPNs, firewalls, or localhost). If you have to do Local Testing, you would have to use a BrowserStack Local MCP server. +- ❌ Latency can be slightly higher, but nothing considerable — you generally won’t notice it in normal use. +### Installation Steps: + + - On VSCode (Copilot - Agent Mode): `.vscode/mcp.json`: + + - Locate or Create the Configuration File: + - In the root directory of your project, look for a folder named .vscode. This folder is usually hidden so you will need to find it as mentioned in the expand. + - If this folder doesn't exist, create it. + - Inside the .vscode folder, create a new file named mcp.json + - To setup Remote BrowserStack MCP instead of local BrowserStack MCP you can add the following JSON content : +
+ Remote MCP JSON file +
+ + ### Alternative way to Setup Remote MCP + + - Step 1.Click on the gear icon to Select Tools + +
+ Select Tools +
+ + - Step 2. A tool menu would appear at the top-centre, scroll down on the menu at the top and then Click on Add MCP Server + +
+ Add MCP Server +
+ + - Step 3. Click on HTTP option +
+ HTTP Option +
+ + - Step 4. Paste Remote MCP Server URL : https://mcp.browserstack.com/mcp +
+ Remote MCP Server URL +
+ + - Step 5. Give server id as : browserstack + +
+ Remote MCP Server ID +
+ + - Step 6. In VSCode Click on start MCP Server and then click on "Allow" + +
+ authentication1 +
+ +
+ authentication2 +
+ +
+ Sign_in_success +
+ + ## 🤝 Recommended MCP Clients diff --git a/assets/authentication1.png b/assets/authentication1.png new file mode 100644 index 00000000..5ca7de8b Binary files /dev/null and b/assets/authentication1.png differ diff --git a/assets/authentication2.png b/assets/authentication2.png new file mode 100644 index 00000000..e76cf5f2 Binary files /dev/null and b/assets/authentication2.png differ diff --git a/assets/http_option.png b/assets/http_option.png new file mode 100644 index 00000000..4b63c7c6 Binary files /dev/null and b/assets/http_option.png differ diff --git a/assets/one-click-cursor.png b/assets/one-click-cursor.png new file mode 100644 index 00000000..324bdc6b Binary files /dev/null and b/assets/one-click-cursor.png differ diff --git a/assets/one-click-vs-code.png b/assets/one-click-vs-code.png new file mode 100644 index 00000000..3b6d4539 Binary files /dev/null and b/assets/one-click-vs-code.png differ diff --git a/assets/remotemcp_json_file.png b/assets/remotemcp_json_file.png new file mode 100644 index 00000000..cb3d9669 Binary files /dev/null and b/assets/remotemcp_json_file.png differ diff --git a/assets/server_id.png b/assets/server_id.png new file mode 100644 index 00000000..64ca97c7 Binary files /dev/null and b/assets/server_id.png differ diff --git a/assets/server_url.png b/assets/server_url.png new file mode 100644 index 00000000..fb049ae2 Binary files /dev/null and b/assets/server_url.png differ diff --git a/assets/signin_success.png b/assets/signin_success.png new file mode 100644 index 00000000..68bdefeb Binary files /dev/null and b/assets/signin_success.png differ diff --git a/package-lock.json b/package-lock.json index f62c471c..414e471a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@browserstack/mcp-server", - "version": "1.2.2", + "version": "1.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@browserstack/mcp-server", - "version": "1.2.2", + "version": "1.2.6", "license": "ISC", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.4", + "@modelcontextprotocol/sdk": "^1.18.1", "@types/form-data": "^2.5.2", "axios": "^1.8.4", "browserstack-local": "^1.5.6", @@ -1125,16 +1125,17 @@ "dev": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.4.tgz", - "integrity": "sha512-OTbhe5slIjiOtLxXhKalkKGhIQrwvhgCDs/C2r8kcBTy5HR/g43aDQU0l7r8O0VGbJPTNJvDc7ZdQMdQDJXmbw==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.18.1.tgz", + "integrity": "sha512-d//GE8/Yh7aC3e7p+kZG8JqqEAwwDUmAfvH1quogtbk+ksS6E0RR6toKKESPYYZVre0meqkJb27zb+dhqE9Sgw==", "license": "MIT", "dependencies": { - "ajv": "^8.17.1", + "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", @@ -1158,22 +1159,6 @@ "node": ">= 0.6" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", @@ -1288,12 +1273,6 @@ "node": ">=0.10.0" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -2402,7 +2381,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2576,12 +2554,13 @@ } }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", + "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -4072,8 +4051,7 @@ "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -4094,22 +4072,6 @@ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, - "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fast-xml-parser": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", @@ -4994,8 +4956,7 @@ "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -6028,7 +5989,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "engines": { "node": ">=6" } @@ -6173,15 +6133,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -6879,9 +6830,9 @@ } }, "node_modules/tar-fs": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz", - "integrity": "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -7160,7 +7111,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "dependencies": { "punycode": "^2.1.0" } @@ -7217,10 +7167,11 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", + "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "dev": true, + "license": "MIT", "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/package.json b/package.json index 5afe2d93..eae70a86 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "@browserstack/mcp-server", - "version": "1.2.2", + "version": "1.2.6", "description": "BrowserStack's Official MCP Server", + "mcpName": "io.github.browserstack/mcp-server", "main": "dist/index.js", "repository": { "type": "git", @@ -34,7 +35,7 @@ "author": "", "license": "ISC", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.4", + "@modelcontextprotocol/sdk": "^1.18.1", "@types/form-data": "^2.5.2", "axios": "^1.8.4", "browserstack-local": "^1.5.6", diff --git a/server.json b/server.json new file mode 100644 index 00000000..23eb603e --- /dev/null +++ b/server.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json", + "name": "io.github.browserstack/mcp-server", + "description": "BrowserStack's Official MCP Server", + "repository": { + "url": "https://github.com/browserstack/mcp-server", + "source": "github" + }, + "version": "1.0.0", + "packages": [ + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "@browserstack/mcp-server", + "version": "1.2.6", + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "description": "BrowserStack Username", + "isRequired": true, + "format": "string", + "isSecret": false, + "name": "BROWSERSTACK_USERNAME" + }, + { + "description": "BrowserStack Access Key", + "isRequired": true, + "format": "string", + "isSecret": true, + "name": "BROWSERSTACK_ACCESS_KEY" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 6a17511b..ef48938d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,3 +51,4 @@ process.on("exit", () => { export { setLogger } from "./logger.js"; export { BrowserStackMcpServer } from "./server-factory.js"; export { trackMCP } from "./lib/instrumentation.js"; +export const PackageJsonVersion = packageJson.version; diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index 23cdeea9..fc6186ab 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -4,12 +4,15 @@ const { HttpsProxyAgent } = httpsProxyAgentPkg; import * as https from "https"; import * as fs from "fs"; import config from "../config.js"; +import { isDataUrlPayloadTooLarge } from "../lib/utils.js"; type RequestOptions = { url: string; headers?: Record; params?: Record; body?: any; + timeout?: number; + responseType?: AxiosRequestConfig["responseType"]; raise_error?: boolean; // default: true }; @@ -99,11 +102,53 @@ class ApiClient { return getAxiosAgent(); } + private validateUrl(url: string, options?: AxiosRequestConfig) { + try { + const parsedUrl = new URL(url); + + // Default safe limits + const maxContentLength = options?.maxContentLength ?? 20 * 1024 * 1024; // 20MB + const maxBodyLength = options?.maxBodyLength ?? 20 * 1024 * 1024; // 20MB + const maxUrlLength = 8000; // cutoff for URLs + + // Check overall URL length + if (url.length > maxUrlLength) { + throw new Error( + `URL length exceeds maxUrlLength (${maxUrlLength} chars)`, + ); + } + + if (parsedUrl.protocol === "data:") { + // Either reject completely OR check payload size + if (isDataUrlPayloadTooLarge(url, maxContentLength)) { + throw new Error("data: URI payload too large or invalid"); + } + } else if (!["http:", "https:"].includes(parsedUrl.protocol)) { + throw new Error(`Unsupported URL scheme: ${parsedUrl.protocol}`); + } + + if ( + options?.data && + Buffer.byteLength(JSON.stringify(options.data), "utf8") > maxBodyLength + ) { + throw new Error( + `Request body exceeds maxBodyLength (${maxBodyLength} bytes)`, + ); + } + } catch (error: any) { + throw new Error(`Invalid URL: ${error.message}`); + } + } + private async requestWrapper( fn: (agent: AxiosRequestConfig["httpsAgent"]) => Promise>, + url: string, + config?: AxiosRequestConfig, raise_error: boolean = true, ): Promise> { try { + this.validateUrl(url, config); + const res = await fn(this.axiosAgent); return new ApiResponse(res); } catch (error: any) { @@ -118,11 +163,21 @@ class ApiClient { url, headers, params, + timeout, + responseType, raise_error = true, }: RequestOptions): Promise> { + const config: AxiosRequestConfig = { + headers, + params, + timeout, + responseType, + httpsAgent: this.axiosAgent, + }; return this.requestWrapper( - (agent) => - this.instance.get(url, { headers, params, httpsAgent: agent }), + () => this.instance.get(url, config), + url, + config, raise_error, ); } @@ -131,11 +186,19 @@ class ApiClient { url, headers, body, + timeout, raise_error = true, }: RequestOptions): Promise> { + const config: AxiosRequestConfig = { + headers, + timeout, + httpsAgent: this.axiosAgent, + data: body, + }; return this.requestWrapper( - (agent) => - this.instance.post(url, body, { headers, httpsAgent: agent }), + () => this.instance.post(url, config.data, config), + url, + config, raise_error, ); } @@ -144,11 +207,19 @@ class ApiClient { url, headers, body, + timeout, raise_error = true, }: RequestOptions): Promise> { + const config: AxiosRequestConfig = { + headers, + timeout, + httpsAgent: this.axiosAgent, + data: body, + }; return this.requestWrapper( - (agent) => - this.instance.put(url, body, { headers, httpsAgent: agent }), + () => this.instance.put(url, config.data, config), + url, + config, raise_error, ); } @@ -157,11 +228,19 @@ class ApiClient { url, headers, body, + timeout, raise_error = true, }: RequestOptions): Promise> { + const config: AxiosRequestConfig = { + headers, + timeout, + httpsAgent: this.axiosAgent, + data: body, + }; return this.requestWrapper( - (agent) => - this.instance.patch(url, body, { headers, httpsAgent: agent }), + () => this.instance.patch(url, config.data, config), + url, + config, raise_error, ); } @@ -170,11 +249,19 @@ class ApiClient { url, headers, params, + timeout, raise_error = true, }: RequestOptions): Promise> { + const config: AxiosRequestConfig = { + headers, + params, + timeout, + httpsAgent: this.axiosAgent, + }; return this.requestWrapper( - (agent) => - this.instance.delete(url, { headers, params, httpsAgent: agent }), + () => this.instance.delete(url, config), + url, + config, raise_error, ); } diff --git a/src/lib/device-cache.ts b/src/lib/device-cache.ts index d6dd0aff..32066c3c 100644 --- a/src/lib/device-cache.ts +++ b/src/lib/device-cache.ts @@ -13,6 +13,8 @@ export enum BrowserStackProducts { LIVE = "live", APP_LIVE = "app_live", APP_AUTOMATE = "app_automate", + SELENIUM_AUTOMATE = "selenium_automate", + PLAYWRIGHT_AUTOMATE = "playwright_automate", } const URLS: Record = { @@ -22,6 +24,10 @@ const URLS: Record = { "https://www.browserstack.com/list-of-browsers-and-platforms/app_live.json", [BrowserStackProducts.APP_AUTOMATE]: "https://www.browserstack.com/list-of-browsers-and-platforms/app_automate.json", + [BrowserStackProducts.SELENIUM_AUTOMATE]: + "https://www.browserstack.com/list-of-browsers-and-platforms/automate.json", + [BrowserStackProducts.PLAYWRIGHT_AUTOMATE]: + "https://www.browserstack.com/list-of-browsers-and-platforms/playwright.json", }; /** @@ -34,37 +40,41 @@ export async function getDevicesAndBrowsers( fs.mkdirSync(CACHE_DIR, { recursive: true }); } - let cache: any = {}; + let cache: Record = {}; + // Load existing cache if (fs.existsSync(CACHE_FILE)) { - const stats = fs.statSync(CACHE_FILE); - if (Date.now() - stats.mtimeMs < TTL_MS) { - try { - cache = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")); - if (cache[type]) { - return cache[type]; - } - } catch (error) { - console.error("Error parsing cache file:", error); - // Continue with fetching fresh data - } + try { + cache = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")); + } catch (err) { + console.error("Error parsing cache file:", err); + cache = {}; + } + + // Check per-product TTL + const cachedEntry = cache[type]; + if (cachedEntry?.timestamp && Date.now() - cachedEntry.timestamp < TTL_MS) { + return cachedEntry.data; } } + // Fetch fresh data from BrowserStack const liveRes = await apiClient.get({ url: URLS[type], raise_error: false }); - if (!liveRes.ok) { throw new Error( - `Failed to fetch configuration from BrowserStack : ${type}=${liveRes.statusText}`, + `Failed to fetch configuration from BrowserStack: ${type} = ${liveRes.statusText}`, ); } - cache = { - [type]: liveRes.data, + // Save to cache with timestamp and data directly under product key + cache[type] = { + timestamp: Date.now(), + data: liveRes.data, }; - fs.writeFileSync(CACHE_FILE, JSON.stringify(cache), "utf8"); - return cache[type]; + fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2), "utf8"); + + return liveRes.data; } // Rate limiter for started event (3H) diff --git a/src/lib/inmemory-store.ts b/src/lib/inmemory-store.ts index 76515ebc..1f3ac90c 100644 --- a/src/lib/inmemory-store.ts +++ b/src/lib/inmemory-store.ts @@ -1 +1,13 @@ export const signedUrlMap = new Map(); + +let _storedPercyResults: any = null; + +export const storedPercyResults = { + get: () => _storedPercyResults, + set: (value: any) => { + _storedPercyResults = value; + }, + clear: () => { + _storedPercyResults = null; + }, +}; diff --git a/src/lib/instrumentation.ts b/src/lib/instrumentation.ts index 080d0dc3..c9dd1a1c 100644 --- a/src/lib/instrumentation.ts +++ b/src/lib/instrumentation.ts @@ -3,7 +3,7 @@ import { getBrowserStackAuth } from "./get-auth.js"; import { createRequire } from "module"; const require = createRequire(import.meta.url); const packageJson = require("../../package.json"); -import axios from "axios"; +import { apiClient } from "./apiClient.js"; import globalConfig from "../config.js"; interface MCPEventPayload { @@ -63,13 +63,16 @@ export function trackMCP( authHeader = `Basic ${Buffer.from(authString).toString("base64")}`; } - axios - .post(instrumentationEndpoint, event, { + apiClient + .post({ + url: instrumentationEndpoint, + body: event, headers: { "Content-Type": "application/json", ...(authHeader ? { Authorization: authHeader } : {}), }, timeout: 2000, + raise_error: false, }) .catch(() => {}); } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e19532c9..5b8d5af6 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,10 @@ import sharp from "sharp"; import type { ApiResponse } from "./apiClient.js"; +import { BrowserStackConfig } from "./types.js"; +import { getBrowserStackAuth } from "./get-auth.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { trackMCP } from "../index.js"; export function sanitizeUrlParam(param: string): string { // Remove any characters that could be used for command injection @@ -38,3 +43,73 @@ export async function assertOkResponse( ); } } + +export async function fetchFromBrowserStackAPI( + url: string, + config: BrowserStackConfig, +): Promise { + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); + + const res = await fetch(url, { + headers: { + Authorization: `Basic ${auth}`, + }, + }); + + if (!res.ok) { + throw new Error( + `Failed to fetch from ${url}: ${res.status} ${res.statusText}`, + ); + } + + return res.json(); +} + +function errorContent(message: string): CallToolResult { + return { + content: [{ type: "text", text: message }], + isError: true, + }; +} + +export function handleMCPError( + toolName: string, + server: McpServer, + config: BrowserStackConfig, + error: unknown, +) { + trackMCP(toolName, server.server.getClientVersion()!, error, config); + + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + + const readableToolName = toolName.replace(/([A-Z])/g, " $1").toLowerCase(); + + return errorContent( + `Failed to ${readableToolName}: ${errorMessage}. Please open an issue on GitHub if the problem persists`, + ); +} + +export function isDataUrlPayloadTooLarge( + dataUrl: string, + maxBytes: number, +): boolean { + const commaIndex = dataUrl.indexOf(","); + if (commaIndex === -1) return true; // malformed + const meta = dataUrl.slice(0, commaIndex); + const payload = dataUrl.slice(commaIndex + 1); + + const isBase64 = /;base64$/i.test(meta); + if (!isBase64) { + try { + const decoded = decodeURIComponent(payload); + return Buffer.byteLength(decoded, "utf8") > maxBytes; + } catch { + return true; + } + } + + const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0; + const decodedBytes = Math.floor((payload.length * 3) / 4) - padding; + return decodedBytes > maxBytes; +} diff --git a/src/lib/version-resolver.ts b/src/lib/version-resolver.ts index 25564e97..14ca3cb0 100644 --- a/src/lib/version-resolver.ts +++ b/src/lib/version-resolver.ts @@ -6,6 +6,7 @@ export function resolveVersion(requested: string, available: string[]): string { // strip duplicates & sort const uniq = Array.from(new Set(available)); + // pick min/max if (requested === "latest" || requested === "oldest") { // try numeric @@ -21,29 +22,55 @@ export function resolveVersion(requested: string, available: string[]): string { return requested === "latest" ? lex[lex.length - 1] : lex[0]; } - // exact? + // exact match? if (uniq.includes(requested)) { return requested; } - // try closest numeric + const caseInsensitiveMatch = uniq.find( + (v) => v.toLowerCase() === requested.toLowerCase(), + ); + if (caseInsensitiveMatch) { + return caseInsensitiveMatch; + } + + // Try major version matching (e.g., "14" matches "14.0", "14.1", etc.) const reqNum = parseFloat(requested); - const nums = uniq - .map((v) => ({ v, n: parseFloat(v) })) - .filter((x) => !isNaN(x.n)); - if (!isNaN(reqNum) && nums.length) { - let best = nums[0], - bestDiff = Math.abs(nums[0].n - reqNum); - for (const x of nums) { - const d = Math.abs(x.n - reqNum); - if (d < bestDiff) { - best = x; - bestDiff = d; + if (!isNaN(reqNum)) { + const majorVersionMatches = uniq.filter((v) => { + const vNum = parseFloat(v); + return !isNaN(vNum) && Math.floor(vNum) === Math.floor(reqNum); + }); + + if (majorVersionMatches.length > 0) { + // If multiple matches, prefer the most common format or latest + const exactMatch = majorVersionMatches.find( + (v) => v === `${Math.floor(reqNum)}.0`, + ); + if (exactMatch) { + return exactMatch; } + // Return the first match (usually the most common format) + return majorVersionMatches[0]; + } + } + + // Fuzzy matching: find the closest version + const reqNumForFuzzy = parseFloat(requested); + if (!isNaN(reqNumForFuzzy)) { + const numericVersions = uniq + .map((v) => ({ v, n: parseFloat(v) })) + .filter((x) => !isNaN(x.n)) + .sort( + (a, b) => + Math.abs(a.n - reqNumForFuzzy) - Math.abs(b.n - reqNumForFuzzy), + ); + + if (numericVersions.length > 0) { + return numericVersions[0].v; } - return best.v; } - // final fallback + // Fallback: return the first available version return uniq[0]; } diff --git a/src/server-factory.ts b/src/server-factory.ts index 82f93730..a5a926d3 100644 --- a/src/server-factory.ts +++ b/src/server-factory.ts @@ -7,6 +7,7 @@ const require = createRequire(import.meta.url); const packageJson = require("../package.json"); import logger from "./logger.js"; import addSDKTools from "./tools/bstack-sdk.js"; +import addPercyTools from "./tools/percy-sdk.js"; import addBrowserLiveTools from "./tools/live.js"; import addAccessibilityTools from "./tools/accessibility.js"; import addTestManagementTools from "./tools/testmanagement.js"; @@ -15,8 +16,10 @@ import addFailureLogsTools from "./tools/get-failure-logs.js"; import addAutomateTools from "./tools/automate.js"; import addSelfHealTools from "./tools/selfheal.js"; import addAppLiveTools from "./tools/applive.js"; +import addBuildInsightsTools from "./tools/build-insights.js"; import { setupOnInitialized } from "./oninitialized.js"; import { BrowserStackConfig } from "./lib/types.js"; +import addRCATools from "./tools/rca-agent.js"; /** * Wrapper class for BrowserStack MCP Server @@ -48,6 +51,7 @@ export class BrowserStackMcpServer { const toolAdders = [ addAccessibilityTools, addSDKTools, + addPercyTools, addAppLiveTools, addBrowserLiveTools, addTestManagementTools, @@ -55,6 +59,8 @@ export class BrowserStackMcpServer { addFailureLogsTools, addAutomateTools, addSelfHealTools, + addBuildInsightsTools, + addRCATools, ]; toolAdders.forEach((adder) => { diff --git a/src/tools/accessibility.ts b/src/tools/accessibility.ts index 625bc2a0..5f881b12 100644 --- a/src/tools/accessibility.ts +++ b/src/tools/accessibility.ts @@ -3,81 +3,402 @@ import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { AccessibilityScanner } from "./accessiblity-utils/scanner.js"; import { AccessibilityReportFetcher } from "./accessiblity-utils/report-fetcher.js"; +import { AccessibilityAuthConfig } from "./accessiblity-utils/auth-config.js"; import { trackMCP } from "../lib/instrumentation.js"; import { parseAccessibilityReportFromCSV } from "./accessiblity-utils/report-parser.js"; import { queryAccessibilityRAG } from "./accessiblity-utils/accessibility-rag.js"; import { getBrowserStackAuth } from "../lib/get-auth.js"; import { BrowserStackConfig } from "../lib/types.js"; +import logger from "../logger.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(); +interface AuthCredentials { + username: string; + password: string; +} + +interface ScanProgressContext { + sendNotification: (notification: any) => Promise; + _meta?: { + progressToken?: string | number; + }; +} + +interface FormAuthArgs { + name: string; + type: "form"; + url: string; + username: string; + password: string; + usernameSelector: string; + passwordSelector: string; + submitSelector: string; +} + +interface BasicAuthArgs { + name: string; + type: "basic"; + url: string; + username: string; + password: string; +} + +type AuthConfigArgs = FormAuthArgs | BasicAuthArgs; + +function setupAuth(config: BrowserStackConfig): AuthCredentials { const authString = getBrowserStackAuth(config); const [username, password] = authString.split(":"); - scanner.setAuth({ username, password }); + return { username, password }; +} - // Start scan - const startResp = await scanner.startScan(name, [pageURL]); - const scanId = startResp.data!.id; - const scanRunId = startResp.data!.scanRunId; +function createErrorResponse(message: string, isError = true): CallToolResult { + return { + content: [ + { + type: "text", + text: message, + isError, + }, + ], + isError, + }; +} + +function createSuccessResponse(messages: string[]): CallToolResult { + return { + content: messages.map((text) => ({ + type: "text" as const, + text, + })), + }; +} + +function handleMCPError( + toolName: string, + server: McpServer, + config: BrowserStackConfig, + error: unknown, +): CallToolResult { + trackMCP(toolName, server.server.getClientVersion()!, error, config); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + return createErrorResponse( + `Failed to ${toolName.replace(/([A-Z])/g, " $1").toLowerCase()}: ${errorMessage}. Please open an issue on GitHub if the problem persists`, + ); +} - // Notify scan start +async function notifyScanProgress( + context: ScanProgressContext, + message: string, + progress = 0, +): Promise { await context.sendNotification({ method: "notifications/progress", params: { - progressToken: context._meta?.progressToken ?? "NOT_FOUND", - message: `Accessibility scan "${name}" started`, - progress: 0, + progressToken: context._meta?.progressToken?.toString() ?? "NOT_FOUND", + message, + progress, total: 100, }, }); +} + +async function initializeScanner( + config: BrowserStackConfig, +): Promise { + const scanner = new AccessibilityScanner(); + const auth = setupAuth(config); + scanner.setAuth(auth); + return scanner; +} + +async function initializeReportFetcher( + config: BrowserStackConfig, +): Promise { + const reportFetcher = new AccessibilityReportFetcher(); + const auth = setupAuth(config); + reportFetcher.setAuth(auth); + return reportFetcher; +} + +async function executeAccessibilityRAG( + args: { query: string }, + server: McpServer, + config: BrowserStackConfig, +): Promise { + try { + trackMCP( + "accessibilityExpert", + server.server.getClientVersion()!, + undefined, + config, + ); + return await queryAccessibilityRAG(args.query, config); + } catch (error) { + return handleMCPError("accessibilityExpert", server, config, error); + } +} + +async function executeFetchAccessibilityIssues( + args: { scanId: string; scanRunId: string; cursor?: number }, + server: McpServer, + config: BrowserStackConfig, +): Promise { + try { + trackMCP( + "fetchAccessibilityIssues", + server.server.getClientVersion()!, + undefined, + config, + ); + return await fetchAccessibilityIssues( + args.scanId, + args.scanRunId, + config, + args.cursor, + ); + } catch (error) { + return handleMCPError("fetchAccessibilityIssues", server, config, error); + } +} + +async function fetchAccessibilityIssues( + scanId: string, + scanRunId: string, + config: BrowserStackConfig, + cursor = 0, +): Promise { + const reportFetcher = await initializeReportFetcher(config); + const reportLink = await reportFetcher.getReportLink(scanId, scanRunId); + + const { records, page_length, total_issues, next_page } = + await parseAccessibilityReportFromCSV(reportLink, { nextPage: cursor }); + + const currentlyShown = + cursor === 0 + ? page_length + : Math.floor(cursor / JSON.stringify(records[0] || {}).length) + + page_length; + const remainingIssues = total_issues - currentlyShown; + + const messages = [ + `Retrieved ${page_length} accessibility issues (Total: ${total_issues})`, + `Issues: ${JSON.stringify(records, null, 2)}`, + ]; + + if (next_page !== null) { + messages.push( + `${remainingIssues} more issues available. Use fetchAccessibilityIssues with cursor: ${next_page} to get the next batch.`, + ); + } else { + messages.push(`✅ All issues retrieved.`); + } + + return createSuccessResponse(messages); +} + +async function executeAccessibilityScan( + args: { name: string; pageURL: string; authConfigId?: number }, + context: ScanProgressContext, + server: McpServer, + config: BrowserStackConfig, +): Promise { + try { + trackMCP( + "startAccessibilityScan", + server.server.getClientVersion()!, + undefined, + config, + ); + return await runAccessibilityScan( + args.name, + args.pageURL, + context, + config, + args.authConfigId, + ); + } catch (error) { + return handleMCPError("startAccessibilityScan", server, config, error); + } +} + +function validateFormAuthArgs(args: AuthConfigArgs): args is FormAuthArgs { + return ( + args.type === "form" && + "usernameSelector" in args && + "passwordSelector" in args && + "submitSelector" in args && + !!args.usernameSelector && + !!args.passwordSelector && + !!args.submitSelector + ); +} + +async function createAuthConfig( + args: AuthConfigArgs, + config: BrowserStackConfig, +): Promise { + const authConfig = new AccessibilityAuthConfig(); + const auth = setupAuth(config); + authConfig.setAuth(auth); + + if (args.type === "form") { + if (!validateFormAuthArgs(args)) { + throw new Error( + "Form authentication requires usernameSelector, passwordSelector, and submitSelector", + ); + } + return await authConfig.createFormAuthConfig(args.name, { + username: args.username, + usernameSelector: args.usernameSelector, + password: args.password, + passwordSelector: args.passwordSelector, + submitSelector: args.submitSelector, + url: args.url, + }); + } else { + return await authConfig.createBasicAuthConfig(args.name, { + url: args.url, + username: args.username, + password: args.password, + }); + } +} + +async function executeCreateAuthConfig( + args: AuthConfigArgs, + server: McpServer, + config: BrowserStackConfig, +): Promise { + try { + trackMCP( + "createAccessibilityAuthConfig", + server.server.getClientVersion()!, + undefined, + config, + ); + logger.info(`Creating auth config: ${JSON.stringify(args)}`); + + const result = await createAuthConfig(args, config); + + return createSuccessResponse([ + `✅ Auth config "${args.name}" created successfully with ID: ${result.data?.id}`, + `Auth config details: ${JSON.stringify(result.data, null, 2)}`, + ]); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("Form authentication requires") + ) { + return createErrorResponse(error.message); + } + return handleMCPError( + "createAccessibilityAuthConfig", + server, + config, + error, + ); + } +} + +async function executeGetAuthConfig( + args: { configId: number }, + server: McpServer, + config: BrowserStackConfig, +): Promise { + try { + trackMCP( + "getAccessibilityAuthConfig", + server.server.getClientVersion()!, + undefined, + config, + ); + + const authConfig = new AccessibilityAuthConfig(); + const auth = setupAuth(config); + authConfig.setAuth(auth); + + const result = await authConfig.getAuthConfig(args.configId); + + return createSuccessResponse([ + `✅ Auth config retrieved successfully`, + `Auth config details: ${JSON.stringify(result.data, null, 2)}`, + ]); + } catch (error) { + return handleMCPError("getAccessibilityAuthConfig", server, config, error); + } +} + +function createScanFailureResponse( + name: string, + status: string, +): CallToolResult { + return createErrorResponse( + `❌ Accessibility scan "${name}" failed with status: ${status} , check the BrowserStack dashboard for more details [https://scanner.browserstack.com/site-scanner/scan-details/${name}].`, + ); +} + +function createScanSuccessResponse( + name: string, + totalIssues: number, + pageLength: number, + records: any[], + scanId: string, + scanRunId: string, + reportUrl: string, + cursor: number | null, +): CallToolResult { + const messages = [ + `Accessibility scan "${name}" completed. check the BrowserStack dashboard for more details [https://scanner.browserstack.com/site-scanner/scan-details/${name}].`, + `Scan ID: ${scanId} and Scan Run ID: ${scanRunId}`, + `You can also download the full report from the following link: ${reportUrl}`, + `We found ${totalIssues} issues. Below are the details of the ${pageLength} most critical issues.`, + `Scan results: ${JSON.stringify(records, null, 2)}`, + ]; + + if (cursor !== null) { + messages.push( + `More issues available. Use fetchAccessibilityIssues tool with scanId: "${scanId}", scanRunId: "${scanRunId}", and cursor: ${cursor} to get the next batch.`, + ); + } + + return createSuccessResponse(messages); +} + +async function runAccessibilityScan( + name: string, + pageURL: string, + context: ScanProgressContext, + config: BrowserStackConfig, + authConfigId?: number, +): Promise { + const scanner = await initializeScanner(config); + + const startResp = await scanner.startScan(name, [pageURL], authConfigId); + const scanId = startResp.data!.id; + const scanRunId = startResp.data!.scanRunId; + + await notifyScanProgress(context, `Accessibility scan "${name}" started`, 0); - // Wait until scan completes const status = await scanner.waitUntilComplete(scanId, scanRunId, context); if (status !== "completed") { - return { - content: [ - { - type: "text", - text: `❌ Accessibility scan "${name}" failed with status: ${status} , check the BrowserStack dashboard for more details [https://scanner.browserstack.com/site-scanner/scan-details/${name}].`, - isError: true, - }, - ], - isError: true, - }; + return createScanFailureResponse(name, status); } - // Create report fetcher and set auth on the go - const reportFetcher = new AccessibilityReportFetcher(); - reportFetcher.setAuth({ username, password }); - - // Fetch CSV report link + const reportFetcher = await initializeReportFetcher(config); const reportLink = await reportFetcher.getReportLink(scanId, scanRunId); - const { records, page_length, total_issues } = + const { records, page_length, total_issues, next_page } = await parseAccessibilityReportFromCSV(reportLink); - return { - content: [ - { - type: "text", - text: `✅ Accessibility scan "${name}" completed. check the BrowserStack dashboard for more details [https://scanner.browserstack.com/site-scanner/scan-details/${name}].`, - }, - { - type: "text", - text: `We found ${total_issues} issues. Below are the details of the ${page_length} most critical issues.`, - }, - { - type: "text", - text: `Scan results: ${JSON.stringify(records, null, 2)}`, - }, - ], - }; + return createScanSuccessResponse( + name, + total_issues, + page_length, + records, + scanId, + scanRunId, + reportLink, + next_page, + ); } export default function addAccessibilityTools( @@ -97,33 +418,7 @@ export default function addAccessibilityTools( ), }, async (args) => { - try { - trackMCP( - "accessibilityExpert", - server.server.getClientVersion()!, - undefined, - config, - ); - return await queryAccessibilityRAG(args.query, config); - } catch (error) { - trackMCP( - "accessibilityExpert", - server.server.getClientVersion()!, - error, - config, - ); - return { - content: [ - { - type: "text", - text: `Failed to query accessibility RAG: ${ - error instanceof Error ? error.message : "Unknown error" - }. Please open an issue on GitHub if the problem persists`, - }, - ], - isError: true, - }; - } + return await executeAccessibilityRAG(args, server, config); }, ); @@ -133,41 +428,79 @@ export default function addAccessibilityTools( { name: z.string().describe("Name of the accessibility scan"), pageURL: z.string().describe("The URL to scan for accessibility issues"), + authConfigId: z + .number() + .optional() + .describe("Optional auth config ID for authenticated scans"), }, async (args, context) => { - try { - 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: [ - { - type: "text", - text: `Failed to start accessibility scan: ${ - error instanceof Error ? error.message : "Unknown error" - }. Please open an issue on GitHub if the problem persists`, - isError: true, - }, - ], - isError: true, - }; - } + return await executeAccessibilityScan(args, context, server, config); + }, + ); + + tools.createAccessibilityAuthConfig = server.tool( + "createAccessibilityAuthConfig", + "Create an authentication configuration for accessibility scans. Supports both form-based and basic authentication.", + { + name: z.string().describe("Name for the auth configuration"), + type: z + .enum(["form", "basic"]) + .describe( + "Authentication type: 'form' for form-based auth, 'basic' for HTTP basic auth", + ), + url: z.string().describe("URL of the authentication page"), + username: z.string().describe("Username for authentication"), + password: z.string().describe("Password for authentication"), + usernameSelector: z + .string() + .optional() + .describe("CSS selector for username field (required for form auth)"), + passwordSelector: z + .string() + .optional() + .describe("CSS selector for password field (required for form auth)"), + submitSelector: z + .string() + .optional() + .describe("CSS selector for submit button (required for form auth)"), + }, + async (args) => { + return await executeCreateAuthConfig( + args as AuthConfigArgs, + server, + config, + ); + }, + ); + + tools.getAccessibilityAuthConfig = server.tool( + "getAccessibilityAuthConfig", + "Retrieve an existing authentication configuration by ID.", + { + configId: z.number().describe("ID of the auth configuration to retrieve"), + }, + async (args) => { + return await executeGetAuthConfig(args, server, config); + }, + ); + + tools.fetchAccessibilityIssues = server.tool( + "fetchAccessibilityIssues", + "Fetch accessibility issues from a completed scan with pagination support. Use cursor parameter to get subsequent pages of results.", + { + scanId: z + .string() + .describe("The scan ID from a completed accessibility scan"), + scanRunId: z + .string() + .describe("The scan run ID from a completed accessibility scan"), + cursor: z + .number() + .optional() + .describe("Character offset for pagination (default: 0)"), + }, + async (args) => { + return await executeFetchAccessibilityIssues(args, server, config); }, ); diff --git a/src/tools/accessiblity-utils/auth-config.ts b/src/tools/accessiblity-utils/auth-config.ts new file mode 100644 index 00000000..9ac64fc7 --- /dev/null +++ b/src/tools/accessiblity-utils/auth-config.ts @@ -0,0 +1,201 @@ +import { apiClient } from "../../lib/apiClient.js"; +import logger from "../../logger.js"; + +export interface AuthConfigResponse { + success: boolean; + data?: { + id: number; + name: string; + type: string; + username?: string; + password?: string; + url?: string; + usernameSelector?: string; + passwordSelector?: string; + submitSelector?: string; + }; + errors?: string[]; +} + +export interface FormAuthData { + username: string; + usernameSelector: string; + password: string; + passwordSelector: string; + submitSelector: string; + url: string; +} + +export interface BasicAuthData { + url: string; + username: string; + password: string; +} + +export class AccessibilityAuthConfig { + private auth: { username: string; password: string } | undefined; + + public setAuth(auth: { username: string; password: string }): void { + this.auth = auth; + } + + private transformLocalUrl(url: string): string { + try { + const parsed = new URL(url); + const localHosts = new Set(["127.0.0.1", "localhost", "0.0.0.0"]); + const BS_LOCAL_DOMAIN = "bs-local.com"; + + if (localHosts.has(parsed.hostname)) { + parsed.hostname = BS_LOCAL_DOMAIN; + return parsed.toString(); + } + return url; + } catch { + return url; + } + } + + async createFormAuthConfig( + name: string, + authData: FormAuthData, + ): Promise { + if (!this.auth?.username || !this.auth?.password) { + throw new Error( + "BrowserStack credentials are not set for AccessibilityAuthConfig.", + ); + } + + const transformedAuthData = { + ...authData, + url: this.transformLocalUrl(authData.url), + }; + + const requestBody = { + name, + type: "form", + authData: transformedAuthData, + }; + + try { + const response = await apiClient.post({ + url: "https://api-accessibility.browserstack.com/api/website-scanner/v1/auth_configs", + headers: { + Authorization: + "Basic " + + Buffer.from(`${this.auth.username}:${this.auth.password}`).toString( + "base64", + ), + "Content-Type": "application/json", + }, + body: requestBody, + }); + + const data = response.data; + logger.info(`The data returned from the API is: ${JSON.stringify(data)}`); + if (!data.success) { + throw new Error( + `Unable to create auth config: ${data.errors?.join(", ")}`, + ); + } + return data; + } catch (err: any) { + logger.error( + `Error creating form auth config: ${JSON.stringify(err?.response?.data)}`, + ); + const msg = + err?.response?.data?.error || + err?.response?.data?.message || + err?.message || + String(err); + throw new Error(`Failed to create form auth config: ${msg}`); + } + } + + async createBasicAuthConfig( + name: string, + authData: BasicAuthData, + ): Promise { + if (!this.auth?.username || !this.auth?.password) { + throw new Error( + "BrowserStack credentials are not set for AccessibilityAuthConfig.", + ); + } + + const transformedAuthData = { + ...authData, + url: this.transformLocalUrl(authData.url), + }; + + const requestBody = { + name, + type: "basic", + authData: transformedAuthData, + }; + + try { + const response = await apiClient.post({ + url: "https://api-accessibility.browserstack.com/api/website-scanner/v1/auth_configs", + 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 create auth config: ${data.errors?.join(", ")}`, + ); + } + return data; + } catch (err: any) { + const msg = + err?.response?.data?.error || + err?.response?.data?.message || + err?.message || + String(err); + throw new Error(`Failed to create basic auth config: ${msg}`); + } + } + + async getAuthConfig(configId: number): Promise { + if (!this.auth?.username || !this.auth?.password) { + throw new Error( + "BrowserStack credentials are not set for AccessibilityAuthConfig.", + ); + } + + try { + const response = await apiClient.get({ + url: `https://api-accessibility.browserstack.com/api/website-scanner/v1/auth_configs/${configId}`, + headers: { + Authorization: + "Basic " + + Buffer.from(`${this.auth.username}:${this.auth.password}`).toString( + "base64", + ), + }, + }); + + const data = response.data; + if (!data.success) { + throw new Error( + `Unable to get auth config: ${data.errors?.join(", ")}`, + ); + } + return data; + } catch (err: any) { + const msg = + err?.response?.data?.error || + err?.response?.data?.message || + err?.message || + String(err); + throw new Error(`Failed to get auth config: ${msg}`); + } + } +} diff --git a/src/tools/accessiblity-utils/scanner.ts b/src/tools/accessiblity-utils/scanner.ts index 640da0b8..f5e9c12f 100644 --- a/src/tools/accessiblity-utils/scanner.ts +++ b/src/tools/accessiblity-utils/scanner.ts @@ -30,6 +30,7 @@ export class AccessibilityScanner { async startScan( name: string, urlList: string[], + authConfigId?: number, ): Promise { if (!this.auth?.username || !this.auth?.password) { throw new Error( @@ -82,6 +83,7 @@ export class AccessibilityScanner { name, urlList: transformedUrlList, recurring: false, + ...(authConfigId && { authConfigId }), }; let requestBody = baseRequestBody; diff --git a/src/tools/add-percy-snapshots.ts b/src/tools/add-percy-snapshots.ts new file mode 100644 index 00000000..2642a582 --- /dev/null +++ b/src/tools/add-percy-snapshots.ts @@ -0,0 +1,40 @@ +import { storedPercyResults } from "../lib/inmemory-store.js"; +import { updateFileAndStep } from "./percy-snapshot-utils/utils.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { percyWebSetupInstructions } from "../tools/sdk-utils/percy-web/handler.js"; + +export async function updateTestsWithPercyCommands(args: { + index: number; +}): Promise { + const { index } = args; + const stored = storedPercyResults.get(); + if (!stored || !stored.testFiles) { + throw new Error( + `No test files found in memory. Please call listTestFiles first.`, + ); + } + + const fileStatusMap = stored.testFiles; + const filePaths = Object.keys(fileStatusMap); + + if (index < 0 || index >= filePaths.length) { + throw new Error( + `Invalid index: ${index}. There are ${filePaths.length} files available.`, + ); + } + + const result = await updateFileAndStep( + filePaths[index], + index, + filePaths.length, + percyWebSetupInstructions, + ); + + const updatedStored = { ...stored }; + updatedStored.testFiles[filePaths[index]] = true; // true = updated + storedPercyResults.set(updatedStored); + + return { + content: result, + }; +} diff --git a/src/tools/appautomate-utils/appium-sdk/config-generator.ts b/src/tools/appautomate-utils/appium-sdk/config-generator.ts new file mode 100644 index 00000000..959cf2d2 --- /dev/null +++ b/src/tools/appautomate-utils/appium-sdk/config-generator.ts @@ -0,0 +1,103 @@ +import { + APP_DEVICE_CONFIGS, + AppSDKSupportedTestingFrameworkEnum, + DEFAULT_APP_PATH, + createStep, +} from "./index.js"; +import { ValidatedEnvironment } from "../../sdk-utils/common/device-validator.js"; + +export function generateAppBrowserStackYMLInstructions( + config: { + validatedEnvironments?: ValidatedEnvironment[]; + platforms?: string[]; + testingFramework?: string; + projectName?: string; + }, + username: string, + accessKey: string, + appPath: string = DEFAULT_APP_PATH, +): string { + if ( + config.testingFramework === + AppSDKSupportedTestingFrameworkEnum.nightwatch || + config.testingFramework === + AppSDKSupportedTestingFrameworkEnum.webdriverio || + config.testingFramework === AppSDKSupportedTestingFrameworkEnum.cucumberRuby + ) { + return ""; + } + + const platformConfigs = generatePlatformConfigs(config); + + const projectName = config.projectName || "BrowserStack Sample"; + const buildName = config.projectName + ? `${config.projectName}-AppAutomate-Build` + : "bstack-demo"; + + const configContent = `\`\`\`yaml +userName: ${username} +accessKey: ${accessKey} +app: ${appPath} +platforms: +${platformConfigs} +parallelsPerPlatform: 1 +browserstackLocal: true +// TODO: replace projectName and buildName according to actual project +projectName: ${projectName} +buildName: ${buildName} +debug: true +networkLogs: true +percy: false +percyCaptureMode: auto +accessibility: false +\`\`\` + +**Important notes:** +- Replace \`app: ${appPath}\` with the path to your actual app file (e.g., \`./SampleApp.apk\` for Android or \`./SampleApp.ipa\` for iOS) +- You can upload your app using BrowserStack's App Upload API or manually through the dashboard +- Set \`browserstackLocal: true\` if you need to test with local/staging servers +- Adjust \`parallelsPerPlatform\` based on your subscription limits`; + + const stepTitle = + "Update browserstack.yml file with App Automate configuration:"; + + const stepDescription = `Create or update the browserstack.yml file in your project root with the following content: + ${configContent}`; + + return createStep(stepTitle, stepDescription); +} + +function generatePlatformConfigs(config: { + validatedEnvironments?: ValidatedEnvironment[]; + platforms?: string[]; +}): string { + if (config.validatedEnvironments && config.validatedEnvironments.length > 0) { + return config.validatedEnvironments + .filter((env) => env.platform === "android" || env.platform === "ios") + .map((env) => { + return ` - platformName: ${env.platform} + deviceName: "${env.deviceName}" + platformVersion: "${env.osVersion}"`; + }) + .join("\n"); + } else if (config.platforms && config.platforms.length > 0) { + return config.platforms + .map((platform) => { + const devices = + APP_DEVICE_CONFIGS[platform as keyof typeof APP_DEVICE_CONFIGS]; + if (!devices) return ""; + + return devices + .map( + (device) => ` - platformName: ${platform} + deviceName: ${device.deviceName} + platformVersion: "${device.platformVersion}"`, + ) + .join("\n"); + }) + .filter(Boolean) + .join("\n"); + } + + return ""; +} diff --git a/src/tools/appautomate-utils/appium-sdk/constants.ts b/src/tools/appautomate-utils/appium-sdk/constants.ts new file mode 100644 index 00000000..5b9a2494 --- /dev/null +++ b/src/tools/appautomate-utils/appium-sdk/constants.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; +import { + AppSDKSupportedFrameworkEnum, + AppSDKSupportedTestingFrameworkEnum, + AppSDKSupportedLanguageEnum, + AppSDKSupportedPlatformEnum, +} from "./index.js"; + +// App Automate specific device configurations +export const APP_DEVICE_CONFIGS = { + android: [ + { deviceName: "Samsung Galaxy S22 Ultra", platformVersion: "12.0" }, + { deviceName: "Google Pixel 7 Pro", platformVersion: "13.0" }, + { deviceName: "OnePlus 9", platformVersion: "11.0" }, + ], + ios: [ + { deviceName: "iPhone 14", platformVersion: "16" }, + { deviceName: "iPhone 13", platformVersion: "15" }, + { deviceName: "iPad Air 4", platformVersion: "14" }, + ], +}; + +// Step delimiter for parsing instructions +export const STEP_DELIMITER = "---STEP---"; + +// Default app path for examples +export const DEFAULT_APP_PATH = "bs://sample.app"; + +// Tool description and schema for setupBrowserStackAppAutomateTests +export const SETUP_APP_AUTOMATE_DESCRIPTION = + "Set up BrowserStack App Automate SDK integration for Appium-based mobile app testing. ONLY for Appium based framework . This tool configures SDK for various languages with appium. For pre-built Espresso or XCUITest test suites, use 'runAppTestsOnBrowserStack' instead."; + +export const SETUP_APP_AUTOMATE_SCHEMA = { + detectedFramework: z + .nativeEnum(AppSDKSupportedFrameworkEnum) + .describe( + "The mobile automation framework configured in the project. Example: 'appium'", + ), + + detectedTestingFramework: z + .nativeEnum(AppSDKSupportedTestingFrameworkEnum) + .describe( + "The testing framework used in the project. Be precise with framework selection Example: 'testng', 'behave', 'pytest', 'robot'", + ), + + detectedLanguage: z + .nativeEnum(AppSDKSupportedLanguageEnum) + .describe( + "The programming language used in the project. Supports Java and C#. Example: 'java', 'csharp'", + ), + + devices: z + .array( + z.union([ + // Android: [android, deviceName, osVersion] + z.tuple([ + z + .literal(AppSDKSupportedPlatformEnum.android) + .describe("Platform identifier: 'android'"), + z + .string() + .describe( + "Device name, e.g. 'Samsung Galaxy S24', 'Google Pixel 8'", + ), + z.string().describe("Android version, e.g. '14', '16', 'latest'"), + ]), + // iOS: [ios, deviceName, osVersion] + z.tuple([ + z + .literal(AppSDKSupportedPlatformEnum.ios) + .describe("Platform identifier: 'ios'"), + z.string().describe("Device name, e.g. 'iPhone 15', 'iPhone 14 Pro'"), + z.string().describe("iOS version, e.g. '17', '16', 'latest'"), + ]), + ]), + ) + .max(3) + .default([]) + .describe( + "Tuples describing target mobile devices. Add device only when user asks explicitly for it. Defaults to [] . Example: [['android', 'Samsung Galaxy S24', '14'], ['ios', 'iPhone 15', '17']]", + ), + + appPath: z + .string() + .describe( + "Path to the mobile app file (.apk for Android, .ipa for iOS). Can be a local file path or a BrowserStack app URL (bs://)", + ), + project: z + .string() + .optional() + .default("BStack-AppAutomate-Suite") + .describe("Project name for organizing test runs on BrowserStack."), +}; diff --git a/src/tools/appautomate-utils/appium-sdk/formatter.ts b/src/tools/appautomate-utils/appium-sdk/formatter.ts new file mode 100644 index 00000000..0a9d1a6c --- /dev/null +++ b/src/tools/appautomate-utils/appium-sdk/formatter.ts @@ -0,0 +1,87 @@ +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { STEP_DELIMITER } from "./constants.js"; + +export function formatFinalAppInstructions( + formattedInstructions: string, +): CallToolResult { + const fullInstructions = ` +⚠️ IMPORTANT: DO NOT SKIP ANY STEP +All the setup steps described in this file MUST be executed regardless of any existing configuration or setup. +This ensures proper BrowserStack App Automate SDK setup. +Each step is compulsory and sequence needs to be maintained. + +${formattedInstructions}`; + + return { + content: [ + { + type: "text", + text: fullInstructions, + isError: false, + }, + ], + }; +} + +export function createStep(title: string, content: string): string { + return `${STEP_DELIMITER} +${title} + +${content}`; +} + +export function combineInstructions(...instructionParts: string[]): string { + return instructionParts.filter(Boolean).join("\n\n"); +} + +export function formatEnvCommands( + username: string, + accessKey: string, + isWindows: boolean, +): string { + if (isWindows) { + return `\`\`\`cmd +setx BROWSERSTACK_USERNAME "${username}" +setx BROWSERSTACK_ACCESS_KEY "${accessKey}" +\`\`\``; + } + return `\`\`\`bash +export BROWSERSTACK_USERNAME=${username} +export BROWSERSTACK_ACCESS_KEY=${accessKey} +\`\`\``; +} + +export function createEnvStep( + username: string, + accessKey: string, + isWindows: boolean, + platformLabel: string, + title: string = "Set BrowserStack credentials as environment variables:", +): string { + return createStep( + title, + `**${platformLabel}:** +${formatEnvCommands(username, accessKey, isWindows)}`, + ); +} + +export function formatMultiLineCommand( + command: string, + isWindows: boolean = process.platform === "win32", +): string { + if (isWindows) { + // For Windows, keep commands on single line + return command.replace(/\s*\\\s*\n\s*/g, " "); + } + return command; +} + +export function formatAppInstructionsWithNumbers(instructions: string): string { + const steps = instructions + .split(STEP_DELIMITER) + .filter((step) => step.trim()); + + return steps + .map((step, index) => `**Step ${index + 1}:**\n${step.trim()}`) + .join("\n\n"); +} diff --git a/src/tools/appautomate-utils/appium-sdk/handler.ts b/src/tools/appautomate-utils/appium-sdk/handler.ts new file mode 100644 index 00000000..0a24358a --- /dev/null +++ b/src/tools/appautomate-utils/appium-sdk/handler.ts @@ -0,0 +1,127 @@ +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { getBrowserStackAuth } from "../../../lib/get-auth.js"; +import { validateAppAutomateDevices } from "../../sdk-utils/common/device-validator.js"; + +import { + getAppUploadInstruction, + validateSupportforAppAutomate, + SupportedFramework, +} from "./utils.js"; + +import { + getAppSDKPrefixCommand, + generateAppBrowserStackYMLInstructions, +} from "./index.js"; + +import { + AppSDKSupportedLanguage, + AppSDKSupportedTestingFramework, + AppSDKInstruction, + formatAppInstructionsWithNumbers, + getAppInstructionsForProjectConfiguration, + SETUP_APP_AUTOMATE_SCHEMA, +} from "./index.js"; + +export async function setupAppAutomateHandler( + rawInput: unknown, + config: BrowserStackConfig, +): Promise { + const input = z.object(SETUP_APP_AUTOMATE_SCHEMA).parse(rawInput); + const auth = getBrowserStackAuth(config); + const [username, accessKey] = auth.split(":"); + + const instructions: AppSDKInstruction[] = []; + + // Use variables for all major input properties + const testingFramework = + input.detectedTestingFramework as AppSDKSupportedTestingFramework; + const language = input.detectedLanguage as AppSDKSupportedLanguage; + const inputDevices = (input.devices as Array>) ?? []; + const appPath = input.appPath as string; + const framework = input.detectedFramework as SupportedFramework; + + //Validating if supported framework or not + validateSupportforAppAutomate(framework, language, testingFramework); + + // Use default mobile devices when array is empty + const devices = + inputDevices.length === 0 + ? [["android", "Samsung Galaxy S24", "latest"]] + : inputDevices; + + // Validate devices against real BrowserStack device data + const validatedEnvironments = await validateAppAutomateDevices(devices); + + // Extract platforms for backward compatibility (if needed) + const platforms = validatedEnvironments.map((env) => env.platform); + + // Step 1: Generate SDK setup command + const sdkCommand = getAppSDKPrefixCommand( + language, + testingFramework, + username, + accessKey, + appPath, + ); + + if (sdkCommand) { + instructions.push({ content: sdkCommand, type: "setup" }); + } + + // Step 2: Generate browserstack.yml configuration + const configInstructions = generateAppBrowserStackYMLInstructions( + { + validatedEnvironments, + platforms, + testingFramework, + projectName: input.project as string, + }, + username, + accessKey, + appPath, + ); + + if (configInstructions) { + instructions.push({ content: configInstructions, type: "config" }); + } + + // Step 3: Generate app upload instruction + const appUploadInstruction = await getAppUploadInstruction( + appPath, + username, + accessKey, + testingFramework, + ); + + if (appUploadInstruction) { + instructions.push({ content: appUploadInstruction, type: "setup" }); + } + + // Step 4: Generate project configuration and run instructions + const projectInstructions = getAppInstructionsForProjectConfiguration( + framework, + testingFramework, + language, + ); + + if (projectInstructions) { + instructions.push({ content: projectInstructions, type: "run" }); + } + + const combinedInstructions = instructions + .map((instruction) => instruction.content) + .join("\n\n"); + + return { + content: [ + { + type: "text", + text: formatAppInstructionsWithNumbers(combinedInstructions), + isError: false, + }, + ], + isError: false, + }; +} diff --git a/src/tools/appautomate-utils/appium-sdk/index.ts b/src/tools/appautomate-utils/appium-sdk/index.ts new file mode 100644 index 00000000..f9c4a29b --- /dev/null +++ b/src/tools/appautomate-utils/appium-sdk/index.ts @@ -0,0 +1,12 @@ +// Barrel exports for App BrowserStack module +export { + getAppSDKPrefixCommand, + getAppInstructionsForProjectConfiguration, +} from "./instructions.js"; +export { generateAppBrowserStackYMLInstructions } from "./config-generator.js"; + +export * from "./types.js"; +export * from "./constants.js"; +export * from "./utils.js"; +export * from "./instructions.js"; +export * from "./formatter.js"; diff --git a/src/tools/appautomate-utils/appium-sdk/instructions.ts b/src/tools/appautomate-utils/appium-sdk/instructions.ts new file mode 100644 index 00000000..b1d73d4c --- /dev/null +++ b/src/tools/appautomate-utils/appium-sdk/instructions.ts @@ -0,0 +1,66 @@ +import { + AppSDKSupportedLanguage, + AppSDKSupportedTestingFramework, +} from "./index.js"; + +// Language-specific instruction imports +import { getJavaAppInstructions } from "./languages/java.js"; +import { getCSharpAppInstructions } from "./languages/csharp.js"; +import { getNodejsAppInstructions } from "./languages/nodejs.js"; +import { getPythonAppInstructions } from "./languages/python.js"; +import { getRubyAppInstructions } from "./languages/ruby.js"; + +// Language-specific command imports +import { getCSharpSDKCommand } from "./languages/csharp.js"; +import { getJavaSDKCommand } from "./languages/java.js"; +import { getNodejsSDKCommand } from "./languages/nodejs.js"; +import { getPythonSDKCommand } from "./languages/python.js"; +import { getRubySDKCommand } from "./languages/ruby.js"; + +export function getAppInstructionsForProjectConfiguration( + framework: string, + testingFramework: AppSDKSupportedTestingFramework, + language: AppSDKSupportedLanguage, +): string { + if (!framework || !testingFramework || !language) { + return ""; + } + + switch (language) { + case "java": + return getJavaAppInstructions(); + case "nodejs": + return getNodejsAppInstructions(testingFramework); + case "python": + return getPythonAppInstructions(testingFramework); + case "ruby": + return getRubyAppInstructions(); + case "csharp": + return getCSharpAppInstructions(); + default: + return ""; + } +} + +export function getAppSDKPrefixCommand( + language: AppSDKSupportedLanguage, + testingFramework: string, + username: string, + accessKey: string, + appPath?: string, +): string { + switch (language) { + case "csharp": + return getCSharpSDKCommand(username, accessKey); + case "java": + return getJavaSDKCommand(testingFramework, username, accessKey, appPath); + case "nodejs": + return getNodejsSDKCommand(testingFramework, username, accessKey); + case "python": + return getPythonSDKCommand(testingFramework, username, accessKey); + case "ruby": + return getRubySDKCommand(testingFramework, username, accessKey); + default: + return ""; + } +} diff --git a/src/tools/appautomate-utils/appium-sdk/languages/csharp.ts b/src/tools/appautomate-utils/appium-sdk/languages/csharp.ts new file mode 100644 index 00000000..11caa233 --- /dev/null +++ b/src/tools/appautomate-utils/appium-sdk/languages/csharp.ts @@ -0,0 +1,112 @@ +// C# instructions and commands for App SDK utilities +import { + PLATFORM_UTILS, + createStep, + createEnvStep, + combineInstructions, +} from "../index.js"; + +export function getCSharpAppInstructions(): string { + const { isWindows, isAppleSilicon, getPlatformLabel } = PLATFORM_UTILS; + + let runCommand = ""; + if (isWindows) { + runCommand = `\`\`\`cmd +dotnet build +dotnet test --filter [other_args] +\`\`\``; + } else if (isAppleSilicon) { + runCommand = `\`\`\`bash +dotnet build +dotnet test --filter [other_args] +\`\`\` + +**Did not set the alias?** +Use the absolute path to the dotnet installation to run your tests on Mac computers with Apple silicon chips: +\`\`\`bash +/dotnet test +\`\`\``; + } else { + runCommand = `\`\`\`bash +dotnet build +dotnet test --filter [other_args] +\`\`\``; + } + + const runStep = createStep( + "Run your C# test suite:", + `**${getPlatformLabel()}:** +${runCommand} + +**Debug Guidelines:** +If you encounter the error: java.lang.IllegalArgumentException: Multiple entries with the same key, +__Resolution:__ +- The app capability should only be set in one place: browserstack.yml. +- Remove or comment out any code or configuration in your test setup (e.g., step definitions, runners, or capabilities setup) that sets the app path directly.`, + ); + + return runStep; +} + +export function getCSharpSDKCommand( + username: string, + accessKey: string, +): string { + const { + isWindows = false, + isAppleSilicon = false, + getPlatformLabel = () => "Unknown", + } = PLATFORM_UTILS || {}; + if (!PLATFORM_UTILS) { + console.warn("PLATFORM_UTILS is undefined. Defaulting platform values."); + } + + const envStep = createEnvStep( + username, + accessKey, + isWindows, + getPlatformLabel(), + ); + + const installCommands = isWindows + ? `\`\`\`cmd +dotnet add package BrowserStack.TestAdapter +dotnet build +dotnet browserstack-sdk setup --userName "${username}" --accessKey "${accessKey}" +\`\`\`` + : `\`\`\`bash +dotnet add package BrowserStack.TestAdapter +dotnet build +dotnet browserstack-sdk setup --userName "${username}" --accessKey "${accessKey}" +\`\`\``; + + const installStep = createStep( + "Install BrowserStack SDK", + `Run the following command to install the BrowserStack SDK and create a browserstack.yml file in the root directory of your project: + +**${getPlatformLabel()}:** +${installCommands}`, + ); + + const appleSiliconNote = isAppleSilicon + ? createStep( + "[Only for Macs with Apple silicon] Install dotnet x64 on MacOS", + `If you are using a Mac computer with Apple silicon chip (M1 or M2) architecture, use the given command: + +\`\`\`bash +cd #(project folder Android or iOS) +dotnet browserstack-sdk setup-dotnet --dotnet-path "" --dotnet-version "" +\`\`\` + +- \`\` - Mention the absolute path to the directory where you want to save dotnet x64 +- \`\` - Mention the dotnet version which you want to use to run tests + +This command performs the following functions: +- Installs dotnet x64 +- Installs the required version of dotnet x64 at an appropriate path +- Sets alias for the dotnet installation location on confirmation (enter y option)`, + ) + : ""; + + return combineInstructions(envStep, installStep, appleSiliconNote); +} diff --git a/src/tools/appautomate-utils/appium-sdk/languages/java.ts b/src/tools/appautomate-utils/appium-sdk/languages/java.ts new file mode 100644 index 00000000..96dc57fa --- /dev/null +++ b/src/tools/appautomate-utils/appium-sdk/languages/java.ts @@ -0,0 +1,197 @@ +// Java instructions and commands for App SDK utilities +import { + createStep, + combineInstructions, + createEnvStep, + PLATFORM_UTILS, +} from "../index.js"; + +// Java-specific constants and mappings +export const MAVEN_ARCHETYPE_GROUP_ID = "com.browserstack"; +export const MAVEN_ARCHETYPE_ARTIFACT_ID = "junit-archetype-integrate"; +export const MAVEN_ARCHETYPE_VERSION = "1.0"; + +// Version mapping for different frameworks +export const JAVA_APP_FRAMEWORK_VERSION_MAP: Record = { + testng: "1.4", + selenide: "1.4", + junit5: "1.0", + junit4: "1.0", + jbehave: "1.0", + cucumberTestng: "1.0", + cucumberJunit4: "1.0", + cucumberJunit5: "1.0", + cucumber: "1.0", + serenity: "1.0", +}; + +// Framework mapping for Java Maven archetype generation for App Automate +export const JAVA_APP_FRAMEWORK_MAP: Record = { + testng: "testng-archetype-integrate", + junit5: "browserstack-sdk-archetype-integrate", + selenide: "selenide-archetype-integrate", + jbehave: "browserstack-sdk-archetype-integrate", + junit4: "browserstack-sdk-archetype-integrate", + cucumberTestng: "browserstack-sdk-archetype-integrate", + cucumberJunit4: "browserstack-sdk-archetype-integrate", + cucumberJunit5: "browserstack-sdk-archetype-integrate", + cucumber: "browserstack-sdk-archetype-integrate", + serenity: "browserstack-sdk-archetype-integrate", +}; + +// Common Gradle setup instructions for App Automate (platform-independent) +export const GRADLE_APP_SETUP_INSTRUCTIONS = ` +**For Gradle setup:** +1. Add browserstack-java-sdk to dependencies: + compileOnly 'com.browserstack:browserstack-java-sdk:latest.release' + +2. Add browserstackSDK path variable: + def browserstackSDKArtifact = configurations.compileClasspath.resolvedConfiguration.resolvedArtifacts.find { it.name == 'browserstack-java-sdk' } + +3. Add javaagent to gradle tasks: + jvmArgs "-javaagent:\${browserstackSDKArtifact.file}" +`; + +export function getJavaAppInstructions(): string { + const baseRunStep = createStep( + "Run your App Automate test suite:", + `\`\`\`bash +mvn test +\`\`\``, + ); + return baseRunStep; +} + +export function getJavaAppFrameworkForMaven(framework: string): string { + return JAVA_APP_FRAMEWORK_MAP[framework] || framework; +} + +export function getJavaAppFrameworkVersion(framework: string): string { + return JAVA_APP_FRAMEWORK_VERSION_MAP[framework] || MAVEN_ARCHETYPE_VERSION; +} + +function getMavenCommandForWindows( + framework: string, + mavenFramework: string, + version: string, + username: string, + accessKey: string, + appPath?: string, +): string { + let command = + `mvn archetype:generate -B ` + + `-DarchetypeGroupId="${MAVEN_ARCHETYPE_GROUP_ID}" ` + + `-DarchetypeArtifactId="${mavenFramework}" ` + + `-DarchetypeVersion="${version}" ` + + `-DgroupId="${MAVEN_ARCHETYPE_GROUP_ID}" ` + + `-DartifactId="${mavenFramework}" ` + + `-Dversion="${version}" ` + + `-DBROWSERSTACK_USERNAME="${username}" ` + + `-DBROWSERSTACK_ACCESS_KEY="${accessKey}"`; + + // Add framework parameter for browserstack-sdk-archetype-integrate + if (mavenFramework === "browserstack-sdk-archetype-integrate") { + command += ` -DBROWSERSTACK_FRAMEWORK="${framework}"`; + } + + // Add app path if provided + if (appPath) { + command += ` -DBROWSERSTACK_APP="${appPath}"`; + } + + return command; +} + +function getMavenCommandForUnix( + framework: string, + mavenFramework: string, + version: string, + username: string, + accessKey: string, + appPath?: string, +): string { + let command = + `mvn archetype:generate -B ` + + `-DarchetypeGroupId="${MAVEN_ARCHETYPE_GROUP_ID}" ` + + `-DarchetypeArtifactId="${mavenFramework}" ` + + `-DarchetypeVersion="${version}" ` + + `-DgroupId="${MAVEN_ARCHETYPE_GROUP_ID}" ` + + `-DartifactId="${mavenFramework}" ` + + `-Dversion="${version}" ` + + `-DBROWSERSTACK_USERNAME="${username}" ` + + `-DBROWSERSTACK_ACCESS_KEY="${accessKey}"`; + + // Add framework parameter for browserstack-sdk-archetype-integrate + if (mavenFramework === "browserstack-sdk-archetype-integrate") { + command += ` -DBROWSERSTACK_FRAMEWORK="${framework}"`; + } + + // Add app path if provided + if (appPath) { + command += ` -DBROWSERSTACK_APP="${appPath}"`; + } + + return command; +} + +export function getJavaSDKCommand( + framework: string, + username: string, + accessKey: string, + appPath?: string, +): string { + const { isWindows = false, getPlatformLabel } = PLATFORM_UTILS || {}; + + const mavenFramework = getJavaAppFrameworkForMaven(framework); + const version = getJavaAppFrameworkVersion(framework); + + let mavenCommand: string; + + if (isWindows) { + mavenCommand = getMavenCommandForWindows( + framework, + mavenFramework, + version, + username, + accessKey, + appPath, + ); + } else { + mavenCommand = getMavenCommandForUnix( + framework, + mavenFramework, + version, + username, + accessKey, + appPath, + ); + } + + const envStep = createEnvStep( + username, + accessKey, + isWindows, + getPlatformLabel(), + ); + + const mavenStep = createStep( + "Install BrowserStack SDK using Maven Archetype for App Automate", + `Maven command for ${framework} (${getPlatformLabel()}): + \`\`\`bash + ${mavenCommand} + \`\`\` + + Alternative setup for Gradle users: + ${GRADLE_APP_SETUP_INSTRUCTIONS}`, + ); + + const argsLineStep = createStep( + "Verifying dependency and argsLine", + `Verify browserstack-java-sdk with LATEST is added as dependency and add this line in pom.xml if not added: + \`\`\`xml + -javaagent:"\${com.browserstack:browserstack-java-sdk:jar}" + \`\`\``, + ); + + return combineInstructions(envStep, mavenStep, argsLineStep); +} diff --git a/src/tools/appautomate-utils/appium-sdk/languages/nodejs.ts b/src/tools/appautomate-utils/appium-sdk/languages/nodejs.ts new file mode 100644 index 00000000..fe4cff3d --- /dev/null +++ b/src/tools/appautomate-utils/appium-sdk/languages/nodejs.ts @@ -0,0 +1,289 @@ +// Node.js instructions and commands for App SDK utilities +import { + AppSDKSupportedTestingFramework, + AppSDKSupportedTestingFrameworkEnum, + createStep, + combineInstructions, +} from "../index.js"; + +export function getNodejsSDKCommand( + testingFramework: string, + username: string, + accessKey: string, +): string { + switch (testingFramework) { + case "webdriverio": + return getWebDriverIOCommand(username, accessKey); + case "nightwatch": + return getNightwatchCommand(username, accessKey); + case "jest": + return getJestCommand(username, accessKey); + case "mocha": + return getMochaCommand(username, accessKey); + case "cucumberJs": + return getCucumberJSCommand(username, accessKey); + default: + return ""; + } +} + +export function getNodejsAppInstructions( + testingFramework: AppSDKSupportedTestingFramework, +): string { + switch (testingFramework) { + case AppSDKSupportedTestingFrameworkEnum.webdriverio: + return createStep( + "Run your WebdriverIO test suite:", + "Your test suite is now ready to run on BrowserStack. Use the commands defined in your package.json file to run the tests", + ); + case AppSDKSupportedTestingFrameworkEnum.nightwatch: + return createStep( + "Run your App Automate test suite:", + `For Android: + \`\`\`bash + npx nightwatch --env browserstack.android + \`\`\` + For iOS: + \`\`\`bash + npx nightwatch --env browserstack.ios + \`\`\``, + ); + case AppSDKSupportedTestingFrameworkEnum.jest: + return createStep( + "Run your Jest test suite with BrowserStack SDK:", + `Use the npm script defined in your package.json. For example:\n\n\`\`\`bash\nnpx run browserstack-node-sdk jest specs/single_test.js\n\`\`\``, + ); + case AppSDKSupportedTestingFrameworkEnum.mocha: + return createStep( + "Run your Mocha test suite with BrowserStack SDK:", + `Use the npm script defined in your package.json. For example:\n\n\`\`\`bash\nnpx run browserstack-node-sdk mocha specs/single_test.js\n\`\`\``, + ); + + case AppSDKSupportedTestingFrameworkEnum.cucumberJs: + return createStep( + "Run your Cucumber JS test suite with BrowserStack SDK:", + `Use the npm script defined in your package.json. For example:\n\n\`\`\`bash\nnpx run browserstack-node-sdk cucumber-js specs/single_test.js\n\`\`\``, + ); + default: + return ""; + } +} + +function getWebDriverIOCommand(username: string, accessKey: string): string { + const prerequisiteStep = createStep( + "Prerequisite Setup:", + `a. Ensure you do not modify or replace any existing local driver code, + as it will be automatically managed and overwritten by the BrowserStack SDK/Driver. + b. Do not create any YML file in this integration as it is not required. + c. Ensure you create the WDIO config file as per the instructions below.`, + ); + + const envStep = createStep( + "Set your BrowserStack credentials as environment variables:", + `\`\`\`bash +export BROWSERSTACK_USERNAME=${username} +export BROWSERSTACK_ACCESS_KEY=${accessKey} +\`\`\``, + ); + + const installStep = createStep( + "Install BrowserStack WDIO service:", + `\`\`\`bash +npm install @wdio/browserstack-service@^7 --save-dev +\`\`\``, + ); + + const configStep = createStep( + "Update your WebdriverIO config file (e.g., \\`wdio.conf.js\\`) to add the BrowserStack service and capabilities:", + `\`\`\`js +exports.config = { + user: process.env.BROWSERSTACK_USERNAME || '${username}', + key: process.env.BROWSERSTACK_ACCESS_KEY || '${accessKey}', + hostname: 'hub.browserstack.com', + services: [ + [ + 'browserstack', + { + app: 'bs://sample.app', + browserstackLocal: true, + accessibility: false, + testObservabilityOptions: { + buildName: "bstack-demo", + projectName: "BrowserStack Sample", + buildTag: 'Any build tag goes here. For e.g. ["Tag1","Tag2"]' + }, + }, + ] + ], + capabilities: [{ + 'bstack:options': { + deviceName: 'Samsung Galaxy S22 Ultra', + platformVersion: '12.0', + platformName: 'android', + } + }], + commonCapabilities: { + 'bstack:options': { + debug: true, + networkLogs: true, + percy: false, + percyCaptureMode: 'auto' + } + }, + maxInstances: 10, + // ...other config +}; +\`\`\``, + ); + + return combineInstructions( + prerequisiteStep, + envStep, + installStep, + configStep, + ); +} + +function getNightwatchCommand(username: string, accessKey: string): string { + const prerequisiteStep = createStep( + "Prerequisite Setup:", + ` a. Ensure you do not modify or replace any existing local driver code, + as it will be automatically managed and overwritten by the BrowserStack SDK/Driver. + b. Do not create any YML file in this integration as it is not required. + c. Ensure you create the WDIO config file as per the instructions below.`, + ); + + const envStep = createStep( + "Set your BrowserStack credentials as environment variables:", + `\`\`\`bash +export BROWSERSTACK_USERNAME=${username} +export BROWSERSTACK_ACCESS_KEY=${accessKey} +\`\`\``, + ); + + const installStep = createStep( + "Install Nightwatch and BrowserStack integration:", + `\`\`\`bash +npm install --save-dev @nightwatch/browserstack +\`\`\``, + ); + + const configStep = createStep( + "Update your Nightwatch config file (e.g., \\`nightwatch.conf.js\\`) to add the BrowserStack settings and capabilities:", + `\`\`\`js + + test_settings:{ + ... + browserstack: { + selenium: { + host: 'hub.browserstack.com', + port: 443 + }, + desiredCapabilities: { + 'bstack:options': { + userName: '', + accessKey: '', + appiumVersion: '2.0.0' + } + }, + disable_error_log: false, + webdriver: { + timeout_options: { + timeout: 60000, + retry_attempts: 3 + }, + keep_alive: true, + start_process: false + } + }, + 'browserstack.android': { + extends: 'browserstack', + 'desiredCapabilities': { + browserName: null, + 'appium:options': { + automationName: 'UiAutomator2', + app: 'wikipedia-sample-app',// custom-id of the uploaded app + appPackage: 'org.wikipedia', + appActivity: 'org.wikipedia.main.MainActivity', + appWaitActivity: 'org.wikipedia.onboarding.InitialOnboardingActivity', + platformVersion: '11.0', + deviceName: 'Google Pixel 5' + }, + appUploadUrl: 'https://raw.githubusercontent.com/priyansh3133/wikipedia/main/wikipedia.apk',// URL of the app to be uploaded to BrowserStack before starting the test + // appUploadPath: '/path/to/app_name.apk' // if the app needs to be uploaded to BrowserStack from a local system + } + }, + 'browserstack.ios': { + extends: 'browserstack', + 'desiredCapabilities': { + browserName: null, + platformName: 'ios', + 'appium:options': { + automationName: 'XCUITest', + app: 'BStackSampleApp', + platformVersion: '16', + deviceName: 'iPhone 14' + }, + appUploadUrl: 'https://www.browserstack.com/app-automate/sample-apps/ios/BStackSampleApp.ipa', + // appUploadPath: '/path/to/app_name.ipa' + } + ... + } +\`\`\``, + ); + + return combineInstructions( + prerequisiteStep, + envStep, + installStep, + configStep, + ); +} + +function getJestCommand(username: string, accessKey: string): string { + const envStep = createStep( + "Set your BrowserStack credentials as environment variables:", + `\`\`\`bash +export BROWSERSTACK_USERNAME=${username} +export BROWSERSTACK_ACCESS_KEY=${accessKey} +\`\`\``, + ); + + const installStep = createStep( + "Install Jest and BrowserStack SDK:", + `\`\`\`bash +npm install --save-dev browserstack-node-sdk +\`\`\``, + ); + + return combineInstructions(envStep, installStep); +} + +function getMochaCommand(username: string, accessKey: string): string { + const envStep = createStep( + "Set your BrowserStack credentials as environment variables:", + `\`\`\`bash +export BROWSERSTACK_USERNAME=${username} +export BROWSERSTACK_ACCESS_KEY=${accessKey} +\`\`\``, + ); + + const installStep = createStep( + "Install Mocha and BrowserStack SDK:", + `\`\`\`bash +npm install --save-dev browserstack-node-sdk +\`\`\``, + ); + + return combineInstructions(envStep, installStep); +} + +function getCucumberJSCommand(username: string, accessKey: string): string { + return createStep( + "Set your BrowserStack credentials as environment variables:", + `\`\`\`bash +export BROWSERSTACK_USERNAME=${username} +export BROWSERSTACK_ACCESS_KEY=${accessKey} +\`\`\``, + ); +} diff --git a/src/tools/appautomate-utils/appium-sdk/languages/python.ts b/src/tools/appautomate-utils/appium-sdk/languages/python.ts new file mode 100644 index 00000000..a54a3ae2 --- /dev/null +++ b/src/tools/appautomate-utils/appium-sdk/languages/python.ts @@ -0,0 +1,156 @@ +// Python instructions and commands for App SDK utilities +import { + AppSDKSupportedTestingFramework, + AppSDKSupportedTestingFrameworkEnum, + createStep, + createEnvStep, + combineInstructions, + PLATFORM_UTILS, +} from "../index.js"; + +export function getPythonAppInstructions( + testingFramework: AppSDKSupportedTestingFramework, +): string { + switch (testingFramework) { + case AppSDKSupportedTestingFrameworkEnum.robot: + return createStep( + "Run your App Automate test suite with Robot Framework:", + `\`\`\`bash +browserstack-sdk robot +\`\`\``, + ); + case AppSDKSupportedTestingFrameworkEnum.pytest: + return createStep( + "Run your App Automate test suite with Pytest:", + `\`\`\`bash +browserstack-sdk pytest -s +\`\`\``, + ); + case AppSDKSupportedTestingFrameworkEnum.behave: + return createStep( + "Run your App Automate test suite with Behave:", + `\`\`\`bash +browserstack-sdk behave +\`\`\``, + ); + case AppSDKSupportedTestingFrameworkEnum.lettuce: + return createStep( + "Run your test with Lettuce:", + `\`\`\`bash +# Run using paver +paver run first_test +\`\`\``, + ); + default: + return ""; + } +} + +export function getPythonSDKCommand( + framework: string, + username: string, + accessKey: string, +): string { + const { isWindows, getPlatformLabel } = PLATFORM_UTILS; + + switch (framework) { + case "robot": + case "pytest": + case "behave": + return getPythonCommonSDKCommand( + username, + accessKey, + isWindows, + getPlatformLabel(), + ); + case "lettuce": + return getLettuceCommand( + username, + accessKey, + isWindows, + getPlatformLabel(), + ); + default: + return ""; + } +} + +function getPythonCommonSDKCommand( + username: string, + accessKey: string, + isWindows: boolean, + platformLabel: string, +): string { + const envStep = createEnvStep( + username, + accessKey, + isWindows, + platformLabel, + "Set your BrowserStack credentials as environment variables:", + ); + + const installStep = createStep( + "Install BrowserStack Python SDK:", + `\`\`\`bash +python3 -m pip install browserstack-sdk +\`\`\``, + ); + + const setupStep = createStep( + "Set up BrowserStack SDK:", + `\`\`\`bash +browserstack-sdk setup --username "${username}" --key "${accessKey}" +\`\`\``, + ); + + return combineInstructions(envStep, installStep, setupStep); +} + +function getLettuceCommand( + username: string, + accessKey: string, + isWindows: boolean, + platformLabel: string, +): string { + const envStep = createEnvStep( + username, + accessKey, + isWindows, + platformLabel, + "Set your BrowserStack credentials as environment variables:", + ); + + const configStep = createStep( + "Configure Appium's desired capabilities in config.json:", + `**Android example:** +\`\`\`json +{ + "capabilities": { + "browserstack.user" : "${username}", + "browserstack.key" : "${accessKey}", + "project": "First Lettuce Android Project", + "build": "Lettuce Android", + "name": "first_test", + "browserstack.debug": true, + "app": "bs://", + "device": "Google Pixel 3", + "os_version": "9.0" + } +} +\`\`\``, + ); + + const initStep = createStep( + "Initialize remote WebDriver in terrain.py:", + `\`\`\`python +# Initialize the remote Webdriver using BrowserStack remote URL +# and desired capabilities defined above +context.browser = webdriver.Remote( + desired_capabilities=desired_capabilities, + command_executor="https://hub-cloud.browserstack.com/wd/hub" +) +\`\`\``, + ); + + return combineInstructions(envStep, configStep, initStep); +} diff --git a/src/tools/appautomate-utils/appium-sdk/languages/ruby.ts b/src/tools/appautomate-utils/appium-sdk/languages/ruby.ts new file mode 100644 index 00000000..10ff6a6f --- /dev/null +++ b/src/tools/appautomate-utils/appium-sdk/languages/ruby.ts @@ -0,0 +1,125 @@ +// Ruby instructions and commands for App SDK utilities +import { + createStep, + combineInstructions, + createEnvStep, + PLATFORM_UTILS, +} from "../index.js"; + +const username = "${process.env.BROWSERSTACK_USERNAME}"; +const accessKey = "${process.env.BROWSERSTACK_ACCESS_KEY}"; + +export function getRubyAppInstructions(): string { + const configStep = createStep( + "Create/Update the config file (config.yml) as follows:", + `\`\`\`yaml +server: "hub-cloud.browserstack.com" + +common_caps: + "browserstack.user": "${username}" + "browserstack.key": "${accessKey}" + "project": "First Cucumber Android Project" + "build": "Cucumber Android" + "browserstack.debug": true + +browser_caps: + - + "deviceName": "Google Pixel 3" + "os_version": "9.0" + "app": "" + "name": "first_test" +\`\`\``, + ); + + const envStep = createStep( + "Create/Update your support/env.rb file:", + `\`\`\`ruby +require 'rubygems' +require 'appium_lib' + +# Load configuration from config.yml +caps = Appium.load_appium_txt file: File.expand_path('./../config.yml', __FILE__) +username = "${username}" +password = "${accessKey}" + +# Create desired capabilities +desired_caps = { + caps: caps, + appium_lib: { + server_url: "https://#{username}:#{password}@#{caps['server']}/wd/hub" + } +} + +# Initialize Appium driver +begin + $appium_driver = Appium::Driver.new(desired_caps, true) + $driver = $appium_driver.start_driver +rescue Exception => e + puts e.message + Process.exit(0) +end + +# Add cleanup hook +at_exit do + $driver.quit if $driver +end +\`\`\``, + ); + + const runStep = createStep( + "Run the test:", + `\`\`\`bash +bundle exec cucumber +\`\`\``, + ); + + return combineInstructions(configStep, envStep, runStep); +} + +export function getRubySDKCommand( + framework: string, + username: string, + accessKey: string, +): string { + const { isWindows, getPlatformLabel } = PLATFORM_UTILS; + + const envStep = createEnvStep( + username, + accessKey, + isWindows, + getPlatformLabel(), + "Set your BrowserStack credentials as environment variables:", + ); + + const installStep = createStep( + "Install required Ruby gems:", + `\`\`\`bash +# Install Bundler if not already installed +gem install bundler + +# Install Appium Ruby client library +gem install appium_lib + +# Install Cucumber +gem install cucumber +\`\`\``, + ); + + const gemfileStep = createStep( + "Create a Gemfile for dependency management:", + `\`\`\`ruby +# Gemfile +source 'https://rubygems.org' + +gem 'appium_lib' +gem 'cucumber' +\`\`\` + +Then run: +\`\`\`bash +bundle install +\`\`\``, + ); + + return combineInstructions(envStep, installStep, gemfileStep); +} diff --git a/src/tools/appautomate-utils/appium-sdk/types.ts b/src/tools/appautomate-utils/appium-sdk/types.ts new file mode 100644 index 00000000..eecd469e --- /dev/null +++ b/src/tools/appautomate-utils/appium-sdk/types.ts @@ -0,0 +1,77 @@ +// Shared types for App SDK utilities +export enum AppSDKSupportedLanguageEnum { + java = "java", + nodejs = "nodejs", + python = "python", + ruby = "ruby", + csharp = "csharp", +} +export type AppSDKSupportedLanguage = keyof typeof AppSDKSupportedLanguageEnum; + +export enum AppSDKSupportedFrameworkEnum { + appium = "appium", +} + +export type AppSDKSupportedFramework = + keyof typeof AppSDKSupportedFrameworkEnum; + +export enum AppSDKSupportedTestingFrameworkEnum { + testng = "testng", + junit5 = "junit5", + junit4 = "junit4", + selenide = "selenide", + jbehave = "jbehave", + serenity = "serenity", + cucumberTestng = "cucumberTestng", + cucumberJunit4 = "cucumberJunit4", + cucumberJunit5 = "cucumberJunit5", + webdriverio = "webdriverio", + nightwatch = "nightwatch", + jest = "jest", + mocha = "mocha", + cucumberJs = "cucumberJs", + robot = "robot", + pytest = "pytest", + behave = "behave", + lettuce = "lettuce", + rspec = "rspec", + cucumberRuby = "cucumberRuby", + nunit = "nunit", + mstest = "mstest", + xunit = "xunit", + specflow = "specflow", + reqnroll = "reqnroll", +} + +export type AppSDKSupportedTestingFramework = + keyof typeof AppSDKSupportedTestingFrameworkEnum; + +export enum AppSDKSupportedPlatformEnum { + android = "android", + ios = "ios", +} +export type AppSDKSupportedPlatform = keyof typeof AppSDKSupportedPlatformEnum; + +// App SDK instruction type +export interface AppSDKInstruction { + content: string; + type: "config" | "run" | "setup"; +} + +export const SUPPORTED_CONFIGURATIONS = { + appium: { + ruby: ["cucumberRuby"], + java: [ + "testng", + "cucumber", + "junit4", + "junit5", + "jbehave", + "selenide", + "serenity", + ], + csharp: [], + python: ["pytest", "robot", "behave", "lettuce"], + nodejs: ["jest", "mocha", "cucumberJs", "webdriverio", "nightwatch"], + }, +}; diff --git a/src/tools/appautomate-utils/appium-sdk/utils.ts b/src/tools/appautomate-utils/appium-sdk/utils.ts new file mode 100644 index 00000000..3c532da8 --- /dev/null +++ b/src/tools/appautomate-utils/appium-sdk/utils.ts @@ -0,0 +1,113 @@ +import { uploadApp } from "../native-execution/appautomate.js"; +import { + AppSDKSupportedTestingFramework, + AppSDKSupportedTestingFrameworkEnum, + createStep, +} from "./index.js"; +import { SUPPORTED_CONFIGURATIONS } from "./types.js"; + +export function isBrowserStackAppUrl(appPath: string): boolean { + return appPath.startsWith("bs://"); +} + +export function generateBuildName(baseName: string = "app-automate"): string { + const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, ""); + return `${baseName}-${timestamp}`; +} + +export function createError( + message: string, + context?: Record, +): Error { + const error = new Error(message); + if (context) { + (error as any).context = context; + } + return error; +} + +// Platform utilities for cross-platform support +export const PLATFORM_UTILS = { + isWindows: process.platform === "win32", + isMac: process.platform === "darwin", + isAppleSilicon: process.platform === "darwin" && process.arch === "arm64", + getPlatformLabel: () => { + switch (process.platform) { + case "win32": + return "Windows"; + case "darwin": + return "macOS"; + default: + return "macOS"; + } + }, +}; + +export async function getAppUploadInstruction( + appPath: string, + username: string, + accessKey: string, + detectedTestingFramework: AppSDKSupportedTestingFramework, +): Promise { + if ( + detectedTestingFramework === + AppSDKSupportedTestingFrameworkEnum.nightwatch || + detectedTestingFramework === + AppSDKSupportedTestingFrameworkEnum.webdriverio || + detectedTestingFramework === + AppSDKSupportedTestingFrameworkEnum.cucumberRuby + ) { + const app_url = await uploadApp(appPath, username, accessKey); + if (app_url) { + return createStep( + "Updating app_path with app_url", + `Replace the value of app_path in your configuration with: ${app_url}`, + ); + } + } + return ""; +} + +export type SupportedFramework = keyof typeof SUPPORTED_CONFIGURATIONS; +type SupportedLanguage = + keyof (typeof SUPPORTED_CONFIGURATIONS)[SupportedFramework]; +type SupportedTestingFramework = string; + +export function validateSupportforAppAutomate( + framework: SupportedFramework, + language: SupportedLanguage, + testingFramework: SupportedTestingFramework, +) { + const frameworks = Object.keys( + SUPPORTED_CONFIGURATIONS, + ) as SupportedFramework[]; + if (!SUPPORTED_CONFIGURATIONS[framework]) { + throw new Error( + `Unsupported framework '${framework}'. Supported frameworks: ${frameworks.join(", ")}`, + ); + } + + const languages = Object.keys( + SUPPORTED_CONFIGURATIONS[framework], + ) as SupportedLanguage[]; + if (!SUPPORTED_CONFIGURATIONS[framework][language]) { + throw new Error( + `Unsupported language '${language}' for framework '${framework}'. Supported languages: ${languages.join(", ")}`, + ); + } + + const testingFrameworks = SUPPORTED_CONFIGURATIONS[framework][ + language + ] as string[]; + + if (testingFrameworks.length === 0) { + throw new Error( + `No testing frameworks are supported for language '${language}' and framework '${framework}'.`, + ); + } + if (!testingFrameworks.includes(testingFramework)) { + throw new Error( + `Unsupported testing framework '${testingFramework}' for language '${language}' and framework '${framework}'. Supported testing frameworks: ${testingFrameworks.join(", ")}`, + ); + } +} diff --git a/src/tools/appautomate-utils/appautomate.ts b/src/tools/appautomate-utils/native-execution/appautomate.ts similarity index 97% rename from src/tools/appautomate-utils/appautomate.ts rename to src/tools/appautomate-utils/native-execution/appautomate.ts index 9e16dc1f..00a2f8b6 100644 --- a/src/tools/appautomate-utils/appautomate.ts +++ b/src/tools/appautomate-utils/native-execution/appautomate.ts @@ -1,8 +1,8 @@ import fs from "fs"; import FormData from "form-data"; -import { apiClient } from "../../lib/apiClient.js"; -import { customFuzzySearch } from "../../lib/fuzzy.js"; -import { BrowserStackConfig } from "../../lib/types.js"; +import { apiClient } from "../../../lib/apiClient.js"; +import { customFuzzySearch } from "../../../lib/fuzzy.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; interface Device { device: string; diff --git a/src/tools/appautomate-utils/native-execution/constants.ts b/src/tools/appautomate-utils/native-execution/constants.ts new file mode 100644 index 00000000..9239a432 --- /dev/null +++ b/src/tools/appautomate-utils/native-execution/constants.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import { AppTestPlatform } from "./types.js"; +import { AppSDKSupportedPlatformEnum } from "../appium-sdk/types.js"; + +export const RUN_APP_AUTOMATE_DESCRIPTION = `Execute pre-built native mobile test suites (Espresso for Android, XCUITest for iOS) by direct upload to BrowserStack. ONLY for compiled .apk/.ipa test files. This is NOT for SDK integration or Appium tests. For Appium-based testing with SDK setup, use 'setupBrowserStackAppAutomateTests' instead.`; + +export const RUN_APP_AUTOMATE_SCHEMA = { + appPath: z + .string() + .describe( + "Path to your application file:\n" + + "If in development IDE directory:\n" + + "• For Android: 'gradle assembleDebug'\n" + + "• For iOS:\n" + + " xcodebuild clean -scheme YOUR_SCHEME && \\\n" + + " xcodebuild archive -scheme YOUR_SCHEME -configuration Release -archivePath build/app.xcarchive && \\\n" + + " xcodebuild -exportArchive -archivePath build/app.xcarchive -exportPath build/ipa -exportOptionsPlist exportOptions.plist\n\n" + + "If in other directory, provide existing app path", + ), + testSuitePath: z + .string() + .describe( + "Path to your test suite file:\n" + + "If in development IDE directory:\n" + + "• For Android: 'gradle assembleAndroidTest'\n" + + "• For iOS:\n" + + " xcodebuild test-without-building -scheme YOUR_SCHEME -destination 'generic/platform=iOS' && \\\n" + + " cd ~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug-iphonesimulator/ && \\\n" + + " zip -r Tests.zip *.xctestrun *-Runner.app\n\n" + + "If in other directory, provide existing test file path", + ), + devices: z + .array( + z.union([ + // Android: [android, deviceName, osVersion] + z.tuple([ + z + .literal(AppSDKSupportedPlatformEnum.android) + .describe("Platform identifier: 'android'"), + z + .string() + .describe( + "Device name, e.g. 'Samsung Galaxy S24', 'Google Pixel 8'", + ), + z.string().describe("Android version, e.g. '14', '16', 'latest'"), + ]), + // iOS: [ios, deviceName, osVersion] + z.tuple([ + z + .literal(AppSDKSupportedPlatformEnum.ios) + .describe("Platform identifier: 'ios'"), + z.string().describe("Device name, e.g. 'iPhone 15', 'iPhone 14 Pro'"), + z.string().describe("iOS version, e.g. '17', '16', 'latest'"), + ]), + ]), + ) + .max(3) + .default([]) + .describe( + "Tuples describing target mobile devices. Add device only when user asks explicitly for it. Defaults to [] . Example: [['android', 'Samsung Galaxy S24', '14'], ['ios', 'iPhone 15', '17']]", + ), + project: z + .string() + .optional() + .default("BStack-AppAutomate-Suite") + .describe("Project name for organizing test runs on BrowserStack."), + detectedAutomationFramework: z + .nativeEnum(AppTestPlatform) + .describe( + "The automation framework used in the project, such as 'espresso' (Android) or 'xcuitest' (iOS).", + ), +}; diff --git a/src/tools/appautomate-utils/native-execution/types.ts b/src/tools/appautomate-utils/native-execution/types.ts new file mode 100644 index 00000000..6f0901d3 --- /dev/null +++ b/src/tools/appautomate-utils/native-execution/types.ts @@ -0,0 +1,22 @@ +export enum AppTestPlatform { + ESPRESSO = "espresso", + XCUITEST = "xcuitest", +} + +export interface Device { + device: string; + display_name: string; + os_version: string; + real_mobile: boolean; +} + +export interface PlatformDevices { + os: string; + os_display_name: string; + devices: Device[]; +} + +export enum Platform { + ANDROID = "android", + IOS = "ios", +} diff --git a/src/tools/appautomate-utils/types.ts b/src/tools/appautomate-utils/types.ts deleted file mode 100644 index 16af7262..00000000 --- a/src/tools/appautomate-utils/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum AppTestPlatform { - ESPRESSO = "espresso", - APPIUM = "appium", - XCUITEST = "xcuitest", -} diff --git a/src/tools/appautomate.ts b/src/tools/appautomate.ts index c7b1af1f..29a7d57a 100644 --- a/src/tools/appautomate.ts +++ b/src/tools/appautomate.ts @@ -7,7 +7,19 @@ import { BrowserStackConfig } from "../lib/types.js"; import { trackMCP } from "../lib/instrumentation.js"; import { maybeCompressBase64 } from "../lib/utils.js"; import { remote } from "webdriverio"; -import { AppTestPlatform } from "./appautomate-utils/types.js"; +import { AppTestPlatform } from "./appautomate-utils/native-execution/types.js"; +import { setupAppAutomateHandler } from "./appautomate-utils/appium-sdk/handler.js"; +import { validateAppAutomateDevices } from "./sdk-utils/common/device-validator.js"; + +import { + SETUP_APP_AUTOMATE_DESCRIPTION, + SETUP_APP_AUTOMATE_SCHEMA, +} from "./appautomate-utils/appium-sdk/constants.js"; + +import { + PlatformDevices, + Platform, +} from "./appautomate-utils/native-execution/types.js"; import { getDevicesAndBrowsers, @@ -26,26 +38,11 @@ import { uploadXcuiApp, uploadXcuiTestSuite, triggerXcuiBuild, -} from "./appautomate-utils/appautomate.js"; - -// Types -interface Device { - device: string; - display_name: string; - os_version: string; - real_mobile: boolean; -} - -interface PlatformDevices { - os: string; - os_display_name: string; - devices: Device[]; -} - -enum Platform { - ANDROID = "android", - IOS = "ios", -} +} from "./appautomate-utils/native-execution/appautomate.js"; +import { + RUN_APP_AUTOMATE_DESCRIPTION, + RUN_APP_AUTOMATE_SCHEMA, +} from "./appautomate-utils/native-execution/constants.js"; /** * Launches an app on a selected BrowserStack device and takes a screenshot. @@ -179,7 +176,7 @@ async function runAppTestsOnBrowserStack( testSuitePath?: string; browserstackAppUrl?: string; browserstackTestSuiteUrl?: string; - devices: string[]; + devices: Array>; project: string; detectedAutomationFramework: string; }, @@ -197,6 +194,9 @@ async function runAppTestsOnBrowserStack( ); } + // Validate devices against real BrowserStack device data + await validateAppAutomateDevices(args.devices); + switch (args.detectedAutomationFramework) { case AppTestPlatform.ESPRESSO: { try { @@ -223,10 +223,16 @@ async function runAppTestsOnBrowserStack( logger.info(`Test suite uploaded. URL: ${test_suite_url}`); } + // Convert array format to string format for Espresso + const deviceStrings = args.devices.map((device) => { + const [, deviceName, osVersion] = device; + return `${deviceName}-${osVersion}`; + }); + const build_id = await triggerEspressoBuild( app_url, test_suite_url, - args.devices, + deviceStrings, args.project, ); @@ -268,10 +274,16 @@ async function runAppTestsOnBrowserStack( logger.info(`Test suite uploaded. URL: ${test_suite_url}`); } + // Convert array format to string format for XCUITest + const deviceStrings = args.devices.map((device) => { + const [, deviceName, osVersion] = device; + return `${deviceName}-${osVersion}`; + }); + const build_id = await triggerXcuiBuild( app_url, test_suite_url, - args.devices, + deviceStrings, args.project, config, ); @@ -356,48 +368,8 @@ export default function addAppAutomationTools( tools.runAppTestsOnBrowserStack = server.tool( "runAppTestsOnBrowserStack", - "Run AppAutomate tests on BrowserStack by uploading app and test suite. If running from Android Studio or Xcode, the tool will help export app and test files automatically. For other environments, you'll need to provide the paths to your pre-built app and test files.", - { - appPath: z - .string() - .describe( - "Path to your application file:\n" + - "If in development IDE directory:\n" + - "• For Android: 'gradle assembleDebug'\n" + - "• For iOS:\n" + - " xcodebuild clean -scheme YOUR_SCHEME && \\\n" + - " xcodebuild archive -scheme YOUR_SCHEME -configuration Release -archivePath build/app.xcarchive && \\\n" + - " xcodebuild -exportArchive -archivePath build/app.xcarchive -exportPath build/ipa -exportOptionsPlist exportOptions.plist\n\n" + - "If in other directory, provide existing app path", - ), - testSuitePath: z - .string() - .describe( - "Path to your test suite file:\n" + - "If in development IDE directory:\n" + - "• For Android: 'gradle assembleAndroidTest'\n" + - "• For iOS:\n" + - " xcodebuild test-without-building -scheme YOUR_SCHEME -destination 'generic/platform=iOS' && \\\n" + - " cd ~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug-iphonesimulator/ && \\\n" + - " zip -r Tests.zip *.xctestrun *-Runner.app\n\n" + - "If in other directory, provide existing test file path", - ), - devices: z - .array(z.string()) - .describe( - "List of devices to run the test on, e.g., ['Samsung Galaxy S20-10.0', 'iPhone 12 Pro-16.0'].", - ), - project: z - .string() - .optional() - .default("BStack-AppAutomate-Suite") - .describe("Project name for organizing test runs on BrowserStack."), - detectedAutomationFramework: z - .string() - .describe( - "The automation framework used in the project, such as 'espresso' (Android) or 'xcuitest' (iOS).", - ), - }, + RUN_APP_AUTOMATE_DESCRIPTION, + RUN_APP_AUTOMATE_SCHEMA, async (args) => { try { trackMCP( @@ -429,5 +401,29 @@ export default function addAppAutomationTools( }, ); + tools.setupBrowserStackAppAutomateTests = server.tool( + "setupBrowserStackAppAutomateTests", + SETUP_APP_AUTOMATE_DESCRIPTION, + SETUP_APP_AUTOMATE_SCHEMA, + async (args) => { + try { + return await setupAppAutomateHandler(args, config); + } catch (error) { + const error_message = + error instanceof Error ? error.message : "Unknown error"; + return { + content: [ + { + type: "text", + text: `Failed to bootstrap project with BrowserStack App Automate SDK. Error: ${error_message}. Please open an issue on GitHub if the problem persists`, + isError: true, + }, + ], + isError: true, + }; + } + }, + ); + return tools; } diff --git a/src/tools/automate-utils/fetch-screenshots.ts b/src/tools/automate-utils/fetch-screenshots.ts index 5574f0bc..46eff517 100644 --- a/src/tools/automate-utils/fetch-screenshots.ts +++ b/src/tools/automate-utils/fetch-screenshots.ts @@ -59,7 +59,10 @@ async function convertUrlsToBase64( ): Promise> { const screenshots = await Promise.all( urls.map(async (url) => { - const response = await apiClient.get({ url }); + const response = await apiClient.get({ + url, + responseType: "arraybuffer", + }); // Axios returns response.data as a Buffer for binary data const base64 = Buffer.from(response.data).toString("base64"); diff --git a/src/tools/bstack-sdk.ts b/src/tools/bstack-sdk.ts index f2e597e5..a7de7819 100644 --- a/src/tools/bstack-sdk.ts +++ b/src/tools/bstack-sdk.ts @@ -1,169 +1,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; -import { trackMCP } from "../lib/instrumentation.js"; -import { getSDKPrefixCommand } from "./sdk-utils/commands.js"; - -import { - SDKSupportedBrowserAutomationFramework, - SDKSupportedLanguage, - SDKSupportedTestingFramework, - SDKSupportedLanguageEnum, - SDKSupportedBrowserAutomationFrameworkEnum, - SDKSupportedTestingFrameworkEnum, -} from "./sdk-utils/types.js"; - -import { - generateBrowserStackYMLInstructions, - getInstructionsForProjectConfiguration, - formatInstructionsWithNumbers, -} from "./sdk-utils/instructions.js"; - -import { - formatPercyInstructions, - getPercyInstructions, -} from "./sdk-utils/percy/instructions.js"; -import { getBrowserStackAuth } from "../lib/get-auth.js"; import { BrowserStackConfig } from "../lib/types.js"; +import { RunTestsOnBrowserStackParamsShape } from "./sdk-utils/common/schema.js"; +import { runTestsOnBrowserStackHandler } from "./sdk-utils/handler.js"; +import { RUN_ON_BROWSERSTACK_DESCRIPTION } from "./sdk-utils/common/constants.js"; +import { handleMCPError } from "../lib/utils.js"; +import { trackMCP } from "../lib/instrumentation.js"; -/** - * BrowserStack SDK hooks into your test framework to seamlessly run tests on BrowserStack. - * This tool gives instructions to setup a browserstack.yml file in the project root and installs the necessary dependencies. - */ -export async function bootstrapProjectWithSDK({ - detectedBrowserAutomationFramework, - detectedTestingFramework, - 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" || - detectedTestingFramework === "webdriverio" - ) { - let combinedInstructions = getInstructionsForProjectConfiguration( - detectedBrowserAutomationFramework, - detectedTestingFramework, - detectedLanguage, - username, - accessKey, - ); - - if (enablePercy) { - const percyInstructions = getPercyInstructions( - detectedLanguage, - detectedBrowserAutomationFramework, - detectedTestingFramework, - ); - - if (percyInstructions) { - combinedInstructions += - "\n\n" + formatPercyInstructions(percyInstructions); - } else { - throw new Error( - `Percy is currently not supported through MCP for ${detectedLanguage} with ${detectedTestingFramework}. If you want to run the test cases without Percy, disable Percy and run it again.`, - ); - } - } - - // Apply consistent formatting for all configurations - return formatFinalInstructions(combinedInstructions); - } - - // Handle default flow using browserstack.yml - const sdkSetupCommand = getSDKPrefixCommand( - detectedLanguage, - detectedTestingFramework, - username, - accessKey, - ); - - const ymlInstructions = generateBrowserStackYMLInstructions( - desiredPlatforms, - enablePercy, - ); - - const instructionsForProjectConfiguration = - getInstructionsForProjectConfiguration( - detectedBrowserAutomationFramework, - detectedTestingFramework, - detectedLanguage, - username, - accessKey, - ); - - let combinedInstructions = ""; - - // Step 1: Add SDK setup command - if (sdkSetupCommand) { - combinedInstructions += sdkSetupCommand; - } - - // Step 2: Add browserstack.yml setup - if (ymlInstructions) { - combinedInstructions += "\n\n---STEP---\n" + ymlInstructions; - } - - // Step 3: Add language/framework-specific setup - if (instructionsForProjectConfiguration) { - combinedInstructions += "\n\n" + instructionsForProjectConfiguration; - } - - // Step 4: Add Percy setup if applicable - if (enablePercy) { - const percyInstructions = getPercyInstructions( - detectedLanguage, - detectedBrowserAutomationFramework, - detectedTestingFramework, - ); - - if (percyInstructions) { - combinedInstructions += - "\n\n" + formatPercyInstructions(percyInstructions); - } else { - throw new Error( - `Percy is currently not supported through MCP for ${detectedLanguage} with ${detectedTestingFramework}. If you want to run the test cases without Percy, disable Percy and run it again.`, - ); - } - } - - // Apply consistent formatting for all configurations - return formatFinalInstructions(combinedInstructions); -} - -// Helper function to apply consistent formatting to all instruction types -function formatFinalInstructions(combinedInstructions: string): CallToolResult { - const fullInstructions = `⚠️ IMPORTANT: DO NOT SKIP ANY STEP - All the setup steps described in this file MUST be executed regardless of any existing configuration or setup. - This ensures proper BrowserStack SDK setup. - - ${formatInstructionsWithNumbers(combinedInstructions)}`; - - return { - content: [ - { - type: "text", - text: fullInstructions, - isError: false, - }, - ], - }; -} - -export default function addSDKTools( +export function registerRunBrowserStackTestsTool( server: McpServer, config: BrowserStackConfig, ) { @@ -171,84 +14,23 @@ export default function addSDKTools( tools.setupBrowserStackAutomateTests = server.tool( "setupBrowserStackAutomateTests", - "Set up and run automated web-based tests on BrowserStack using the BrowserStack SDK. Use for functional or integration tests on BrowserStack, with optional Percy visual testing for supported frameworks. Example prompts: run this test on browserstack; run this test on browserstack with Percy; set up this project for browserstack with Percy. Integrate BrowserStack SDK into your project", - { - detectedBrowserAutomationFramework: z - .nativeEnum(SDKSupportedBrowserAutomationFrameworkEnum) - .describe( - "The automation framework configured in the project. Example: 'playwright', 'selenium'", - ), - - detectedTestingFramework: z - .nativeEnum(SDKSupportedTestingFrameworkEnum) - .describe( - "The testing framework used in the project. Be precise with framework selection Example: 'webdriverio', 'jest', 'pytest', 'junit4', 'junit5', 'mocha'", - ), - - detectedLanguage: z - .nativeEnum(SDKSupportedLanguageEnum) - .describe( - "The programming language used in the project. Example: 'nodejs', 'python', 'java', 'csharp'", - ), - - desiredPlatforms: z - .array(z.enum(["windows", "macos", "android", "ios"])) - .describe( - "The platforms the user wants to test on. Always ask this to the user, do not try to infer this.", - ), - - enablePercy: z - .boolean() - .optional() - .default(false) - .describe( - "Set to true if the user wants to enable Percy for visual testing. Defaults to false.", - ), - }, - + RUN_ON_BROWSERSTACK_DESCRIPTION, + RunTestsOnBrowserStackParamsShape, async (args) => { try { trackMCP( "runTestsOnBrowserStack", server.server.getClientVersion()!, - undefined, config, ); - - return await bootstrapProjectWithSDK({ - detectedBrowserAutomationFramework: - args.detectedBrowserAutomationFramework as SDKSupportedBrowserAutomationFramework, - - detectedTestingFramework: - args.detectedTestingFramework as SDKSupportedTestingFramework, - - detectedLanguage: args.detectedLanguage as SDKSupportedLanguage, - - desiredPlatforms: args.desiredPlatforms, - enablePercy: args.enablePercy, - config, - }); + return await runTestsOnBrowserStackHandler(args, config); } catch (error) { - trackMCP( - "runTestsOnBrowserStack", - server.server.getClientVersion()!, - error, - config, - ); - - return { - content: [ - { - type: "text", - text: `Failed to bootstrap project with BrowserStack SDK. Error: ${error}. Please open an issue on GitHub if the problem persists`, - isError: true, - }, - ], - isError: true, - }; + return handleMCPError("runTestsOnBrowserStack", server, config, error); } }, ); return tools; } + +export default registerRunBrowserStackTestsTool; diff --git a/src/tools/build-insights.ts b/src/tools/build-insights.ts new file mode 100644 index 00000000..ed6e3d74 --- /dev/null +++ b/src/tools/build-insights.ts @@ -0,0 +1,95 @@ +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 { BrowserStackConfig } from "../lib/types.js"; +import { fetchFromBrowserStackAPI, handleMCPError } from "../lib/utils.js"; +import { trackMCP } from "../lib/instrumentation.js"; + +// Tool function that fetches build insights from two APIs +export async function fetchBuildInsightsTool( + args: { buildId: string }, + config: BrowserStackConfig, +): Promise { + try { + const buildUrl = `https://api-automation.browserstack.com/ext/v1/builds/${args.buildId}`; + const qualityGateUrl = `https://api-automation.browserstack.com/ext/v1/quality-gates/${args.buildId}`; + + const [buildData, qualityData] = await Promise.all([ + fetchFromBrowserStackAPI(buildUrl, config), + fetchFromBrowserStackAPI(qualityGateUrl, config), + ]); + + // Select useful fields for users + const insights = { + name: buildData.name, + status: buildData.status, + duration: buildData.duration, + user: buildData.user, + tags: buildData.tags, + alerts: buildData.alerts, + status_stats: buildData.status_stats, + failure_categories: buildData.failure_categories, + smart_tags: buildData.smart_tags, + unique_errors: buildData.unique_errors?.overview, + observability_url: buildData?.observability_url, + ci_build_url: buildData.ci_info?.build_url, + quality_gate_result: qualityData.quality_gate_result, + }; + + const qualityProfiles = qualityData.quality_profiles?.map( + (profile: any) => ({ + name: profile.name, + result: profile.result, + }), + ); + + const qualityProfilesText = + qualityProfiles && qualityProfiles.length > 0 + ? `Quality Gate Profiles (respond only if explicitly requested): ${JSON.stringify(qualityProfiles, null, 2)}` + : "No Quality Gate Profiles available."; + + return { + content: [ + { + type: "text", + text: "Build insights:\n" + JSON.stringify(insights, null, 2), + }, + { type: "text", text: qualityProfilesText }, + ], + }; + } catch (error) { + logger.error("Error fetching build insights", error); + throw error; + } +} + +// Registers the fetchBuildInsights tool with the MCP server +export default function addBuildInsightsTools( + server: McpServer, + config: BrowserStackConfig, +) { + const tools: Record = {}; + + tools.fetchBuildInsights = server.tool( + "fetchBuildInsights", + "Fetches insights about a BrowserStack build by combining build details and quality gate results.", + { + buildId: z.string().describe("The build UUID of the BrowserStack build"), + }, + async (args) => { + try { + trackMCP( + "fetchBuildInsights", + server.server.getClientVersion()!, + config, + ); + return await fetchBuildInsightsTool(args, config); + } catch (error) { + return handleMCPError("fetchBuildInsights", server, config, error); + } + }, + ); + + return tools; +} diff --git a/src/tools/list-test-files.ts b/src/tools/list-test-files.ts new file mode 100644 index 00000000..46374636 --- /dev/null +++ b/src/tools/list-test-files.ts @@ -0,0 +1,81 @@ +import { listTestFiles } from "./percy-snapshot-utils/detect-test-files.js"; +import { storedPercyResults } from "../lib/inmemory-store.js"; +import { updateFileAndStep } from "./percy-snapshot-utils/utils.js"; +import { percyWebSetupInstructions } from "./sdk-utils/percy-web/handler.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function addListTestFiles(): Promise { + const storedResults = storedPercyResults.get(); + if (!storedResults) { + throw new Error( + "No Framework details found. Please call expandPercyVisualTesting first to fetch the framework details.", + ); + } + + const language = storedResults.detectedLanguage; + const framework = storedResults.detectedTestingFramework; + + // Use stored paths from setUpPercy + const dirs = storedResults.folderPaths; + const files = storedResults.filePaths; + + let testFiles: string[] = []; + + if (files && files.length > 0) { + testFiles = testFiles.concat(files); + } + + if (dirs && dirs.length > 0) { + for (const dir of dirs) { + const discoveredFiles = await listTestFiles({ + language, + framework, + baseDir: dir, + }); + testFiles = testFiles.concat(discoveredFiles); + } + } + + // Validate that we have at least one test file + if (testFiles.length === 0) { + throw new Error( + "No test files found. Please provide either specific file paths (files) or directory paths (dirs) containing test files.", + ); + } + + if (testFiles.length === 1) { + const result = await updateFileAndStep( + testFiles[0], + 0, + 1, + percyWebSetupInstructions, + ); + return { + content: result, + }; + } + + // For multiple files, store directly in testFiles + const fileStatusMap: { [key: string]: boolean } = {}; + testFiles.forEach((file) => { + fileStatusMap[file] = false; // false = not updated, true = updated + }); + + // Update storedPercyResults with test files + const updatedStored = { ...storedResults }; + updatedStored.testFiles = fileStatusMap; + storedPercyResults.set(updatedStored); + + return { + content: [ + { + type: "text", + text: `The Test files are stored in memory and the total number of tests files found is ${testFiles.length}.`, + }, + { + type: "text", + text: `You can now use the tool addPercySnapshotCommands to update the test file with Percy commands for visual testing.`, + }, + ], + }; +} diff --git a/src/tools/percy-sdk.ts b/src/tools/percy-sdk.ts new file mode 100644 index 00000000..dac7626a --- /dev/null +++ b/src/tools/percy-sdk.ts @@ -0,0 +1,194 @@ +import { trackMCP } from "../index.js"; +import { BrowserStackConfig } from "../lib/types.js"; +import { fetchPercyChanges } from "./review-agent.js"; +import { addListTestFiles } from "./list-test-files.js"; +import { runPercyScan } from "./run-percy-scan.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SetUpPercyParamsShape } from "./sdk-utils/common/schema.js"; +import { updateTestsWithPercyCommands } from "./add-percy-snapshots.js"; +import { approveOrDeclinePercyBuild } from "./review-agent-utils/percy-approve-reject.js"; +import { + setUpPercyHandler, + simulatePercyChangeHandler, +} from "./sdk-utils/handler.js"; +import { z } from "zod"; +import { + SETUP_PERCY_DESCRIPTION, + LIST_TEST_FILES_DESCRIPTION, + PERCY_SNAPSHOT_COMMANDS_DESCRIPTION, + SIMULATE_PERCY_CHANGE_DESCRIPTION, +} from "./sdk-utils/common/constants.js"; +import { UpdateTestFileWithInstructionsParams } from "./percy-snapshot-utils/constants.js"; + +import { + RunPercyScanParamsShape, + FetchPercyChangesParamsShape, + ManagePercyBuildApprovalParamsShape, +} from "./sdk-utils/common/schema.js"; +import { handleMCPError } from "../lib/utils.js"; + +export function registerPercyTools( + server: McpServer, + config: BrowserStackConfig, +) { + const tools: Record = {}; + + server.prompt( + "integrate-percy", + { + project_name: z + .string() + .describe("The name of the project to integrate with Percy"), + }, + async ({ project_name }) => { + return { + messages: [ + { + role: "assistant", + content: { + type: "text", + text: `Integrate percy in this project ${project_name} using tool percyVisualTestIntegrationAgent.`, + }, + }, + ], + }; + }, + ); + + tools.percyVisualTestIntegrationAgent = server.tool( + "percyVisualTestIntegrationAgent", + SIMULATE_PERCY_CHANGE_DESCRIPTION, + SetUpPercyParamsShape, + async (args) => { + try { + trackMCP( + "VisualTestIntegrationAgent", + server.server.getClientVersion()!, + config, + ); + return simulatePercyChangeHandler(args, config); + } catch (error) { + return handleMCPError( + "VisualTestIntegrationAgent", + server, + config, + error, + ); + } + }, + ); + + tools.setupPercyVisualTesting = server.tool( + "expandPercyVisualTesting", + SETUP_PERCY_DESCRIPTION, + SetUpPercyParamsShape, + async (args) => { + try { + trackMCP( + "setupPercyVisualTesting", + server.server.getClientVersion()!, + config, + ); + return setUpPercyHandler(args, config); + } catch (error) { + return handleMCPError("setupPercyVisualTesting", server, config, error); + } + }, + ); + + tools.addPercySnapshotCommands = server.tool( + "addPercySnapshotCommands", + PERCY_SNAPSHOT_COMMANDS_DESCRIPTION, + UpdateTestFileWithInstructionsParams, + async (args) => { + try { + trackMCP( + "addPercySnapshotCommands", + server.server.getClientVersion()!, + config, + ); + return await updateTestsWithPercyCommands(args); + } catch (error) { + return handleMCPError( + "addPercySnapshotCommands", + server, + config, + error, + ); + } + }, + ); + + tools.listTestFiles = server.tool( + "listTestFiles", + LIST_TEST_FILES_DESCRIPTION, + {}, + async () => { + try { + trackMCP("listTestFiles", server.server.getClientVersion()!, config); + return addListTestFiles(); + } catch (error) { + return handleMCPError("listTestFiles", server, config, error); + } + }, + ); + + tools.runPercyScan = server.tool( + "runPercyScan", + "Run a Percy visual test scan. Example prompts : Run this Percy build/scan. Never run percy scan/build without this tool", + RunPercyScanParamsShape, + async (args) => { + try { + trackMCP("runPercyScan", server.server.getClientVersion()!, config); + return runPercyScan(args, config); + } catch (error) { + return handleMCPError("runPercyScan", server, config, error); + } + }, + ); + + tools.fetchPercyChanges = server.tool( + "fetchPercyChanges", + "Retrieves and summarizes all visual changes detected by Percy AI between the latest and previous builds, helping quickly review what has changed in your project.", + FetchPercyChangesParamsShape, + async (args) => { + try { + trackMCP( + "fetchPercyChanges", + server.server.getClientVersion()!, + config, + ); + return await fetchPercyChanges(args, config); + } catch (error) { + return handleMCPError("fetchPercyChanges", server, config, error); + } + }, + ); + + tools.managePercyBuildApproval = server.tool( + "managePercyBuildApproval", + "Approve or reject a Percy build", + ManagePercyBuildApprovalParamsShape, + async (args) => { + try { + trackMCP( + "managePercyBuildApproval", + server.server.getClientVersion()!, + config, + ); + return await approveOrDeclinePercyBuild(args, config); + } catch (error) { + return handleMCPError( + "managePercyBuildApproval", + server, + config, + error, + ); + } + }, + ); + + return tools; +} + +export default registerPercyTools; diff --git a/src/tools/percy-snapshot-utils/constants.ts b/src/tools/percy-snapshot-utils/constants.ts new file mode 100644 index 00000000..a76f4426 --- /dev/null +++ b/src/tools/percy-snapshot-utils/constants.ts @@ -0,0 +1,495 @@ +import { z } from "zod"; +import { SDKSupportedLanguage } from "../sdk-utils/common/types.js"; +import { DetectionConfig } from "./types.js"; + +export const UpdateTestFileWithInstructionsParams = { + index: z.number().describe("Index of the test file to update"), +}; + +export const TEST_FILE_DETECTION: Record< + SDKSupportedLanguage, + DetectionConfig +> = { + java: { + extensions: [".java"], + namePatterns: [ + /Test\.java$/, + /Tests\.java$/, + /Steps\.java$/, + /.*UI.*Test\.java$/, + /.*Web.*Test\.java$/, + /.*E2E.*Test\.java$/, + /.*Integration.*Test\.java$/, + /.*Functional.*Test\.java$/, + ], + contentRegex: [ + /@Test\b/, + /@RunWith\b/, + /@CucumberOptions\b/, + /import\s+org\.junit/, + /import\s+org\.testng/, + /import\s+io\.cucumber/, + /import\s+org\.jbehave/, + ], + uiDriverRegex: [ + /import\s+org\.openqa\.selenium/, + /import\s+org\.seleniumhq\.selenium/, + /import\s+io\.appium\.java_client/, + /import\s+com\.microsoft\.playwright/, + /import\s+com\.codeborne\.selenide/, + /import\s+net\.serenitybdd/, + /import\s+cucumber\.api\.java\.en/, + /new\s+\w*Driver\s*\(/, + /\.findElement\s*\(/, + /\.get\s*\(['"]https?:/, + /\.click\s*\(/, + /\.navigate\(\)/, + /WebDriver/, + /RemoteWebDriver/, + /ChromeDriver/, + /FirefoxDriver/, + ], + uiIndicatorRegex: [ + // UI interactions without explicit driver imports + /\.sendKeys\s*\(/, + /\.getText\s*\(/, + /\.isDisplayed\s*\(/, + /By\.id\s*\(/, + /By\.className\s*\(/, + /By\.xpath\s*\(/, + /waitForElement/, + /waitForVisible/, + /assertTitle/, + /screenshot/, + /captureScreenshot/, + // Page Object patterns + /PageObject/, + /BasePage/, + /WebPage/, + // UI test annotations and patterns + /@UITest/, + /@WebTest/, + /@E2ETest/, + // Common UI assertions + /assertUrl/, + /verifyText/, + /checkElement/, + // Browser/window operations + /maximizeWindow/, + /setWindowSize/, + /switchTo/, + // Cucumber UI steps + /Given.*I\s+(open|visit|navigate)/, + /When.*I\s+(click|type|select)/, + /Then.*I\s+(see|verify|check)/, + /And.*I\s+(wait|scroll)/, + ], + backendRegex: [ + /import\s+org\.springframework\.test/, + /import\s+javax\.persistence/, + /@DataJpaTest/, + /@WebMvcTest/, + /@MockBean/, + /EntityManager/, + /JdbcTemplate/, + /TestRestTemplate/, + /@Repository/, + /@Service/, + /@Entity/, + ], + excludeRegex: [ + /UnitTest/, + /MockTest/, + /StubTest/, + /DatabaseTest/, + /import\s+org\.mockito/, + /@Mock\b/, + /@Spy\b/, + ], + }, + csharp: { + extensions: [".cs"], + namePatterns: [ + /Test\.cs$/, + /Tests\.cs$/, + /Steps\.cs$/, + /.*UI.*Test\.cs$/, + /.*Web.*Test\.cs$/, + /.*E2E.*Test\.cs$/, + ], + contentRegex: [ + /\[Test\]/, + /\[TestCase\]/, + /\[Fact\]/, + /\[Theory\]/, + /\[Binding\]/, + /using\s+NUnit\.Framework/, + /using\s+Xunit/, + /using\s+TechTalk\.SpecFlow/, + ], + uiDriverRegex: [ + /using\s+OpenQA\.Selenium/, + /using\s+Appium/, + /using\s+Microsoft\.Playwright/, + /using\s+Selenide/, + /using\s+Atata/, + /new\s+\w*Driver\s*\(/, + /\.FindElement\s*\(/, + /\.Navigate\(\)/, + /IWebDriver/, + /WebDriver/, + ], + uiIndicatorRegex: [ + /\.SendKeys\s*\(/, + /\.Click\s*\(/, + /\.Text/, + /\.Displayed/, + /By\.Id/, + /By\.ClassName/, + /By\.XPath/, + /WaitForElement/, + /TakeScreenshot/, + /PageObject/, + /\[UITest\]/, + /\[WebTest\]/, + /\[E2ETest\]/, + /NavigateTo/, + /VerifyText/, + /AssertUrl/, + ], + backendRegex: [ + /using\s+Microsoft\.EntityFrameworkCore/, + /using\s+System\.Data/, + /DbContext/, + /Repository/, + /Controller/, + /\[ApiTest\]/, + /\[DatabaseTest\]/, + ], + excludeRegex: [/\[UnitTest\]/, /Mock/, /Stub/, /using\s+Moq/], + }, + nodejs: { + extensions: [".js", ".ts"], + namePatterns: [ + /.test.js$/, + /.spec.js$/, + /.test.ts$/, + /.spec.ts$/, + /.*ui.*.test.(js|ts)$/, + /.*web.*.test.(js|ts)$/, + /.*e2e.*.(js|ts)$/, + /.*integration.*.test.(js|ts)$/, + ], + contentRegex: [ + /\bdescribe\s*\(/, + /\bit\s*\(/, + /\btest\s*\(/, + /require\(['"]mocha['"]\)/, + /require\(['"]jest['"]\)/, + /import.*from\s+['"]jest['"]/, + /from\s+['"]@jest/, + ], + uiDriverRegex: [ + /require\(['"]selenium-webdriver['"]\)/, + /require\(['"]webdriverio['"]\)/, + /require\(['"]puppeteer['"]\)/, + /require\(['"]playwright['"]\)/, + /require\(['"]cypress['"]\)/, + /require\(['"]@wdio\/sync['"]\)/, + /import.*from\s+['"]selenium-webdriver['"]/, + /import.*from\s+['"]webdriverio['"]/, + /import.*from\s+['"]puppeteer['"]/, + /import.*from\s+['"]playwright['"]/, + /import.*from\s+['"]cypress['"]/, + /import.*from\s+['"]@wdio/, + /\.launch\(/, + /\.goto\(/, + /driver\./, + /browser\./, + ], + uiIndicatorRegex: [ + // Browser automation - SPECIFIC CONTEXT + /driver\.click\(/, + /driver\.type\(/, + /driver\.fill\(/, + /browser\.click\(/, + /driver\.waitForSelector\(/, + /browser\.waitForElement\(/, + /driver\.screenshot\(/, + /browser\.screenshot\(/, + /driver\.evaluate\(/, + /driver\.focus\(/, + /driver\.hover\(/, + // Page object patterns - UI specific + /page\.goto/, + /page\.click/, + /page\.fill/, + /page\.screenshot/, + /page\.waitForSelector/, + /page\.locator/, + /page\.getByRole/, + // Cypress specific patterns + /cy\.visit/, + /cy\.get/, + /cy\.click/, + /cy\.type/, + /cy\.should/, + /cy\.wait/, + /cy\.screenshot/, + /cy\.viewport/, + // WebDriverIO specific patterns + /browser\.url/, + /browser\.click/, + /browser\.setValue/, + /\$\(['"][#.]/, + /\$\$\(['"][#.]/, // CSS/XPath selectors + // Playwright specific + /expect.*toBeVisible/, + /expect.*toHaveText/, + /expect.*toBeEnabled/, + /locator\(/, + /getByText\(/, + /getByRole\(/, + /getByTestId\(/, + // DOM queries in test context + /findElement/, + /querySelector.*\)\.click/, + /getElementById.*\)\.click/, + // Test descriptions clearly indicating UI + /describe.*['"`].*UI/, + /describe.*['"`].*Web/, + /describe.*['"`].*E2E/, + /describe.*['"`].*Browser/, + /describe.*['"`].*Selenium/, + /it.*['"`].*(click|type|navigate|visit|see).*element/, + /it.*['"`].*(open|load).*page/, + /it.*['"`].*browser/, + ], + backendRegex: [ + /require\(['"]express['"]\)/, + /require\(['"]fastify['"]\)/, + /require\(['"]supertest['"]\)/, + /request\(app\)/, + /mongoose/, + /sequelize/, + /prisma/, + /knex/, + /app\.get\(/, + /app\.post\(/, + /server\./, + /\.connect\(/, + /\.query\(/, + ], + excludeRegex: [ + /\.unit\./, + /\.mock\./, + /jest\.mock/, + /sinon/, + /describe.*['"`]Unit/, + /describe.*['"`]Mock/, + ], + }, + python: { + extensions: [".py"], + namePatterns: [ + /^test_.*\.py$/, + /_test\.py$/, + /test.*ui.*\.py$/, + /test.*web.*\.py$/, + /test.*e2e.*\.py$/, + /test.*integration.*\.py$/, + ], + contentRegex: [ + /import\s+pytest/, + /@pytest\.mark/, + /def\s+test_/, + /\bpytest\./, + /import\s+unittest/, + /class.*TestCase/, + ], + uiDriverRegex: [ + /import\s+selenium/, + /from\s+selenium/, + /import\s+playwright/, + /from\s+playwright/, + /import\s+appium/, + /from\s+appium/, + /import\s+splinter/, + /from\s+splinter/, + /driver\s*=\s*webdriver\./, + /webdriver\.Chrome/, + /webdriver\.Firefox/, + ], + uiIndicatorRegex: [ + // Selenium patterns without imports + /\.find_element/, + /\.click\(/, + /\.send_keys/, + /\.get\(/, + /\.screenshot/, + /\.execute_script/, + /\.switch_to/, + /By\.ID/, + /By\.CLASS_NAME/, + /By\.XPATH/, + // Playwright patterns + /page\.goto/, + /page\.click/, + /page\.fill/, + /page\.screenshot/, + /expect.*to_be_visible/, + /expect.*to_have_text/, + // Generic UI patterns + /WebDriverWait/, + /expected_conditions/, + /ActionChains/, + /@pytest\.mark\.ui/, + /@pytest\.mark\.web/, + /@pytest\.mark\.e2e/, + // Page object patterns + /BasePage/, + /PageObject/, + /WebPage/, + // BDD step patterns + /def\s+.*_(open|visit|navigate|click|type|see|verify)/, + ], + backendRegex: [ + /import\s+flask/, + /from\s+flask/, + /import\s+fastapi/, + /from\s+fastapi/, + /import\s+django/, + /from\s+django/, + /sqlalchemy/, + /requests\.get/, + /requests\.post/, + /TestClient/, + /@pytest\.mark\.django_db/, + /django\.test/, + ], + excludeRegex: [ + /unittest\.mock/, + /from\s+unittest\.mock/, + /mock\.patch/, + /@pytest\.mark\.unit/, + /@mock\./, + ], + }, + ruby: { + extensions: [".rb"], + namePatterns: [ + /_spec\.rb$/, + /_test\.rb$/, + /.*ui.*_spec\.rb$/, + /.*web.*_spec\.rb$/, + /.*e2e.*_spec\.rb$/, + ], + contentRegex: [ + /\bdescribe\s/, + /\bit\s/, + /require\s+['"]rspec/, + /require\s+['"]minitest/, + /RSpec\.describe/, + ], + uiDriverRegex: [ + /require\s+['"]selenium-webdriver['"]/, + /require\s+['"]capybara['"]/, + /require\s+['"]appium_lib['"]/, + /require\s+['"]watir['"]/, + /Selenium::WebDriver/, + /Capybara\./, + ], + uiIndicatorRegex: [ + // Capybara without explicit require + /visit\s/, + /click_button/, + /click_link/, + /fill_in/, + /find\(['"]/, + /has_content/, + /page\./, + /current_path/, + // Selenium patterns + /\.find_element/, + /\.click/, + /\.send_keys/, + // Generic UI patterns + /screenshot/, + /driver\./, + /browser\./, + /feature\s+['"]/, + /scenario\s+['"]/, + /expect.*to\s+have_content/, + /expect.*to\s+have_selector/, + ], + backendRegex: [ + /require\s+['"]sinatra['"]/, + /require\s+['"]rails['"]/, + /ActiveRecord/, + /DatabaseCleaner/, + /FactoryBot/, + ], + excludeRegex: [ + /double\(/, + /instance_double/, + /class_double/, + /allow\(.*\)\.to\s+receive/, + /mock/i, + ], + }, +}; + +export const EXCLUDED_DIRS = new Set([ + "node_modules", + ".venv", + "venv", + "__pycache__", + "site-packages", + "dist", + "build", + ".git", + ".mypy_cache", + ".pytest_cache", + ".tox", + ".idea", + ".vscode", + "coverage", + ".nyc_output", + "target", + "bin", + "obj", + "packages", + ".nuget", +]); + +export const backendIndicators = [ + /import\s+requests/, + /requests\.(get|post|put|delete|patch)/, + /@pytest\.mark\.(api|backend|integration)/, + /BASE_URL\s*=/, + /\.status_code/, + /\.json\(\)/, + /TestClient/, + /Bearer\s+/, + /Authorization.*Bearer/, +]; + +export const strongUIIndicators = [ + // Browser automation with specific context + /(driver|browser|page)\.(click|type|fill|screenshot|wait)/, + /webdriver\.(Chrome|Firefox|Safari|Edge)/, + /(selenium|playwright|puppeteer|cypress).*import/, + // CSS/XPath selectors + /By\.(ID|CLASS_NAME|XPATH|CSS_SELECTOR)/, + /\$\(['"#[.][^'"]*['"]\)/, // $(".class") or $("#id") + // Page Object Model + /class.*Page.*:/, + /class.*PageObject/, + // UI test markers + /@(ui|web|e2e|browser)_?test/, + /@pytest\.mark\.(ui|web|e2e|browser)/, + // Browser navigation + /\.goto\s*\(['"]https?:/, + /\.visit\s*\(['"]https?:/, + /\.navigate\(\)\.to\(/, +]; diff --git a/src/tools/percy-snapshot-utils/detect-test-files.ts b/src/tools/percy-snapshot-utils/detect-test-files.ts new file mode 100644 index 00000000..999790f1 --- /dev/null +++ b/src/tools/percy-snapshot-utils/detect-test-files.ts @@ -0,0 +1,243 @@ +import fs from "fs"; +import path from "path"; + +import { + SDKSupportedLanguage, + SDKSupportedTestingFrameworkEnum, +} from "../sdk-utils/common/types.js"; + +import { + EXCLUDED_DIRS, + TEST_FILE_DETECTION, + backendIndicators, + strongUIIndicators, +} from "../percy-snapshot-utils/constants.js"; + +import { DetectionConfig } from "../percy-snapshot-utils/types.js"; +import logger from "../../logger.js"; + +async function walkDir( + dir: string, + extensions: string[], + depth: number = 6, +): Promise { + const result: string[] = []; + if (depth < 0) return result; + try { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + if (!EXCLUDED_DIRS.has(entry.name) && !entry.name.startsWith(".")) { + result.push(...(await walkDir(fullPath, extensions, depth - 1))); + } + } else if (extensions.some((ext) => entry.name.endsWith(ext))) { + result.push(fullPath); + } + } + } catch { + logger.info("Failed to read user directory"); + } + + return result; +} + +async function fileContainsRegex( + filePath: string, + regexes: RegExp[], +): Promise { + if (!regexes.length) return false; + + try { + const content = await fs.promises.readFile(filePath, "utf8"); + return regexes.some((re) => re.test(content)); + } catch { + return false; + } +} + +async function batchRegexCheck( + filePath: string, + regexGroups: RegExp[][], +): Promise { + try { + const content = await fs.promises.readFile(filePath, "utf8"); + return regexGroups.map((regexes) => + regexes.length > 0 ? regexes.some((re) => re.test(content)) : false, + ); + } catch { + return regexGroups.map(() => false); + } +} + +async function isLikelyUITest(filePath: string): Promise { + try { + const content = await fs.promises.readFile(filePath, "utf8"); + if (backendIndicators.some((pattern) => pattern.test(content))) { + return false; + } + return strongUIIndicators.some((pattern) => pattern.test(content)); + } catch { + return false; + } +} + +function getFileScore(fileName: string, config: DetectionConfig): number { + let score = 0; + + // Higher score for explicit UI test naming + if (/ui|web|e2e|integration|functional/i.test(fileName)) score += 3; + if (config.namePatterns.some((pattern) => pattern.test(fileName))) score += 2; + + return score; +} + +export interface ListTestFilesOptions { + language: SDKSupportedLanguage; + framework?: SDKSupportedTestingFrameworkEnum; + baseDir: string; + strictMode?: boolean; +} + +export async function listTestFiles( + options: ListTestFilesOptions, +): Promise { + const { language, framework, baseDir, strictMode = false } = options; + const config = TEST_FILE_DETECTION[language]; + + if (!config) { + return []; + } + + // Step 1: Collect all files with matching extensions + let files: string[] = []; + try { + files = await walkDir(baseDir, config.extensions, 6); + } catch { + return []; + } + + if (files.length === 0) { + throw new Error("No files found with the specified extensions"); + } + + const candidateFiles: Map = new Map(); + + // Step 2: Fast name-based identification with scoring + for (const file of files) { + const fileName = path.basename(file); + const score = getFileScore(fileName, config); + + if (config.namePatterns.some((pattern) => pattern.test(fileName))) { + candidateFiles.set(file, score); + } + } + + // Step 3: Content-based test detection for remaining files + const remainingFiles = files.filter((file) => !candidateFiles.has(file)); + const contentCheckPromises = remainingFiles.map(async (file) => { + const hasTestContent = await fileContainsRegex(file, config.contentRegex); + if (hasTestContent) { + const fileName = path.basename(file); + const score = getFileScore(fileName, config); + candidateFiles.set(file, score); + } + }); + + await Promise.all(contentCheckPromises); + + // Step 4: Handle SpecFlow .feature files for C# + SpecFlow + if (language === "csharp" && framework === "specflow") { + try { + const featureFiles = await walkDir(baseDir, [".feature"], 6); + featureFiles.forEach((file) => candidateFiles.set(file, 2)); + } catch { + // ignore + } + } + + if (candidateFiles.size === 0) { + return []; + } + + // Step 6: UI Detection with fallback patterns + const uiFiles: string[] = []; + const filesToCheck = Array.from(candidateFiles.keys()); + + // Batch process UI detection for better performance + const batchSize = 10; + for (let i = 0; i < filesToCheck.length; i += batchSize) { + const batch = filesToCheck.slice(i, i + batchSize); + + const batchPromises = batch.map(async (file) => { + // First, use the new precise UI detection + const isUITest = await isLikelyUITest(file); + + if (isUITest) { + return file; + } + + // If not clearly UI, run the traditional checks + const [hasExplicitUI, hasUIIndicators, hasBackend, shouldExclude] = + await batchRegexCheck(file, [ + config.uiDriverRegex, + config.uiIndicatorRegex, + config.backendRegex, + config.excludeRegex || [], + ]); + + if (shouldExclude) { + return null; + } + + if (hasBackend) { + return null; + } + + if (hasExplicitUI) { + return file; + } + + if (hasUIIndicators) { + return file; + } + + if (!strictMode) { + const score = candidateFiles.get(file) || 0; + if (score >= 3) { + return file; + } + } + + return null; + }); + + const batchResults = await Promise.all(batchPromises); + uiFiles.push( + ...batchResults.filter((file): file is string => file !== null), + ); + } + + // Step 7: Sort by score (higher confidence files first) + uiFiles.sort((a, b) => { + const scoreA = candidateFiles.get(a) || 0; + const scoreB = candidateFiles.get(b) || 0; + return scoreB - scoreA; + }); + + return uiFiles; +} + +export async function listUITestFilesStrict( + options: Omit, +): Promise { + return listTestFiles({ ...options, strictMode: true }); +} + +export async function listUITestFilesRelaxed( + options: Omit, +): Promise { + return listTestFiles({ ...options, strictMode: false }); +} diff --git a/src/tools/percy-snapshot-utils/types.ts b/src/tools/percy-snapshot-utils/types.ts new file mode 100644 index 00000000..03b8b513 --- /dev/null +++ b/src/tools/percy-snapshot-utils/types.ts @@ -0,0 +1,20 @@ +import { + SDKSupportedTestingFrameworkEnum, + SDKSupportedLanguageEnum, +} from "../sdk-utils/common/types.js"; + +export type ListTestFilesParams = { + dirs: string[]; + language: SDKSupportedLanguageEnum; + framework?: SDKSupportedTestingFrameworkEnum; +}; + +export interface DetectionConfig { + extensions: string[]; + namePatterns: RegExp[]; + contentRegex: RegExp[]; + uiDriverRegex: RegExp[]; + uiIndicatorRegex: RegExp[]; + backendRegex: RegExp[]; + excludeRegex?: RegExp[]; +} diff --git a/src/tools/percy-snapshot-utils/utils.ts b/src/tools/percy-snapshot-utils/utils.ts new file mode 100644 index 00000000..67625a68 --- /dev/null +++ b/src/tools/percy-snapshot-utils/utils.ts @@ -0,0 +1,42 @@ +const content: { type: "text"; text: string }[] = []; + +export async function updateFileAndStep( + file: string, + idx: number, + total: number, + instruction: string, +) { + content.length = 0; + const nextIndex = idx + 1; + + content.push({ + type: "text", + text: `Complete all steps in order. If a tool call is requested, update the file first, then call the tool. Follow instructions exactly— do not skip any steps to ensure all files are updated.`, + }); + + content.push({ + type: "text", + text: `Step 1 : You need to add percy snapshot commands in some key test cases in the file ${file} use the following instructions: \n${instruction}`, + }); + + content.push({ + type: "text", + text: `Step 2 : Confirm that Percy snapshot commands have been added at all key points of visual change in the file ${file}.`, + }); + + if (nextIndex < total) { + content.push({ + type: "text", + text: `Step 3 : Call the tool updateTestFileWithInstructions with index as ${nextIndex} out of ${total}`, + }); + } + + if (nextIndex === total) { + content.push({ + type: "text", + text: `Step 3: Percy snapshot commands have been added to all files. You can now run the tool runPercyScan to run the percy scan.`, + }); + } + + return content; +} diff --git a/src/tools/rca-agent-utils/constants.ts b/src/tools/rca-agent-utils/constants.ts new file mode 100644 index 00000000..5818733d --- /dev/null +++ b/src/tools/rca-agent-utils/constants.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { TestStatus } from "./types.js"; + +export const FETCH_RCA_PARAMS = { + testId: z + .array(z.number().int()) + .max(3) + .describe( + "Array of integer test IDs to fetch RCA data for (maximum 3 IDs). These must be numeric test IDs, not session IDs or strings. If not provided, use the listTestIds tool to get all failed testcases. If more than 3 IDs are provided, only the first 3 will be processed.", + ), +}; + +export const GET_BUILD_ID_PARAMS = { + browserStackProjectName: z + .string() + .describe( + "The BrowserStack project name used during test run creation. Action: First, check browserstack.yml or any equivalent project configuration files. If the project name is found, extract and return it. If it is not found or if there is any uncertainty, immediately prompt the user to provide the value. Do not infer, guess, or assume a default.", + ), + browserStackBuildName: z + .string() + .describe( + "The BrowserStack build name used during test run creation. Action: First, check browserstack.yml or any equivalent project configuration files. If the build name is found, extract and return it. If it is not found or if there is any uncertainty, immediately prompt the user to provide the value. Do not infer, guess, or assume a default.", + ), +}; + +export const LIST_TEST_IDS_PARAMS = { + buildId: z + .string() + .describe( + "The Browserstack Build ID of the test run. If not known, use the getBuildId tool to fetch it using project and build name", + ), + status: z + .nativeEnum(TestStatus) + .describe( + "Filter tests by status. If not provided, all tests are returned. Example for RCA usecase always use failed status", + ), +}; diff --git a/src/tools/rca-agent-utils/format-rca.ts b/src/tools/rca-agent-utils/format-rca.ts new file mode 100644 index 00000000..c4e84600 --- /dev/null +++ b/src/tools/rca-agent-utils/format-rca.ts @@ -0,0 +1,44 @@ +// Utility function to format RCA data for better readability +export function formatRCAData(rcaData: any): string { + if (!rcaData || !rcaData.testCases || rcaData.testCases.length === 0) { + return "No RCA data available."; + } + + let output = "## Root Cause Analysis Report\n\n"; + + rcaData.testCases.forEach((testCase: any, index: number) => { + // Show test case ID with smaller heading + output += `### Test Case ${index + 1}\n`; + output += `**Test ID:** ${testCase.id}\n`; + output += `**Status:** ${testCase.state}\n\n`; + + // Access RCA data from the correct path + const rca = testCase.rcaData?.rcaData; + + if (rca) { + if (rca.root_cause) { + output += `**Root Cause:** ${rca.root_cause}\n\n`; + } + + if (rca.failure_type) { + output += `**Failure Type:** ${rca.failure_type}\n\n`; + } + + if (rca.description) { + output += `**Detailed Analysis:**\n${rca.description}\n\n`; + } + + if (rca.possible_fix) { + output += `**Recommended Fix:**\n${rca.possible_fix}\n\n`; + } + } else if (testCase.rcaData?.error) { + output += `**Error:** ${testCase.rcaData.error}\n\n`; + } else if (testCase.state === "failed") { + output += `**Note:** RCA analysis failed or is not available for this test case.\n\n`; + } + + output += "---\n\n"; + }); + + return output; +} diff --git a/src/tools/rca-agent-utils/get-build-id.ts b/src/tools/rca-agent-utils/get-build-id.ts new file mode 100644 index 00000000..6f3dffe7 --- /dev/null +++ b/src/tools/rca-agent-utils/get-build-id.ts @@ -0,0 +1,32 @@ +export async function getBuildId( + projectName: string, + buildName: string, + username: string, + accessKey: string, +): Promise { + const url = new URL( + "https://api-automation.browserstack.com/ext/v1/builds/latest", + ); + url.searchParams.append("project_name", projectName); + url.searchParams.append("build_name", buildName); + url.searchParams.append("user_name", username); + + const authHeader = + "Basic " + Buffer.from(`${username}:${accessKey}`).toString("base64"); + + const response = await fetch(url.toString(), { + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch build ID: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + return data.build_id; +} diff --git a/src/tools/rca-agent-utils/get-failed-test-id.ts b/src/tools/rca-agent-utils/get-failed-test-id.ts new file mode 100644 index 00000000..a474b2b6 --- /dev/null +++ b/src/tools/rca-agent-utils/get-failed-test-id.ts @@ -0,0 +1,96 @@ +import logger from "../../logger.js"; +import { TestStatus, FailedTestInfo, TestRun, TestDetails } from "./types.js"; + +export async function getTestIds( + buildId: string, + authString: string, + status?: TestStatus, +): Promise { + const baseUrl = `https://api-automation.browserstack.com/ext/v1/builds/${buildId}/testRuns`; + let url = status ? `${baseUrl}?test_statuses=${status}` : baseUrl; + let allFailedTests: FailedTestInfo[] = []; + let requestNumber = 0; + + // Construct Basic auth header + const encodedCredentials = Buffer.from(authString).toString("base64"); + const authHeader = `Basic ${encodedCredentials}`; + + try { + while (true) { + requestNumber++; + + const response = await fetch(url, { + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch test runs: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as TestRun; + + // Extract failed IDs from current page + if (data.hierarchy && data.hierarchy.length > 0) { + const currentFailedTests = extractFailedTestIds(data.hierarchy, status); + allFailedTests = allFailedTests.concat(currentFailedTests); + } + + // Check for pagination termination conditions + if ( + !data.pagination?.has_next || + !data.pagination.next_page || + requestNumber >= 5 + ) { + break; + } + + const params: Record = { + next_page: data.pagination.next_page, + }; + if (status) params.test_statuses = status; + + url = `${baseUrl}?${new URLSearchParams(params).toString()}`; + } + + // Return unique failed test IDs + return allFailedTests; + } catch (error) { + logger.error("Error fetching failed tests:", error); + throw error; + } +} + +// Recursive function to extract failed test IDs from hierarchy +function extractFailedTestIds( + hierarchy: TestDetails[], + status?: TestStatus, +): FailedTestInfo[] { + let failedTests: FailedTestInfo[] = []; + + for (const node of hierarchy) { + if (node.details?.status === status && node.details?.run_count) { + if (node.details?.observability_url) { + const idMatch = node.details.observability_url.match(/details=(\d+)/); + if (idMatch) { + failedTests.push({ + test_id: idMatch[1], + test_name: node.display_name || `Test ${idMatch[1]}`, + }); + } + } + } + + if (node.children && node.children.length > 0) { + failedTests = failedTests.concat( + extractFailedTestIds(node.children, status), + ); + } + } + + return failedTests; +} diff --git a/src/tools/rca-agent-utils/rca-data.ts b/src/tools/rca-agent-utils/rca-data.ts new file mode 100644 index 00000000..de7949f5 --- /dev/null +++ b/src/tools/rca-agent-utils/rca-data.ts @@ -0,0 +1,285 @@ +import { RCAState, RCATestCase, RCAResponse } from "./types.js"; + +interface ScanProgressContext { + sendNotification: (notification: any) => Promise; + _meta?: { + progressToken?: string | number; + }; +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function isInProgressState(state: RCAState): boolean { + return [ + RCAState.PENDING, + RCAState.FETCHING_LOGS, + RCAState.GENERATING_RCA, + RCAState.GENERATED_RCA, + ].includes(state); +} + +function isFailedState(state: RCAState): boolean { + return [ + RCAState.FAILED, + RCAState.LLM_SERVICE_ERROR, + RCAState.LOG_FETCH_ERROR, + RCAState.UNKNOWN_ERROR, + RCAState.TIMEOUT, + ].includes(state); +} + +function calculateProgress( + resolvedCount: number, + totalCount: number, + baseProgress: number = 10, +): number { + if (totalCount === 0) return 100; // ✅ fix divide by zero + const progressRange = 90 - baseProgress; + const completionProgress = (resolvedCount / totalCount) * progressRange; + return Math.min(100, baseProgress + completionProgress); +} + +// ✅ centralized mapping function +function mapApiState(apiState?: string): RCAState { + const state = apiState?.toLowerCase(); + switch (state) { + case "completed": + return RCAState.COMPLETED; + case "pending": + return RCAState.PENDING; + case "fetching_logs": + return RCAState.FETCHING_LOGS; + case "generating_rca": + return RCAState.GENERATING_RCA; + case "generated_rca": + return RCAState.GENERATED_RCA; + case "error": + return RCAState.UNKNOWN_ERROR; + default: + return RCAState.UNKNOWN_ERROR; + } +} + +async function notifyProgress( + context: ScanProgressContext | undefined, + message: string, + progress: number, +) { + if (!context?.sendNotification) return; + + await context.sendNotification({ + method: "notifications/progress", + params: { + progressToken: context._meta?.progressToken?.toString(), + message, + progress, + total: 100, + }, + }); +} + +async function updateProgress( + context: ScanProgressContext | undefined, + testCases: RCATestCase[], + message?: string, +) { + const inProgressCases = testCases.filter((tc) => isInProgressState(tc.state)); + const resolvedCount = testCases.length - inProgressCases.length; + + await notifyProgress( + context, + message ?? + `RCA analysis in progress (${resolvedCount}/${testCases.length} resolved)`, + inProgressCases.length === 0 + ? 100 + : calculateProgress(resolvedCount, testCases.length), + ); +} + +async function fetchInitialRCA( + testId: number, + headers: Record, + baseUrl: string, +): Promise { + const url = baseUrl.replace("{testId}", testId.toString()); + + try { + const response = await fetch(url, { headers }); + + if (!response.ok) { + return { + id: testId, + testRunId: testId, + state: RCAState.LOG_FETCH_ERROR, + rcaData: { + error: `HTTP ${response.status}: Failed to start RCA analysis`, + }, + }; + } + + const data = await response.json(); + const resultState = mapApiState(data.state); + + return { + id: testId, + testRunId: testId, + state: resultState, + ...(resultState === RCAState.COMPLETED && { rcaData: data }), + ...(isFailedState(resultState) && + data.state && { + rcaData: { + error: `API returned state: ${data.state}`, + originalResponse: data, + }, + }), + }; + } catch (error) { + return { + id: testId, + testRunId: testId, + state: RCAState.LLM_SERVICE_ERROR, + rcaData: { + error: + error instanceof Error ? error.message : "Network or parsing error", + }, + }; + } +} + +async function pollRCAResults( + testCases: RCATestCase[], + headers: Record, + baseUrl: string, + context: ScanProgressContext | undefined, + pollInterval: number, + timeout: number, + initialDelay: number, +): Promise { + const startTime = Date.now(); + await delay(initialDelay); + + try { + while (true) { + const inProgressCases = testCases.filter((tc) => + isInProgressState(tc.state), + ); + await updateProgress(context, testCases); + + if (inProgressCases.length === 0) break; + + if (Date.now() - startTime >= timeout) { + inProgressCases.forEach((tc) => { + tc.state = RCAState.TIMEOUT; + tc.rcaData = { error: `Timeout after ${timeout}ms` }; + }); + await updateProgress(context, testCases, "RCA analysis timed out"); + break; + } + + await Promise.allSettled( + inProgressCases.map(async (tc) => { + try { + const pollUrl = baseUrl.replace("{testId}", tc.id.toString()); + const response = await fetch(pollUrl, { headers }); + if (!response.ok) { + const errorText = await response.text(); + tc.state = RCAState.LOG_FETCH_ERROR; + tc.rcaData = { + error: `HTTP ${response.status}: Polling failed - ${errorText}`, + }; + return; + } + + const data = await response.json(); + if (!isFailedState(tc.state)) { + const mappedState = mapApiState(data.state); + tc.state = mappedState; + + if (mappedState === RCAState.COMPLETED) { + tc.rcaData = data; + } else if (mappedState === RCAState.UNKNOWN_ERROR) { + tc.rcaData = { + error: `API returned state: ${data.state}`, + originalResponse: data, + }; + } + } + } catch (err) { + if (!isFailedState(tc.state)) { + tc.state = RCAState.LLM_SERVICE_ERROR; + tc.rcaData = { + error: + err instanceof Error + ? err.message + : "Network or parsing error", + }; + } + } + }), + ); + + await delay(pollInterval); + } + } catch (err) { + testCases + .filter((tc) => isInProgressState(tc.state)) + .forEach((tc) => { + tc.state = RCAState.UNKNOWN_ERROR; + tc.rcaData = { + error: err instanceof Error ? err.message : "Unexpected error", + }; + }); + await updateProgress( + context, + testCases, + "RCA analysis failed due to unexpected error", + ); + } + + return { testCases }; +} + +export async function getRCAData( + testIds: number[], + authString: string, + context?: ScanProgressContext, +): Promise { + const pollInterval = 5000; + const timeout = 40000; + const initialDelay = 20000; + + const baseUrl = + "https://api-observability.browserstack.com/ext/v1/testRun/{testId}/testRca"; + const headers = { + Authorization: `Basic ${Buffer.from(authString).toString("base64")}`, + "Content-Type": "application/json", + }; + + await notifyProgress(context, "Starting RCA analysis for test cases...", 0); + + const testCases = await Promise.all( + testIds.map((testId) => fetchInitialRCA(testId, headers, baseUrl)), + ); + + const inProgressCount = testCases.filter((tc) => + isInProgressState(tc.state), + ).length; + + await notifyProgress( + context, + `Initial RCA requests completed. ${inProgressCount} cases pending analysis...`, + 10, + ); + + if (inProgressCount === 0) return { testCases }; + + return await pollRCAResults( + testCases, + headers, + baseUrl, + context, + pollInterval, + timeout, + initialDelay, + ); +} diff --git a/src/tools/rca-agent-utils/types.ts b/src/tools/rca-agent-utils/types.ts new file mode 100644 index 00000000..41294d21 --- /dev/null +++ b/src/tools/rca-agent-utils/types.ts @@ -0,0 +1,55 @@ +export enum TestStatus { + PASSED = "passed", + FAILED = "failed", + PENDING = "pending", + SKIPPED = "skipped", +} + +export interface TestDetails { + status: TestStatus; + details: any; + children?: TestDetails[]; + display_name?: string; +} + +export interface TestRun { + hierarchy: TestDetails[]; + pagination?: { + has_next: boolean; + next_page: string | null; + }; +} + +export interface FailedTestInfo { + test_id: number; + test_name: string; +} + +export enum RCAState { + PENDING = "pending", + FETCHING_LOGS = "fetching_logs", + GENERATING_RCA = "generating_rca", + GENERATED_RCA = "generated_rca", + COMPLETED = "completed", + FAILED = "failed", + LLM_SERVICE_ERROR = "LLM_SERVICE_ERROR", + LOG_FETCH_ERROR = "LOG_FETCH_ERROR", + UNKNOWN_ERROR = "UNKNOWN_ERROR", + TIMEOUT = "TIMEOUT", +} + +export interface RCATestCase { + id: number; + testRunId: number; + state: RCAState; + rcaData?: any; +} + +export interface RCAResponse { + testCases: RCATestCase[]; +} + +export interface BuildIdArgs { + browserStackProjectName: string; + browserStackBuildName: string; +} diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts new file mode 100644 index 00000000..920828f1 --- /dev/null +++ b/src/tools/rca-agent.ts @@ -0,0 +1,176 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import logger from "../logger.js"; +import { BrowserStackConfig } from "../lib/types.js"; +import { getBrowserStackAuth } from "../lib/get-auth.js"; +import { getBuildId } from "./rca-agent-utils/get-build-id.js"; +import { getTestIds } from "./rca-agent-utils/get-failed-test-id.js"; +import { getRCAData } from "./rca-agent-utils/rca-data.js"; +import { formatRCAData } from "./rca-agent-utils/format-rca.js"; +import { TestStatus } from "./rca-agent-utils/types.js"; +import { handleMCPError } from "../lib/utils.js"; +import { trackMCP } from "../index.js"; +import { BuildIdArgs } from "./rca-agent-utils/types.js"; +import { + FETCH_RCA_PARAMS, + GET_BUILD_ID_PARAMS, + LIST_TEST_IDS_PARAMS, +} from "./rca-agent-utils/constants.js"; + +// Tool function to fetch build ID +export async function getBuildIdTool( + args: BuildIdArgs, + config: BrowserStackConfig, +): Promise { + try { + const { browserStackProjectName, browserStackBuildName } = args; + + const authString = getBrowserStackAuth(config); + const [username, accessKey] = authString.split(":"); + + const buildId = await getBuildId( + browserStackProjectName, + browserStackBuildName, + username, + accessKey, + ); + + return { + content: [ + { + type: "text", + text: buildId, + }, + ], + }; + } catch (error) { + logger.error("Error fetching build ID", error); + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return { + content: [ + { + type: "text", + text: `Error fetching build ID: ${errorMessage}`, + }, + ], + }; + } +} + +// Tool function that fetches RCA data +export async function fetchRCADataTool( + args: { testId: number[] }, + config: BrowserStackConfig, +): Promise { + try { + const authString = getBrowserStackAuth(config); + + // Limit to first 3 test IDs for performance + const testIds = args.testId; + + const rcaData = await getRCAData(testIds, authString); + + const formattedData = formatRCAData(rcaData); + + return { + content: [ + { + type: "text", + text: formattedData, + }, + ], + }; + } catch (error) { + logger.error("Error fetching RCA data", error); + throw error; + } +} + +export async function listTestIdsTool( + args: { + buildId: string; + status?: TestStatus; + }, + config: BrowserStackConfig, +): Promise { + try { + const { buildId, status } = args; + const authString = getBrowserStackAuth(config); + + // Get test IDs + const testIds = await getTestIds(buildId, authString, status); + + return { + content: [ + { + type: "text", + text: JSON.stringify(testIds, null, 2), + }, + ], + }; + } catch (error) { + logger.error("Error listing test IDs", error); + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return { + content: [ + { + type: "text", + text: `Error listing test IDs: ${errorMessage}`, + }, + ], + }; + } +} + +export default function addRCATools( + server: McpServer, + config: BrowserStackConfig, +) { + const tools: Record = {}; + + tools.fetchRCA = server.tool( + "fetchRCA", + "Retrieves AI-RCA (Root Cause Analysis) data for a BrowserStack Automate and App-Automate session and provides insights into test failures.", + FETCH_RCA_PARAMS, + async (args) => { + try { + trackMCP("fetchRCA", server.server.getClientVersion()!, config); + return await fetchRCADataTool(args, config); + } catch (error) { + return handleMCPError("fetchRCA", server, config, error); + } + }, + ); + + tools.getBuildId = server.tool( + "getBuildId", + "Get the BrowserStack build ID for a given project and build name.", + GET_BUILD_ID_PARAMS, + async (args) => { + try { + trackMCP("getBuildId", server.server.getClientVersion()!, config); + return await getBuildIdTool(args, config); + } catch (error) { + return handleMCPError("getBuildId", server, config, error); + } + }, + ); + + tools.listTestIds = server.tool( + "listTestIds", + "List test IDs from a BrowserStack Automate build, optionally filtered by status", + LIST_TEST_IDS_PARAMS, + async (args) => { + try { + trackMCP("listTestIds", server.server.getClientVersion()!, config); + return await listTestIdsTool(args, config); + } catch (error) { + return handleMCPError("listTestIds", server, config, error); + } + }, + ); + + return tools; +} diff --git a/src/tools/review-agent-utils/build-counts.ts b/src/tools/review-agent-utils/build-counts.ts new file mode 100644 index 00000000..63acbda9 --- /dev/null +++ b/src/tools/review-agent-utils/build-counts.ts @@ -0,0 +1,51 @@ +// Utility for fetching the count of Percy builds and orgId. +export async function getPercyBuildCount(percyToken: string) { + const apiUrl = `https://percy.io/api/v1/builds`; + + const response = await fetch(apiUrl, { + headers: { + Authorization: `Token token=${percyToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Percy builds: ${response.statusText}`); + } + + const data = await response.json(); + const builds = data.data ?? []; + const included = data.included ?? []; + + let isFirstBuild = false; + let lastBuildId: string | undefined; + let orgId: string | undefined; + let browserIds: string[] = []; + + if (builds.length === 0) { + return { + noBuilds: true, + isFirstBuild: false, + lastBuildId: undefined, + orgId, + browserIds: [], + }; + } else { + isFirstBuild = builds.length === 1; + lastBuildId = builds[0].id; + } + + // Extract browserIds from the latest build if available + browserIds = + builds[0]?.relationships?.browsers?.data + ?.map((b: any) => b.id) + ?.filter((id: any) => typeof id === "string") ?? []; + + // Extract orgId from the `included` projects block + const project = included.find((item: any) => item.type === "projects"); + if (project?.relationships?.organization?.data?.id) { + orgId = project.relationships.organization.data.id; + } + + return { noBuilds: false, isFirstBuild, lastBuildId, orgId, browserIds }; +} diff --git a/src/tools/review-agent-utils/percy-approve-reject.ts b/src/tools/review-agent-utils/percy-approve-reject.ts new file mode 100644 index 00000000..ba4bd439 --- /dev/null +++ b/src/tools/review-agent-utils/percy-approve-reject.ts @@ -0,0 +1,53 @@ +import { BrowserStackConfig } from "../../lib/types.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function approveOrDeclinePercyBuild( + args: { buildId: string; action: "approve" | "unapprove" | "reject" }, + config: BrowserStackConfig, +): Promise { + const { buildId, action } = args; + + // Get Basic Auth credentials + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); + + // Prepare request body + const body = { + data: { + type: "reviews", + attributes: { action }, + relationships: { + build: { data: { type: "builds", id: buildId } }, + }, + }, + }; + + // Send request to Percy API + const response = await fetch("https://percy.io/api/v1/reviews", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${auth}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Percy build ${action} failed: ${response.status} ${errorText}`, + ); + } + + const result = await response.json(); + + return { + content: [ + { + type: "text", + text: `Percy build ${buildId} was ${result.data.attributes["review-state"]} by ${result.data.attributes["action-performed-by"].user_name}`, + }, + ], + }; +} diff --git a/src/tools/review-agent-utils/percy-diffs.ts b/src/tools/review-agent-utils/percy-diffs.ts new file mode 100644 index 00000000..1b6a9efe --- /dev/null +++ b/src/tools/review-agent-utils/percy-diffs.ts @@ -0,0 +1,60 @@ +export interface PercySnapshotDiff { + id: string; + name: string | null; + title: string; + description: string | null; + coordinates: any; +} + +export async function getPercySnapshotDiff( + snapshotId: string, + percyToken: string, +): Promise { + const apiUrl = `https://percy.io/api/v1/snapshots/${snapshotId}`; + + const response = await fetch(apiUrl, { + headers: { + Authorization: `Token token=${percyToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch Percy snapshot ${snapshotId}: ${response.statusText}`, + ); + } + + const data = await response.json(); + const pageUrl = data.data.attributes?.name || null; + + const changes: PercySnapshotDiff[] = []; + const comparisons = + data.included?.filter((item: any) => item.type === "comparisons") ?? []; + + for (const comparison of comparisons) { + const appliedRegions = comparison.attributes?.["applied-regions"] ?? []; + for (const region of appliedRegions) { + if (region.ignored) continue; + changes.push({ + id: String(region.id), + name: pageUrl, + title: region.change_title, + description: region.change_description ?? null, + coordinates: region.coordinates ?? null, + }); + } + } + + return changes; +} + +export async function getPercySnapshotDiffs( + snapshotIds: string[], + percyToken: string, +): Promise { + const allDiffs = await Promise.all( + snapshotIds.map((id) => getPercySnapshotDiff(id, percyToken)), + ); + return allDiffs.flat(); +} diff --git a/src/tools/review-agent-utils/percy-snapshots.ts b/src/tools/review-agent-utils/percy-snapshots.ts new file mode 100644 index 00000000..11a48717 --- /dev/null +++ b/src/tools/review-agent-utils/percy-snapshots.ts @@ -0,0 +1,105 @@ +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; +import { sanitizeUrlParam } from "../../lib/utils.js"; + +// Utility for fetching only the IDs of changed Percy snapshots for a given build. +export async function getChangedPercySnapshotIds( + buildId: string, + config: BrowserStackConfig, + orgId: string | undefined, + browserIds: string[], +): Promise { + if (!buildId || !orgId) { + throw new Error( + "Failed to fetch AI Summary: Missing build ID or organization ID", + ); + } + + const urlStr = constructPercyBuildItemsUrl({ + buildId, + orgId, + category: ["changed"], + subcategories: ["unreviewed", "approved", "changes_requested"], + groupSnapshotsBy: "similar_diff", + browserIds, + widths: ["375", "1280", "1920"], + }); + + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); + const response = await fetch(urlStr, { + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch changed Percy snapshots: ${response.status} ${response.statusText}`, + ); + } + + const responseData = await response.json(); + const buildItems = responseData.data ?? []; + + if (buildItems.length === 0) { + return []; + } + + const snapshotIds = buildItems + .flatMap((item: any) => item.attributes?.["snapshot-ids"] ?? []) + .map((id: any) => String(id)); + + return snapshotIds; +} + +export function constructPercyBuildItemsUrl({ + buildId, + orgId, + category = [], + subcategories = [], + browserIds = [], + widths = [], + groupSnapshotsBy, +}: { + buildId: string; + orgId: string; + category?: string[]; + subcategories?: string[]; + browserIds?: string[]; + widths?: string[]; + groupSnapshotsBy?: string; +}): string { + const url = new URL("https://percy.io/api/v1/build-items"); + url.searchParams.set("filter[build-id]", sanitizeUrlParam(buildId)); + url.searchParams.set("filter[organization-id]", sanitizeUrlParam(orgId)); + + if (category && category.length > 0) { + category.forEach((cat) => + url.searchParams.append("filter[category][]", sanitizeUrlParam(cat)), + ); + } + if (subcategories && subcategories.length > 0) { + subcategories.forEach((sub) => + url.searchParams.append("filter[subcategories][]", sanitizeUrlParam(sub)), + ); + } + if (browserIds && browserIds.length > 0) { + browserIds.forEach((id) => + url.searchParams.append("filter[browser_ids][]", sanitizeUrlParam(id)), + ); + } + if (widths && widths.length > 0) { + widths.forEach((w) => + url.searchParams.append("filter[widths][]", sanitizeUrlParam(w)), + ); + } + if (groupSnapshotsBy) { + url.searchParams.set( + "filter[group_snapshots_by]", + sanitizeUrlParam(groupSnapshotsBy), + ); + } + return url.toString(); +} diff --git a/src/tools/review-agent.ts b/src/tools/review-agent.ts new file mode 100644 index 00000000..cf87fef5 --- /dev/null +++ b/src/tools/review-agent.ts @@ -0,0 +1,80 @@ +import { BrowserStackConfig } from "../lib/types.js"; +import { getBrowserStackAuth } from "../lib/get-auth.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { getPercyBuildCount } from "./review-agent-utils/build-counts.js"; +import { getChangedPercySnapshotIds } from "./review-agent-utils/percy-snapshots.js"; +import { PercyIntegrationTypeEnum } from "./sdk-utils/common/types.js"; +import { fetchPercyToken } from "./sdk-utils/percy-web/fetchPercyToken.js"; + +import { + getPercySnapshotDiffs, + PercySnapshotDiff, +} from "./review-agent-utils/percy-diffs.js"; + +export async function fetchPercyChanges( + args: { project_name: string }, + config: BrowserStackConfig, +): Promise { + const { project_name } = args; + const authorization = getBrowserStackAuth(config); + + // Get Percy token for the project + const percyToken = await fetchPercyToken(project_name, authorization, { + type: PercyIntegrationTypeEnum.WEB, + }); + + // Get build info (noBuilds, isFirstBuild, lastBuildId) + const { noBuilds, isFirstBuild, lastBuildId, orgId, browserIds } = + await getPercyBuildCount(percyToken); + + if (noBuilds) { + return { + content: [ + { + type: "text", + text: "No Percy builds found. Please run your first Percy scan to start visual testing.", + }, + ], + }; + } + + if (isFirstBuild || !lastBuildId) { + return { + content: [ + { + type: "text", + text: "This is the first Percy build. No baseline exists to compare changes.", + }, + ], + }; + } + + // Get snapshot IDs for the latest build + const snapshotIds = await getChangedPercySnapshotIds( + lastBuildId, + config, + orgId, + browserIds, + ); + + // Fetch all diffs concurrently and flatten results + const allDiffs = await getPercySnapshotDiffs(snapshotIds, percyToken); + + if (allDiffs.length === 0) { + return { + content: [ + { + type: "text", + text: "AI Summary is not yet available for this build/framework. There may still be visual changes—please review the build on the dashboard.", + }, + ], + }; + } + + return { + content: allDiffs.map((diff: PercySnapshotDiff) => ({ + type: "text", + text: `${diff.name} → ${diff.title}: ${diff.description ?? ""}`, + })), + }; +} diff --git a/src/tools/run-percy-scan.ts b/src/tools/run-percy-scan.ts new file mode 100644 index 00000000..6302dc1a --- /dev/null +++ b/src/tools/run-percy-scan.ts @@ -0,0 +1,117 @@ +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { PercyIntegrationTypeEnum } from "./sdk-utils/common/types.js"; +import { BrowserStackConfig } from "../lib/types.js"; +import { getBrowserStackAuth } from "../lib/get-auth.js"; +import { fetchPercyToken } from "./sdk-utils/percy-web/fetchPercyToken.js"; +import { storedPercyResults } from "../lib/inmemory-store.js"; +import { + getFrameworkTestCommand, + PERCY_FALLBACK_STEPS, +} from "./sdk-utils/percy-web/constants.js"; +import path from "path"; + +export async function runPercyScan( + args: { + projectName: string; + integrationType: PercyIntegrationTypeEnum; + instruction?: string; + }, + config: BrowserStackConfig, +): Promise { + const { projectName, integrationType, instruction } = args; + const authorization = getBrowserStackAuth(config); + const percyToken = await fetchPercyToken(projectName, authorization, { + type: integrationType, + }); + + // Check if we have stored data and project matches + const stored = storedPercyResults.get(); + + // Compute if we have updated files to run + const hasUpdatedFiles = checkForUpdatedFiles(stored, projectName); + const updatedFiles = hasUpdatedFiles ? getUpdatedFiles(stored) : []; + + // Build steps array with conditional spread + const steps = [ + generatePercyTokenInstructions(percyToken), + ...(hasUpdatedFiles ? generateUpdatedFilesSteps(stored, updatedFiles) : []), + ...(instruction && !hasUpdatedFiles + ? generateInstructionSteps(instruction) + : []), + ...(!hasUpdatedFiles ? PERCY_FALLBACK_STEPS : []), + ]; + + const instructionContext = steps + .map((step, index) => `${index + 1}. ${step}`) + .join("\n\n"); + + return { + content: [ + { + type: "text", + text: instructionContext, + }, + ], + }; +} + +function generatePercyTokenInstructions(percyToken: string): string { + return `Set the environment variable for your project: + +export PERCY_TOKEN="${percyToken}" + +(For Windows: use 'setx PERCY_TOKEN "${percyToken}"' or 'set PERCY_TOKEN=${percyToken}' as appropriate.)`; +} + +const toAbs = (p: string): string | undefined => + p ? path.resolve(p) : undefined; + +function checkForUpdatedFiles( + stored: any, // storedPercyResults structure + projectName: string, +): boolean { + const projectMatches = stored?.projectName === projectName; + return ( + projectMatches && + stored?.testFiles && + Object.values(stored.testFiles).some((status) => status === true) + ); +} + +function getUpdatedFiles(stored: any): string[] { + const updatedFiles: string[] = []; + const fileStatusMap = stored.testFiles; + + Object.entries(fileStatusMap).forEach(([filePath, status]) => { + if (status === true) { + updatedFiles.push(filePath); + } + }); + + return updatedFiles; +} + +function generateUpdatedFilesSteps( + stored: any, + updatedFiles: string[], +): string[] { + const filesToRun = updatedFiles.map(toAbs).filter(Boolean) as string[]; + const { detectedLanguage, detectedTestingFramework } = stored; + const exampleCommand = getFrameworkTestCommand( + detectedLanguage, + detectedTestingFramework, + ); + + return [ + `Run only the updated files with Percy:\n` + + `Example: ${exampleCommand} ...`, + `Updated files to run:\n${filesToRun.join("\n")}`, + ]; +} + +function generateInstructionSteps(instruction: string): string[] { + return [ + `Use the provided test command with Percy:\n${instruction}`, + `If this command fails or is incorrect, fall back to the default approach below.`, + ]; +} diff --git a/src/tools/sdk-utils/bstack/commands.ts b/src/tools/sdk-utils/bstack/commands.ts new file mode 100644 index 00000000..1f600d72 --- /dev/null +++ b/src/tools/sdk-utils/bstack/commands.ts @@ -0,0 +1,123 @@ +import { SDKSupportedLanguage } from "../common/types.js"; + +// Constants +const MAVEN_ARCHETYPE_GROUP_ID = "com.browserstack"; +const MAVEN_ARCHETYPE_ARTIFACT_ID = "browserstack-sdk-archetype-integrate"; +const MAVEN_ARCHETYPE_VERSION = "1.0"; + +// Mapping of test frameworks to their corresponding Maven archetype framework names +const JAVA_FRAMEWORK_MAP: Record = { + testng: "testng", + junit5: "junit5", + junit4: "junit4", + cucumber: "cucumber-testng", +} as const; + +// Template for Node.js SDK setup instructions +const NODEJS_SDK_INSTRUCTIONS = ( + username: string, + accessKey: string, +): string => `---STEP--- +Install BrowserStack Node SDK using command: +\`\`\`bash +npm i -D browserstack-node-sdk@latest +\`\`\` +---STEP--- +Run the following command to setup browserstack sdk: +\`\`\`bash +npx setup --username ${username} --key ${accessKey} +\`\`\``; + +// Template for Gradle setup instructions (platform-independent) +const GRADLE_SETUP_INSTRUCTIONS = ` +**For Gradle setup:** +1. Add browserstack-java-sdk to dependencies: + compileOnly 'com.browserstack:browserstack-java-sdk:latest.release' + +2. Add browserstackSDK path variable: + def browserstackSDKArtifact = configurations.compileClasspath.resolvedConfiguration.resolvedArtifacts.find { it.name == 'browserstack-java-sdk' } + +3. Add javaagent to gradle tasks: + jvmArgs "-javaagent:\${browserstackSDKArtifact.file}" +`; + +// Generates Maven archetype command for Windows platform +function getMavenCommandForWindows( + framework: string, + mavenFramework: string, +): string { + return ( + `mvn archetype:generate -B ` + + `-DarchetypeGroupId="${MAVEN_ARCHETYPE_GROUP_ID}" ` + + `-DarchetypeArtifactId="${MAVEN_ARCHETYPE_ARTIFACT_ID}" ` + + `-DarchetypeVersion="${MAVEN_ARCHETYPE_VERSION}" ` + + `-DgroupId="${MAVEN_ARCHETYPE_GROUP_ID}" ` + + `-DartifactId="${MAVEN_ARCHETYPE_ARTIFACT_ID}" ` + + `-Dversion="${MAVEN_ARCHETYPE_VERSION}" ` + + `-DBROWSERSTACK_USERNAME="${process.env.BROWSERSTACK_USERNAME}" ` + + `-DBROWSERSTACK_ACCESS_KEY="${process.env.BROWSERSTACK_ACCESS_KEY}" ` + + `-DBROWSERSTACK_FRAMEWORK="${mavenFramework}"` + ); +} + +// Generates Maven archetype command for Unix-like platforms (macOS/Linux) +function getMavenCommandForUnix( + username: string, + accessKey: string, + mavenFramework: string, +): string { + return `mvn archetype:generate -B -DarchetypeGroupId=${MAVEN_ARCHETYPE_GROUP_ID} \\ +-DarchetypeArtifactId=${MAVEN_ARCHETYPE_ARTIFACT_ID} -DarchetypeVersion=${MAVEN_ARCHETYPE_VERSION} \\ +-DgroupId=${MAVEN_ARCHETYPE_GROUP_ID} -DartifactId=${MAVEN_ARCHETYPE_ARTIFACT_ID} -Dversion=${MAVEN_ARCHETYPE_VERSION} \\ +-DBROWSERSTACK_USERNAME="${username}" \\ +-DBROWSERSTACK_ACCESS_KEY="${accessKey}" \\ +-DBROWSERSTACK_FRAMEWORK="${mavenFramework}"`; +} + +// Generates Java SDK setup instructions with Maven/Gradle options +function getJavaSDKInstructions( + framework: string, + username: string, + accessKey: string, +): string { + const mavenFramework = getJavaFrameworkForMaven(framework); + const isWindows = process.platform === "win32"; + const platformLabel = isWindows ? "Windows" : "macOS/Linux"; + + const mavenCommand = isWindows + ? getMavenCommandForWindows(framework, mavenFramework) + : getMavenCommandForUnix(username, accessKey, mavenFramework); + + return `---STEP--- +Install BrowserStack Java SDK + +**Maven command for ${framework} (${platformLabel}):** +Run the command, it is required to generate the browserstack-sdk-archetype-integrate project: +${mavenCommand} + +Alternative setup for Gradle users: +${GRADLE_SETUP_INSTRUCTIONS}`; +} + +// Main function to get SDK setup commands based on language and framework +export function getSDKPrefixCommand( + language: SDKSupportedLanguage, + framework: string, + username: string, + accessKey: string, +): string { + switch (language) { + case "nodejs": + return NODEJS_SDK_INSTRUCTIONS(username, accessKey); + + case "java": + return getJavaSDKInstructions(framework, username, accessKey); + + default: + return ""; + } +} + +export function getJavaFrameworkForMaven(framework: string): string { + return JAVA_FRAMEWORK_MAP[framework] || framework; +} diff --git a/src/tools/sdk-utils/bstack/configUtils.ts b/src/tools/sdk-utils/bstack/configUtils.ts new file mode 100644 index 00000000..a38f13ae --- /dev/null +++ b/src/tools/sdk-utils/bstack/configUtils.ts @@ -0,0 +1,144 @@ +import { ValidatedEnvironment } from "../common/device-validator.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { getBrowserStackAuth } from "../../../lib/get-auth.js"; + +export function generateBrowserStackYMLInstructions( + config: { + validatedEnvironments?: ValidatedEnvironment[]; + platforms?: string[]; + enablePercy?: boolean; + projectName: string; + }, + browserStackConfig: BrowserStackConfig, +): string { + const enablePercy = config.enablePercy || false; + const projectName = config.projectName || "BrowserStack Automate Build"; + + // Get credentials from config + const authString = getBrowserStackAuth(browserStackConfig); + const [username, accessKey] = authString.split(":"); + + // Generate platform configurations using the utility function + const platformConfigs = generatePlatformConfigs(config); + + const stepTitle = + "Create a browserstack.yml file in the project root with your validated device configurations:If already exists, update it with the following content for devices and project details."; + + const buildName = `${projectName}-Build`; + + let ymlContent = ` +# ====================== +# BrowserStack Reporting +# ====================== + +userName: ${username} +accessKey: ${accessKey} + +# TODO: Replace these sample values with your actual project details +projectName: ${projectName} +buildName: ${buildName} + +# ======================================= +# Platforms (Browsers / Devices to test) +# =======================================`; + + ymlContent += ` +# Platforms object contains all the browser / device combinations you want to test on. +platforms: +${platformConfigs}`; + + ymlContent += ` + +# ======================= +# Parallels per Platform +# ======================= +# The number of parallel threads to be used for each platform set. +# BrowserStack's SDK runner will select the best strategy based on the configured value +# The number of parallel threads to be used for each platform set. +parallelsPerPlatform: 1 + +# ================= +# Local Testing +# ================= +# Set to true to test local +browserstackLocal: true + +# =================== +# Debugging features +# =================== +debug: true # Visual logs, text logs, etc. +testObservability: true # For Test Observability`; + + if (enablePercy) { + ymlContent += ` + +# ===================== +# Percy Visual Testing +# ===================== +# Set percy to true to enable visual testing. +# Set percyCaptureMode to 'manual' to control when screenshots are taken. +percy: true +percyCaptureMode: manual`; + } + + return ` +---STEP--- +${stepTitle} + +\`\`\`yaml${ymlContent} +\`\`\` +\n`; +} + +function generatePlatformConfigs(config: { + validatedEnvironments?: ValidatedEnvironment[]; + platforms?: string[]; +}): string { + if (config.validatedEnvironments && config.validatedEnvironments.length > 0) { + // Generate platforms array from validated environments + const platforms = config.validatedEnvironments.map((env) => { + if (env.platform === "windows" || env.platform === "macos") { + // Desktop configuration + return { + os: env.platform === "windows" ? "Windows" : "OS X", + osVersion: env.osVersion, + browserName: env.browser, + browserVersion: env.browserVersion || "latest", + }; + } else { + // Mobile configuration (android/ios) + return { + deviceName: env.deviceName, + osVersion: env.osVersion, + browserName: env.browser, + }; + } + }); + + // Convert platforms to YAML format + return platforms + .map((platform) => { + if (platform.deviceName) { + // Mobile platform + return ` - deviceName: "${platform.deviceName}" + osVersion: "${platform.osVersion}" + browserName: ${platform.browserName}`; + } else { + // Desktop platform + return ` - os: ${platform.os} + osVersion: "${platform.osVersion}" + browserName: ${platform.browserName} + browserVersion: ${platform.browserVersion}`; + } + }) + .join("\n"); + } else if (config.platforms && config.platforms.length > 0) { + // Fallback to default platforms configuration + return ` - os: Windows + osVersion: 11 + browserName: chrome + browserVersion: latest`; + } + + return ""; +} diff --git a/src/tools/sdk-utils/constants.ts b/src/tools/sdk-utils/bstack/constants.ts similarity index 89% rename from src/tools/sdk-utils/constants.ts rename to src/tools/sdk-utils/bstack/constants.ts index 6bd0c075..47a7bac8 100644 --- a/src/tools/sdk-utils/constants.ts +++ b/src/tools/sdk-utils/bstack/constants.ts @@ -1,34 +1,39 @@ -import { ConfigMapping } from "./types.js"; +import { ConfigMapping } from "../common/types.js"; /** * ---------- PYTHON INSTRUCTIONS ---------- */ -const pythonInstructions = (username: string, accessKey: string) => ` +export const pythonInstructions = () => { + const setup = ` ---STEP--- Install the BrowserStack SDK: \`\`\`bash python3 -m pip install browserstack-sdk \`\`\` +`; + const run = ` ---STEP--- -Setup the BrowserStack SDK with your credentials: +Run your tests on BrowserStack: \`\`\`bash -browserstack-sdk setup --username "${username}" --key "${accessKey}" +browserstack-sdk pytest -s tests/.py \`\`\` ----STEP--- - -Run your tests on BrowserStack: +Or run all tests in a directory: \`\`\`bash -browserstack-sdk python +browserstack-sdk pytest \`\`\` `; -const generatePythonFrameworkInstructions = - (framework: string) => (username: string, accessKey: string) => ` + return { setup, run }; +}; + +export const generatePythonFrameworkInstructions = + (framework: string) => (username: string, accessKey: string) => { + const setup = ` ---STEP--- Install the BrowserStack SDK: @@ -43,7 +48,9 @@ Setup the BrowserStack SDK with framework-specific configuration: \`\`\`bash browserstack-sdk setup --framework "${framework}" --username "${username}" --key "${accessKey}" \`\`\` +`; + const run = ` ---STEP--- Run your ${framework} tests on BrowserStack: @@ -52,9 +59,12 @@ browserstack-sdk ${framework} \`\`\` `; -const robotInstructions = generatePythonFrameworkInstructions("robot"); -const behaveInstructions = generatePythonFrameworkInstructions("behave"); -const pytestInstructions = generatePythonFrameworkInstructions("pytest"); + return { setup, run }; + }; + +export const robotInstructions = generatePythonFrameworkInstructions("robot"); +export const behaveInstructions = generatePythonFrameworkInstructions("behave"); +export const pytestInstructions = generatePythonFrameworkInstructions("pytest"); /** * ---------- JAVA INSTRUCTIONS ---------- @@ -63,7 +73,8 @@ const pytestInstructions = generatePythonFrameworkInstructions("pytest"); const argsInstruction = '-javaagent:"${com.browserstack:browserstack-java-sdk:jar}"'; -const javaInstructions = (username: string, accessKey: string) => ` +export const javaInstructions = (username: string, accessKey: string) => { + const setup = ` ---STEP--- Add the BrowserStack Java SDK dependency to your \`pom.xml\`: @@ -92,7 +103,9 @@ Export your BrowserStack credentials as environment variables: export BROWSERSTACK_USERNAME=${username} export BROWSERSTACK_ACCESS_KEY=${accessKey} \`\`\` +`; + const run = ` ---STEP--- Run your tests using Maven: @@ -106,68 +119,18 @@ gradle clean test \`\`\` `; -const serenityInstructions = (username: string, accessKey: string) => ` ----STEP--- - -Set BrowserStack credentials as environment variables: -For macOS/Linux: -\`\`\`bash -export BROWSERSTACK_USERNAME=${username} -export BROWSERSTACK_ACCESS_KEY=${accessKey} -\`\`\` - -For Windows Command Prompt: -\`\`\`cmd -set BROWSERSTACK_USERNAME=${username} -set BROWSERSTACK_ACCESS_KEY=${accessKey} -\`\`\` - ----STEP--- - -Add serenity-browserstack dependency in pom.xml: -Add the following dependency to your pom.xml file and save it: -\`\`\`xml - - net.serenity-bdd - serenity-browserstack - 3.3.4 - -\`\`\` - ----STEP--- - -Set up serenity.conf file: -Create or update your serenity.conf file in the project root with the following configuration: -\`\`\` -webdriver { - driver = remote - remote.url = "https://hub.browserstack.com/wd/hub" -} -browserstack.user="${username}" -browserstack.key="${accessKey}" -\`\`\` - ----STEP--- - -Run your Serenity tests: -You can continue running your tests as you normally would. For example: - -Using Maven: -\`\`\`bash -mvn clean verify -\`\`\` - -Using Gradle: -\`\`\`bash -gradle clean test -\`\`\` -`; + return { setup, run }; +}; /** * ---------- CSharp INSTRUCTIONS ---------- */ -const csharpCommonInstructions = (username: string, accessKey: string) => ` +export const csharpCommonInstructions = ( + username: string, + accessKey: string, +) => { + const setup = ` ---STEP--- Install BrowserStack TestAdapter NuGet package: @@ -216,7 +179,9 @@ Install the x64 version of .NET for BrowserStack compatibility. sudo dotnet browserstack-sdk setup-dotnet --dotnet-path "" --dotnet-version "" \`\`\` Common paths: /usr/local/share/dotnet, ~/dotnet-x64, or /opt/dotnet-x64 +`; + const run = ` ---STEP--- Run the tests: @@ -230,10 +195,14 @@ Run the tests: \`\`\` `; -const csharpPlaywrightCommonInstructions = ( + return { setup, run }; +}; + +export const csharpPlaywrightCommonInstructions = ( username: string, accessKey: string, -) => ` +) => { + const setup = ` ---STEP--- Install BrowserStack TestAdapter NuGet package: @@ -295,7 +264,9 @@ Fix for Playwright architecture (macOS only): If the folder exists: \`/bin/Debug/net8.0/.playwright/node/darwin-arm64\` Rename \`darwin-arm64\` to \`darwin-x64\` +`; + const run = ` ---STEP--- Run the tests: @@ -309,11 +280,15 @@ Run the tests: \`\`\` `; + return { setup, run }; +}; + /** * ---------- NODEJS INSTRUCTIONS ---------- */ -const nodejsInstructions = (username: string, accessKey: string) => ` +export const nodejsInstructions = (username: string, accessKey: string) => { + const setup = ` ---STEP--- Ensure \`browserstack-node-sdk\` is present in package.json with the latest version: @@ -350,11 +325,27 @@ Run your tests: You can now run your tests on BrowserStack using your standard command or Use the commands defined in your package.json file to run the tests. `; + const run = ` +---STEP--- + +Run your tests on BrowserStack: +\`\`\`bash +npm run test:browserstack +\`\`\` +`; + + return { setup, run }; +}; + /** * ---------- EXPORT CONFIG ---------- */ -const webdriverioInstructions = (username: string, accessKey: string) => ` +export const webdriverioInstructions = ( + username: string, + accessKey: string, +) => { + const setup = ` ---STEP--- Set BrowserStack Credentials: @@ -453,14 +444,20 @@ exports.config.capabilities.forEach(function (caps) { caps[i] = { ...caps[i], ...exports.config.commonCapabilities[i]}; }); \`\`\` +`; + const run = ` ---STEP--- Run your tests: You can now run your tests on BrowserStack using your standard WebdriverIO command or Use the commands defined in your package.json file to run the tests. `; -const cypressInstructions = (username: string, accessKey: string) => ` + return { setup, run }; +}; + +export const cypressInstructions = (username: string, accessKey: string) => { + const setup = ` ---STEP--- Install the BrowserStack Cypress CLI: @@ -521,7 +518,9 @@ Open the generated \`browserstack.json\` file and update it with your BrowserSta \`\`\` **Note:** For Cypress v9 or lower, use \`"cypress_config_file": "./cypress.json"\`. The \`testObservability: true\` flag enables the [Test Reporting & Analytics dashboard](https://www.browserstack.com/docs/test-management/test-reporting-and-analytics) for deeper insights into your test runs. +`; + const run = ` ---STEP--- Run Your Tests on BrowserStack: @@ -530,9 +529,74 @@ Execute your tests on BrowserStack using the following command: npx browserstack-cypress run --sync \`\`\` -After the tests complete, you can view the results on your [BrowserStack Automate Dashboard](https://automate.browserstack.com/dashboard/). +After the tests complete, you can view the results on your [BrowserStack Automate Dashboard](https://automate.browserstack.com/dashboard/).`; + + return { setup, run }; +}; + +const serenityInstructions = (username: string, accessKey: string) => { + const setup = ` +---STEP--- + +Set BrowserStack credentials as environment variables: +For macOS/Linux: +\`\`\`bash +export BROWSERSTACK_USERNAME=${username} +export BROWSERSTACK_ACCESS_KEY=${accessKey} +\`\`\` + +For Windows Command Prompt: +\`\`\`cmd +set BROWSERSTACK_USERNAME=${username} +set BROWSERSTACK_ACCESS_KEY=${accessKey} +\`\`\` + +---STEP--- + +Add serenity-browserstack dependency in pom.xml: +Add the following dependency to your pom.xml file and save it: +\`\`\`xml + + net.serenity-bdd + serenity-browserstack + 3.3.4 + +\`\`\` + +---STEP--- + +Set up serenity.conf file: +Create or update your serenity.conf file in the project root with the following configuration: +\`\`\` +webdriver { + driver = remote + remote.url = "https://hub.browserstack.com/wd/hub" +} +browserstack.user="${username}" +browserstack.key="${accessKey}" +\`\`\` `; + const run = ` +---STEP--- + +Run your Serenity tests: +You can continue running your tests as you normally would. For example: + +Using Maven: +\`\`\`bash +mvn clean verify +\`\`\` + +Using Gradle: +\`\`\`bash +gradle clean test +\`\`\` +`; + + return { setup, run }; +}; + export const SUPPORTED_CONFIGURATIONS: ConfigMapping = { python: { playwright: { @@ -588,8 +652,5 @@ export const SUPPORTED_CONFIGURATIONS: ConfigMapping = { cypress: { cypress: { instructions: cypressInstructions }, }, - webdriverio: { - mocha: { instructions: webdriverioInstructions }, - }, }, }; diff --git a/src/tools/sdk-utils/bstack/frameworks.ts b/src/tools/sdk-utils/bstack/frameworks.ts new file mode 100644 index 00000000..cfacb315 --- /dev/null +++ b/src/tools/sdk-utils/bstack/frameworks.ts @@ -0,0 +1,59 @@ +import { ConfigMapping } from "../common/types.js"; +import * as constants from "./constants.js"; + +export const SUPPORTED_CONFIGURATIONS: ConfigMapping = { + python: { + playwright: { + pytest: { instructions: constants.pythonInstructions }, + }, + selenium: { + pytest: { instructions: constants.pytestInstructions }, + robot: { instructions: constants.robotInstructions }, + behave: { instructions: constants.behaveInstructions }, + }, + }, + java: { + playwright: { + junit4: { instructions: constants.javaInstructions }, + junit5: { instructions: constants.javaInstructions }, + testng: { instructions: constants.javaInstructions }, + }, + selenium: { + testng: { instructions: constants.javaInstructions }, + cucumber: { instructions: constants.javaInstructions }, + junit4: { instructions: constants.javaInstructions }, + junit5: { instructions: constants.javaInstructions }, + }, + }, + csharp: { + playwright: { + nunit: { instructions: constants.csharpPlaywrightCommonInstructions }, + mstest: { instructions: constants.csharpPlaywrightCommonInstructions }, + }, + selenium: { + xunit: { instructions: constants.csharpCommonInstructions }, + nunit: { instructions: constants.csharpCommonInstructions }, + mstest: { instructions: constants.csharpCommonInstructions }, + specflow: { instructions: constants.csharpCommonInstructions }, + reqnroll: { instructions: constants.csharpCommonInstructions }, + }, + }, + nodejs: { + playwright: { + jest: { instructions: constants.nodejsInstructions }, + codeceptjs: { instructions: constants.nodejsInstructions }, + playwright: { instructions: constants.nodejsInstructions }, + }, + selenium: { + jest: { instructions: constants.nodejsInstructions }, + webdriverio: { instructions: constants.webdriverioInstructions }, + mocha: { instructions: constants.nodejsInstructions }, + cucumber: { instructions: constants.nodejsInstructions }, + nightwatch: { instructions: constants.nodejsInstructions }, + codeceptjs: { instructions: constants.nodejsInstructions }, + }, + cypress: { + cypress: { instructions: constants.cypressInstructions }, + }, + }, +}; diff --git a/src/tools/sdk-utils/bstack/index.ts b/src/tools/sdk-utils/bstack/index.ts new file mode 100644 index 00000000..d11f85f1 --- /dev/null +++ b/src/tools/sdk-utils/bstack/index.ts @@ -0,0 +1,5 @@ +// BrowserStack SDK utilities +export { runBstackSDKOnly } from "./sdkHandler.js"; +export { getSDKPrefixCommand, getJavaFrameworkForMaven } from "./commands.js"; +export { generateBrowserStackYMLInstructions } from "./configUtils.js"; +export { SUPPORTED_CONFIGURATIONS } from "./frameworks.js"; diff --git a/src/tools/sdk-utils/bstack/sdkHandler.ts b/src/tools/sdk-utils/bstack/sdkHandler.ts new file mode 100644 index 00000000..54171d4b --- /dev/null +++ b/src/tools/sdk-utils/bstack/sdkHandler.ts @@ -0,0 +1,139 @@ +// Handler for BrowserStack SDK only (no Percy) - Sets up BrowserStack SDK with YML configuration +import { RunTestsInstructionResult, RunTestsStep } from "../common/types.js"; +import { RunTestsOnBrowserStackInput } from "../common/schema.js"; +import { getBrowserStackAuth } from "../../../lib/get-auth.js"; +import { getSDKPrefixCommand } from "./commands.js"; +import { generateBrowserStackYMLInstructions } from "./configUtils.js"; +import { getInstructionsForProjectConfiguration } from "../common/instructionUtils.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { validateDevices } from "../common/device-validator.js"; +import { + SDKSupportedBrowserAutomationFramework, + SDKSupportedTestingFramework, + SDKSupportedLanguage, +} from "../common/types.js"; + +export async function runBstackSDKOnly( + input: RunTestsOnBrowserStackInput, + config: BrowserStackConfig, + isPercyAutomate = false, +): Promise { + const steps: RunTestsStep[] = []; + const authString = getBrowserStackAuth(config); + const [username, accessKey] = authString.split(":"); + + // Validate devices against real BrowserStack device data + const tupleTargets = (input as any).devices as + | Array> + | undefined; + + const validatedEnvironments = await validateDevices( + tupleTargets || [], + input.detectedBrowserAutomationFramework, + ); + + // Handle frameworks with unique setup instructions that don't use browserstack.yml + if ( + input.detectedBrowserAutomationFramework === "cypress" || + input.detectedTestingFramework === "webdriverio" + ) { + const frameworkInstructions = getInstructionsForProjectConfiguration( + input.detectedBrowserAutomationFramework as SDKSupportedBrowserAutomationFramework, + input.detectedTestingFramework as SDKSupportedTestingFramework, + input.detectedLanguage as SDKSupportedLanguage, + username, + accessKey, + ); + + if (frameworkInstructions) { + if (frameworkInstructions.setup) { + steps.push({ + type: "instruction", + title: "Framework-Specific Setup", + content: frameworkInstructions.setup, + }); + } + + if (frameworkInstructions.run && !isPercyAutomate) { + steps.push({ + type: "instruction", + title: "Run the tests", + content: frameworkInstructions.run, + }); + } + } + + return { + steps, + requiresPercy: false, + missingDependencies: [], + }; + } + + // Default flow using browserstack.yml + const sdkSetupCommand = getSDKPrefixCommand( + input.detectedLanguage as SDKSupportedLanguage, + input.detectedTestingFramework as SDKSupportedTestingFramework, + username, + accessKey, + ); + + if (sdkSetupCommand) { + steps.push({ + type: "instruction", + title: "Install BrowserStack SDK", + content: sdkSetupCommand, + }); + } + + const frameworkInstructions = getInstructionsForProjectConfiguration( + input.detectedBrowserAutomationFramework as SDKSupportedBrowserAutomationFramework, + input.detectedTestingFramework as SDKSupportedTestingFramework, + input.detectedLanguage as SDKSupportedLanguage, + username, + accessKey, + ); + + if (frameworkInstructions) { + if (frameworkInstructions.setup) { + steps.push({ + type: "instruction", + title: "Framework-Specific Setup", + content: frameworkInstructions.setup, + }); + } + } + + const ymlInstructions = generateBrowserStackYMLInstructions( + { + validatedEnvironments, + enablePercy: false, + projectName: input.projectName, + }, + config, + ); + + if (ymlInstructions) { + steps.push({ + type: "instruction", + title: "Configure browserstack.yml", + content: ymlInstructions, + }); + } + + if (frameworkInstructions) { + if (frameworkInstructions.run && !isPercyAutomate) { + steps.push({ + type: "instruction", + title: "Run the tests", + content: frameworkInstructions.run, + }); + } + } + + return { + steps, + requiresPercy: false, + missingDependencies: [], + }; +} diff --git a/src/tools/sdk-utils/commands.ts b/src/tools/sdk-utils/commands.ts deleted file mode 100644 index f2573475..00000000 --- a/src/tools/sdk-utils/commands.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Utility to get the language-dependent prefix command for BrowserStack SDK setup -import { SDKSupportedLanguage } from "./types.js"; - -// Framework mapping for Java Maven archetype generation -const JAVA_FRAMEWORK_MAP: Record = { - testng: "testng", - junit5: "junit5", - junit4: "junit4", - cucumber: "cucumber-testng", - serenity: "serenity", -}; - -// Common Gradle setup instructions (platform-independent) -const GRADLE_SETUP_INSTRUCTIONS = ` -**For Gradle setup:** -1. Add browserstack-java-sdk to dependencies: - compileOnly 'com.browserstack:browserstack-java-sdk:latest.release' - -2. Add browserstackSDK path variable: - def browserstackSDKArtifact = configurations.compileClasspath.resolvedConfiguration.resolvedArtifacts.find { it.name == 'browserstack-java-sdk' } - -3. Add javaagent to gradle tasks: - jvmArgs "-javaagent:\${browserstackSDKArtifact.file}" -`; - -export function getSDKPrefixCommand( - language: SDKSupportedLanguage, - framework: string, - username: string, - accessKey: string, -): string { - switch (language) { - case "nodejs": - return `---STEP--- -Install BrowserStack Node SDK using command: -\`\`\`bash -npm i -D browserstack-node-sdk@latest -\`\`\` ----STEP--- -Run the following command to setup browserstack sdk: -\`\`\`bash -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.`; - - case "java": { - const mavenFramework = getJavaFrameworkForMaven(framework); - const isWindows = process.platform === "win32"; - - const mavenCommand = isWindows - ? `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_FRAMEWORK="${mavenFramework}"` - : `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="${username}" \\ --DBROWSERSTACK_ACCESS_KEY="${accessKey}" \\ --DBROWSERSTACK_FRAMEWORK="${mavenFramework}"`; - - const platformLabel = isWindows ? "Windows" : "macOS/Linux"; - - return `---STEP--- -Install BrowserStack Java SDK - -**Maven command for ${framework} (${platformLabel}):** -Run the command, it is required to generate the browserstack-sdk-archetype-integrate project: -${mavenCommand} - -Alternative setup for Gradle users: -${GRADLE_SETUP_INSTRUCTIONS}`; - } - - // Add more languages as needed - default: - return ""; - } -} - -export function getJavaFrameworkForMaven(framework: string): string { - return JAVA_FRAMEWORK_MAP[framework] || framework; -} diff --git a/src/tools/sdk-utils/common/constants.ts b/src/tools/sdk-utils/common/constants.ts new file mode 100644 index 00000000..8898a298 --- /dev/null +++ b/src/tools/sdk-utils/common/constants.ts @@ -0,0 +1,104 @@ +export const IMPORTANT_SETUP_WARNING = + "IMPORTANT: DO NOT SKIP ANY STEP. All the setup steps described below MUST be executed regardless of any existing configuration or setup. This ensures proper BrowserStack SDK setup."; + +export const SETUP_PERCY_DESCRIPTION = + "Set up or expand Percy visual testing configuration with comprehensive coverage for existing projects that might have Percy integrated. This supports both Percy Web Standalone and Percy Automate. Example prompts: Expand percy coverage for this project {project_name}"; + +export const LIST_TEST_FILES_DESCRIPTION = + "Lists all test files for a given set of directories."; + +export const PERCY_SNAPSHOT_COMMANDS_DESCRIPTION = + "Adds Percy snapshot commands to the specified test files."; + +export const RUN_ON_BROWSERSTACK_DESCRIPTION = + "Set up and run automated web-based tests on BrowserStack using the BrowserStack SDK. Use this tool for functional or integration test setup on BrowserStack only. For any visual testing or Percy integration, use the dedicated Percy setup tool. Example prompts: run this test on browserstack; set up this project for browserstack."; + +export const SIMULATE_PERCY_CHANGE_DESCRIPTION = `Integrate Percy visual testing into new projects and demonstrate visual change detection through a step-by-step simulation. This tool handles initial Percy integration, setup, and creates a working demonstration for new users. Primary tool for prompts like: "Integrate percy for this project", "Integrate percy in this project {project_name}"`; + +export const PERCY_REPLACE_REGEX = + /Invoke listTestFiles\(\) with the provided directories[\s\S]*?- DO NOT STOP until you add commands in all the files or you reach end of the files\./; + +export const PERCY_SNAPSHOT_INSTRUCTION = ` +Invoke listTestFiles() with the provided directories from user to gather all test files in memory ---STEP--- +Process files in STRICT sequential order using tool addPercySnapshotCommands() with below instructions: +- Start with index 0 +- Then index 1 +- Then index 2 +- Continue sequentially until the last index +- DO NOT skip any index numbers +- DO NOT jump to random indices like 10, 20, 30 +- DO NOT worry about time consuming and any constraints. +- DO NOT STOP until you add commands in all the files or you reach end of the files. +`; + +export const PERCY_SIMULATE_INSTRUCTION = ` +MANDATORY: Aggressive, Accurate Percy Snapshots Integration + +Prerequisite: Before making any code changes in the user's repository, create and switch to a new branch named browserstack-simulation. Stage all changes but do not commit them. If the repository has not been initialized yet, run git init first. + +You MUST follow these steps with NO EXCEPTIONS: +1. Identify 4-5 key test files in the project that are critical for visual testing. +2. For EACH identified test file, analyze the code to find EVERY location where a Percy snapshot should be called (e.g., after each navigation, UI change, or significant state in tests). +3. Insert snapshot calls at ALL such points, not just the first or most obvious one. + +Iteratively update every selected test file with percy snapshot calls at all identified points, ensuring comprehensive coverage of visual changes.`; + +export const PERCY_SIMULATION_DRIVER_INSTRUCTION = ` +MANDATORY: Step-by-Step Percy Visual Change Simulation +You MUST follow these steps with NO EXCEPTIONS: + +STEP 1: At this case we have added Percy snapshot commands to the test files at the appropriate locations (e.g., after navigation, UI change, or significant state). +- Use ONLY existing snapshot names. +- Do NOT add any popup injection or visual changes yet. +- Do NOT run any Percy builds at this stage. + +STEP 2: Run ONE comprehensive baseline Percy build that executes ALL tests containing Percy snapshots in a SINGLE build. This creates one baseline build with all snapshots for comparison. Use a command like: npx percy exec --label=mcp -- python -m pytest tests/ -k 'test_name1 or test_name2 or test_name3' -v to run multiple specific tests in one build." + +STEP 3: Modify your test to inject a visible UI change (such as a popup) IMMEDIATELY BEFORE an EXISTING snapshot command (e.g., before percy_snapshot(self.driver, "screenshot name")). +- Do NOT add a new snapshot name for the popup. +- The popup must appear in an existing snapshot, not a new one. +- Add this popup code in some test files before the percy_snapshot command you've added, to display the visual changes. + +\`\`\`Javascript +popup_script = \` +var popup = document.createElement('div'); +popup.id = 'percy-test-popup'; +popup.style.cssText = popup.style.cssText = \` + /* TODO: Add styles to make the popup large, centered, and visually noticeable. + Suggested properties: position: fixed; top/left; transform; background; color; font-size; padding; z-index; animation, etc. */ +\`; +popup.innerHTML = 'PERCY TEST
VISUAL CHANGE
DETECTED!'; +document.body.appendChild(popup); +\`; + +# Insert this just before the EXISTING snapshot command: +driver.execute_script(popup_script) +percy_snapshot(self.driver, "Before Adding to Cart") # (Do NOT change the snapshot name, keep existing one) +\`\`\` + +STEP 4: Run a second Percy build with same label and same test command as the baseline. +- The snapshot names must remain the same as in the baseline. +- The visual change should now appear in the same snapshot as before. +- Use the same build command you ran for the baseline. + +STEP 5: Compare the two Percy builds to see the detected visual difference. + +STEP 6: Now ask user if they want to expand percy for other testcases? If yes, call the "expandPercyVisualTesting" tool to enable complete coverage for the entire project. + +CONSTRAINTS: +- Do NOT run any builds until explicitly instructed in the steps. +- Do NOT add new snapshot names—only use existing ones. +- Do NOT add popup injection until the baseline is established. +- Visual changes must appear in EXISTING snapshots, not new ones. + +VALIDATION CHECKPOINTS (before proceeding to the next step): +- Are you adding only snapshot commands (not running builds)? +- Are you reusing existing snapshot names (not creating new ones)? +- Have you established the baseline first (before adding visual changes) + +CRITICAL: +Do NOT run tests separately or create multiple builds during baseline establishment. The goal is to have exactly TWO builds total: (1) baseline build with all original snapshots, (2) modified build with the same tests but visual changes injected. +`; + +export const PERCY_VERIFICATION_REGEX = + /\*\*✅ Verification:\*\*\nPlease verify that you have completed all[\s\S]*?double-check each step and ensure all commands executed successfully\./s; diff --git a/src/tools/sdk-utils/common/device-validator.ts b/src/tools/sdk-utils/common/device-validator.ts new file mode 100644 index 00000000..d2047b2b --- /dev/null +++ b/src/tools/sdk-utils/common/device-validator.ts @@ -0,0 +1,639 @@ +import { + getDevicesAndBrowsers, + BrowserStackProducts, +} from "../../../lib/device-cache.js"; +import { resolveVersion } from "../../../lib/version-resolver.js"; +import { customFuzzySearch } from "../../../lib/fuzzy.js"; +import { SDKSupportedBrowserAutomationFrameworkEnum } from "./types.js"; + +// ============================================================================ +// SHARED TYPES AND INTERFACES +// ============================================================================ + +// Type definitions for better type safety +export interface DesktopBrowserEntry { + os: string; + os_version: string; + browser: string; + browser_version: string; +} + +export interface MobileDeviceEntry { + os: "android" | "ios"; + os_version: string; + display_name: string; + browsers?: Array<{ + browser: string; + display_name?: string; + }>; +} + +export interface ValidatedEnvironment { + platform: string; + osVersion: string; + browser?: string; + browserVersion?: string; + deviceName?: string; + notes?: string; +} + +// Raw data interfaces for API responses +interface RawDesktopPlatform { + os: string; + os_version: string; + browsers: Array<{ + browser: string; + browser_version: string; + }>; +} + +interface RawMobileGroup { + os: "android" | "ios"; + devices: Array<{ + os_version: string; + display_name: string; + browser?: string; + browsers?: Array<{ + browser: string; + display_name?: string; + }>; + }>; +} + +interface RawDeviceData { + desktop?: RawDesktopPlatform[]; + mobile?: RawMobileGroup[]; +} + +const DEFAULTS = { + windows: { browser: "chrome" }, + macos: { browser: "safari" }, + android: { device: "Samsung Galaxy S24", browser: "chrome" }, + ios: { device: "iPhone 15", browser: "safari" }, +} as const; + +// Performance optimization: Indexed maps for faster lookups +interface DesktopIndex { + byOS: Map; + byOSVersion: Map; + byBrowser: Map; + nested: Map>>; +} + +interface MobileIndex { + byPlatform: Map; + byDeviceName: Map; + byOSVersion: Map; +} + +// ============================================================================ +// AUTOMATE SECTION (Desktop + Mobile for BrowserStack SDK) +// ============================================================================ + +// Helper functions to build device entries and eliminate duplication +function buildDesktopEntries( + automateData: RawDeviceData, +): DesktopBrowserEntry[] { + if (!automateData.desktop) { + return []; + } + + return automateData.desktop.flatMap((platform: RawDesktopPlatform) => + platform.browsers.map((browser) => ({ + os: platform.os, + os_version: platform.os_version, + browser: browser.browser, + browser_version: browser.browser_version, + })), + ); +} + +function buildMobileEntries( + appAutomateData: RawDeviceData, + platform: "android" | "ios", +): MobileDeviceEntry[] { + if (!appAutomateData.mobile) { + return []; + } + + return appAutomateData.mobile + .filter((group: RawMobileGroup) => group.os === platform) + .flatMap((group: RawMobileGroup) => + group.devices.map((device) => ({ + os: group.os, + os_version: device.os_version, + display_name: device.display_name, + browsers: device.browsers || [ + { + browser: + device.browser || (platform === "android" ? "chrome" : "safari"), + }, + ], + })), + ); +} + +// Performance optimization: Create indexed maps for faster lookups +function createDesktopIndex(entries: DesktopBrowserEntry[]): DesktopIndex { + const byOS = new Map(); + const byOSVersion = new Map(); + const byBrowser = new Map(); + const nested = new Map< + string, + Map> + >(); + + for (const entry of entries) { + // Index by OS + if (!byOS.has(entry.os)) { + byOS.set(entry.os, []); + } + byOS.get(entry.os)!.push(entry); + + // Index by OS version + if (!byOSVersion.has(entry.os_version)) { + byOSVersion.set(entry.os_version, []); + } + byOSVersion.get(entry.os_version)!.push(entry); + + // Index by browser + if (!byBrowser.has(entry.browser)) { + byBrowser.set(entry.browser, []); + } + byBrowser.get(entry.browser)!.push(entry); + + // Build nested index: Map>> + if (!nested.has(entry.os)) { + nested.set(entry.os, new Map()); + } + const osMap = nested.get(entry.os)!; + + if (!osMap.has(entry.os_version)) { + osMap.set(entry.os_version, new Map()); + } + const osVersionMap = osMap.get(entry.os_version)!; + + if (!osVersionMap.has(entry.browser)) { + osVersionMap.set(entry.browser, []); + } + osVersionMap.get(entry.browser)!.push(entry); + } + + return { byOS, byOSVersion, byBrowser, nested }; +} + +function createMobileIndex(entries: MobileDeviceEntry[]): MobileIndex { + const byPlatform = new Map(); + const byDeviceName = new Map(); + const byOSVersion = new Map(); + + for (const entry of entries) { + // Index by platform + if (!byPlatform.has(entry.os)) { + byPlatform.set(entry.os, []); + } + byPlatform.get(entry.os)!.push(entry); + + // Index by device name (case-insensitive) + const deviceKey = entry.display_name.toLowerCase(); + if (!byDeviceName.has(deviceKey)) { + byDeviceName.set(deviceKey, []); + } + byDeviceName.get(deviceKey)!.push(entry); + + // Index by OS version + if (!byOSVersion.has(entry.os_version)) { + byOSVersion.set(entry.os_version, []); + } + byOSVersion.get(entry.os_version)!.push(entry); + } + + return { byPlatform, byDeviceName, byOSVersion }; +} + +export async function validateDevices( + devices: Array>, + framework?: string, +): Promise { + const validatedEnvironments: ValidatedEnvironment[] = []; + + if (!devices || devices.length === 0) { + // Use centralized default fallback + return [ + { + platform: "windows", + osVersion: "11", + browser: DEFAULTS.windows.browser, + browserVersion: "latest", + }, + ]; + } + + // Determine what data we need to fetch + const needsDesktop = devices.some((env) => + ["windows", "macos"].includes((env[0] || "").toLowerCase()), + ); + const needsMobile = devices.some((env) => + ["android", "ios"].includes((env[0] || "").toLowerCase()), + ); + + // Fetch data using framework-specific endpoint for both desktop and mobile + let deviceData: RawDeviceData | null = null; + + try { + if (needsDesktop || needsMobile) { + if (framework === SDKSupportedBrowserAutomationFrameworkEnum.playwright) { + deviceData = (await getDevicesAndBrowsers( + BrowserStackProducts.PLAYWRIGHT_AUTOMATE, + )) as RawDeviceData; + } else { + deviceData = (await getDevicesAndBrowsers( + BrowserStackProducts.SELENIUM_AUTOMATE, + )) as RawDeviceData; + } + } + } catch (error) { + throw new Error( + `Failed to fetch device data: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // Preprocess data into indexed maps for better performance + let desktopIndex: DesktopIndex | null = null; + let androidIndex: MobileIndex | null = null; + let iosIndex: MobileIndex | null = null; + + if (needsDesktop && deviceData) { + const desktopEntries = buildDesktopEntries(deviceData); + desktopIndex = createDesktopIndex(desktopEntries); + } + + if (needsMobile && deviceData) { + const androidEntries = buildMobileEntries(deviceData, "android"); + const iosEntries = buildMobileEntries(deviceData, "ios"); + androidIndex = createMobileIndex(androidEntries); + iosIndex = createMobileIndex(iosEntries); + } + + for (const env of devices) { + const discriminator = (env[0] || "").toLowerCase(); + let validatedEnv: ValidatedEnvironment; + + if (discriminator === "windows") { + validatedEnv = validateDesktopEnvironment( + env, + desktopIndex!, + "windows", + DEFAULTS.windows.browser, + ); + } else if (discriminator === "macos") { + validatedEnv = validateDesktopEnvironment( + env, + desktopIndex!, + "macos", + DEFAULTS.macos.browser, + ); + } else if (discriminator === "android") { + validatedEnv = validateMobileEnvironment( + env, + androidIndex!, + "android", + DEFAULTS.android.device, + DEFAULTS.android.browser, + ); + } else if (discriminator === "ios") { + validatedEnv = validateMobileEnvironment( + env, + iosIndex!, + "ios", + DEFAULTS.ios.device, + DEFAULTS.ios.browser, + ); + } else { + throw new Error(`Unsupported platform: ${discriminator}`); + } + + validatedEnvironments.push(validatedEnv); + } + + if (framework === SDKSupportedBrowserAutomationFrameworkEnum.playwright) { + validatedEnvironments.forEach((env) => { + if (env.browser) { + env.browser = env.browser.toLowerCase(); + } + }); + } + + return validatedEnvironments; +} + +// Optimized desktop validation using nested indexed maps for O(1) lookups +function validateDesktopEnvironment( + env: string[], + index: DesktopIndex, + platform: "windows" | "macos", + defaultBrowser: string, +): ValidatedEnvironment { + const [, osVersion, browser, browserVersion] = env; + + const osKey = platform === "windows" ? "Windows" : "OS X"; + + // Use nested index for O(1) lookup instead of filtering + const osMap = index.nested.get(osKey); + if (!osMap) { + throw new Error(`No ${platform} devices available`); + } + + // Get available OS versions for this platform + const availableOSVersions = Array.from(osMap.keys()); + + const validatedOSVersion = resolveVersion( + osVersion || "latest", + availableOSVersions, + ); + + // Use nested index for O(1) lookup + const osVersionMap = osMap.get(validatedOSVersion); + if (!osVersionMap) { + throw new Error( + `OS version "${validatedOSVersion}" not available for ${platform}`, + ); + } + + // Get available browsers for this OS version + const availableBrowsers = Array.from(osVersionMap.keys()); + const validatedBrowser = validateBrowserExact( + browser || defaultBrowser, + availableBrowsers, + ); + + // Use nested index for O(1) lookup + const browserEntries = osVersionMap.get(validatedBrowser); + if (!browserEntries || browserEntries.length === 0) { + throw new Error( + `Browser "${validatedBrowser}" not available for ${platform} ${validatedOSVersion}`, + ); + } + + const availableBrowserVersions = [ + ...new Set(browserEntries.map((e) => e.browser_version)), + ] as string[]; + const validatedBrowserVersion = resolveVersion( + browserVersion || "latest", + availableBrowserVersions, + ); + + return { + platform, + osVersion: validatedOSVersion, + browser: validatedBrowser, + browserVersion: validatedBrowserVersion, + }; +} + +// Optimized mobile validation using indexed maps +function validateMobileEnvironment( + env: string[], + index: MobileIndex, + platform: "android" | "ios", + defaultDevice: string, + defaultBrowser: string, +): ValidatedEnvironment { + const [, deviceName, osVersion, browser] = env; + + const platformEntries = index.byPlatform.get(platform) || []; + if (platformEntries.length === 0) { + throw new Error(`No ${platform} devices available`); + } + + // Use fuzzy search only for device names (as suggested in feedback) + const deviceMatches = customFuzzySearch( + platformEntries, + ["display_name"], + deviceName || defaultDevice, + 5, + ); + if (deviceMatches.length === 0) { + throw new Error( + `No ${platform} devices matching "${deviceName}". Available devices: ${platformEntries + .map((d) => d.display_name || "unknown") + .slice(0, 5) + .join(", ")}`, + ); + } + + // Try to find exact match first + const exactMatch = deviceMatches.find( + (m) => m.display_name.toLowerCase() === (deviceName || "").toLowerCase(), + ); + + // If no exact match, throw error instead of using fuzzy match + if (!exactMatch) { + const suggestions = deviceMatches.map((m) => m.display_name).join(", "); + throw new Error( + `Device "${deviceName}" not found exactly for ${platform}. Available similar devices: ${suggestions}. Please use the exact device name.`, + ); + } + + // Use index for faster filtering + const deviceKey = exactMatch.display_name.toLowerCase(); + const deviceFiltered = index.byDeviceName.get(deviceKey) || []; + + const availableOSVersions = [ + ...new Set(deviceFiltered.map((d) => d.os_version)), + ] as string[]; + const validatedOSVersion = resolveVersion( + osVersion || "latest", + availableOSVersions, + ); + + // Use index for faster filtering + const osVersionEntries = index.byOSVersion.get(validatedOSVersion) || []; + const osFiltered = osVersionEntries.filter( + (d) => d.display_name.toLowerCase() === deviceKey, + ); + + // Validate browser if provided - use exact matching for browsers + let validatedBrowser = browser || defaultBrowser; + if (browser && osFiltered.length > 0) { + // Extract browsers more carefully - handle different possible structures + const availableBrowsers = [ + ...new Set( + osFiltered.flatMap((d) => { + if (d.browsers && Array.isArray(d.browsers)) { + // If browsers is an array of objects with browser property + return d.browsers + .map((b) => { + // Use display_name for user-friendly browser names, fallback to browser field + return b.display_name || b.browser; + }) + .filter(Boolean); + } + // For mobile devices, provide default browsers if none found + return platform === "android" ? ["chrome"] : ["safari"]; + }), + ), + ].filter(Boolean) as string[]; + + if (availableBrowsers.length > 0) { + try { + validatedBrowser = validateBrowserExact(browser, availableBrowsers); + } catch (error) { + // Add more context to browser validation errors + throw new Error( + `Failed to validate browser "${browser}" for ${platform} device "${exactMatch.display_name}" on OS version "${validatedOSVersion}". ${error instanceof Error ? error.message : String(error)}`, + ); + } + } else { + // For mobile, if no specific browsers found, just use the requested browser + // as most mobile devices support standard browsers + validatedBrowser = browser || defaultBrowser; + } + } + + return { + platform, + osVersion: validatedOSVersion, + deviceName: exactMatch.display_name, + browser: validatedBrowser, + }; +} + +// ============================================================================ +// APP AUTOMATE SECTION (Mobile devices for App Automate) +// ============================================================================ + +export async function validateAppAutomateDevices( + devices: Array>, +): Promise { + const validatedDevices: ValidatedEnvironment[] = []; + + if (!devices || devices.length === 0) { + // Use centralized default fallback + return [ + { + platform: "android", + osVersion: "latest", + deviceName: DEFAULTS.android.device, + }, + ]; + } + + let appAutomateData: RawDeviceData; + + try { + // Fetch app automate device data + appAutomateData = (await getDevicesAndBrowsers( + BrowserStackProducts.APP_AUTOMATE, + )) as RawDeviceData; + } catch (error) { + // Only wrap fetch-related errors + throw new Error( + `Failed to fetch device data: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + for (const device of devices) { + // Parse device array in format ["android", "Device Name", "OS Version"] + const [platform, deviceName, osVersion] = device; + + // Find matching device in the data + let validatedDevice: ValidatedEnvironment | null = null; + + if (!appAutomateData.mobile) { + throw new Error("No mobile device data available"); + } + + // Filter by platform first + const platformGroup = appAutomateData.mobile.find( + (group) => group.os === platform.toLowerCase(), + ); + + if (!platformGroup) { + throw new Error(`Platform "${platform}" not supported for App Automate`); + } + + const platformDevices = platformGroup.devices; + + // Find exact device name match (case-insensitive) + const exactMatch = platformDevices.find( + (d) => d.display_name.toLowerCase() === deviceName.toLowerCase(), + ); + + if (exactMatch) { + // Check if the OS version is available for this device + const deviceVersions = platformDevices + .filter((d) => d.display_name === exactMatch.display_name) + .map((d) => d.os_version); + + const validatedOSVersion = resolveVersion( + osVersion || "latest", + deviceVersions, + ); + + validatedDevice = { + platform: platformGroup.os, + osVersion: validatedOSVersion, + deviceName: exactMatch.display_name, + }; + } + + if (!validatedDevice) { + // If no exact match found, suggest similar devices from the SAME platform only + const platformDevicesForSearch = platformDevices.map((d) => ({ + ...d, + platform: platformGroup.os, + })); + + // Try fuzzy search with a more lenient threshold + const deviceMatches = customFuzzySearch( + platformDevicesForSearch, + ["display_name"], + deviceName, + 5, + 0.8, // More lenient threshold + ); + + const suggestions = deviceMatches + .map((m) => `${m.display_name}`) + .join(", "); + + // If no fuzzy matches, show some available devices as fallback + const fallbackDevices = platformDevicesForSearch + .slice(0, 5) + .map((d) => d.display_name) + .join(", "); + + const errorMessage = suggestions + ? `Device "${deviceName}" not found for platform "${platform}".\nAvailable similar devices: ${suggestions}` + : `Device "${deviceName}" not found for platform "${platform}".\nAvailable devices: ${fallbackDevices}`; + + throw new Error(errorMessage); + } + + validatedDevices.push(validatedDevice); + } + + return validatedDevices; +} + +// ============================================================================ +// SHARED UTILITY FUNCTIONS +// ============================================================================ + +// Exact browser validation (preferred for structured fields) +function validateBrowserExact( + requestedBrowser: string, + availableBrowsers: string[], +): string { + const exactMatch = availableBrowsers.find( + (b) => b.toLowerCase() === requestedBrowser.toLowerCase(), + ); + if (exactMatch) { + return exactMatch; + } + + throw new Error( + `Browser "${requestedBrowser}" not found. Available options: ${availableBrowsers.join(", ")}`, + ); +} diff --git a/src/tools/sdk-utils/common/formatUtils.ts b/src/tools/sdk-utils/common/formatUtils.ts new file mode 100644 index 00000000..e50527b5 --- /dev/null +++ b/src/tools/sdk-utils/common/formatUtils.ts @@ -0,0 +1,34 @@ +export function formatInstructionsWithNumbers( + instructionText: string, + separator: string = "---STEP---", +): { formattedSteps: string; stepCount: number } { + // Split the instructions by the separator + const steps = instructionText + .split(separator) + .map((step) => step.trim()) + .filter((step) => step.length > 0); + + // If no separators found, treat the entire text as one step + if (steps.length === 1 && !instructionText.includes(separator)) { + return { + formattedSteps: `**Step 1:**\n${instructionText.trim()}`, + stepCount: 1, + }; + } + + // Format each step with numbering + const formattedSteps = steps + .map((step, index) => { + return `**Step ${index + 1}:**\n${step.trim()}`; + }) + .join("\n\n"); + + return { + formattedSteps, + stepCount: steps.length, + }; +} + +export function generateVerificationMessage(stepCount: number): string { + return `**✅ Verification:**\nPlease verify that you have completed all ${stepCount} steps above to ensure proper setup. If you encounter any issues, double-check each step and ensure all commands executed successfully.`; +} diff --git a/src/tools/sdk-utils/common/index.ts b/src/tools/sdk-utils/common/index.ts new file mode 100644 index 00000000..7a145f57 --- /dev/null +++ b/src/tools/sdk-utils/common/index.ts @@ -0,0 +1,4 @@ +// Common utilities and types for SDK tools +export * from "./types.js"; +export * from "./constants.js"; +export * from "./formatUtils.js"; diff --git a/src/tools/sdk-utils/common/instructionUtils.ts b/src/tools/sdk-utils/common/instructionUtils.ts new file mode 100644 index 00000000..484928ed --- /dev/null +++ b/src/tools/sdk-utils/common/instructionUtils.ts @@ -0,0 +1,49 @@ +/** + * Core instruction configuration utilities for runTestsOnBrowserStack tool. + */ + +import { SUPPORTED_CONFIGURATIONS } from "../bstack/frameworks.js"; +import { + SDKSupportedLanguage, + SDKSupportedBrowserAutomationFramework, + SDKSupportedTestingFramework, +} from "./types.js"; + +const errorMessageSuffix = + "Please open an issue at our Github repo: https://github.com/browserstack/browserstack-mcp-server/issues to request support for your project configuration"; + +export const getInstructionsForProjectConfiguration = ( + detectedBrowserAutomationFramework: SDKSupportedBrowserAutomationFramework, + detectedTestingFramework: SDKSupportedTestingFramework, + detectedLanguage: SDKSupportedLanguage, + username: string, + accessKey: string, +) => { + const configuration = SUPPORTED_CONFIGURATIONS[detectedLanguage]; + + if (!configuration) { + throw new Error( + `BrowserStack MCP Server currently does not support ${detectedLanguage}, ${errorMessageSuffix}`, + ); + } + + if (!configuration[detectedBrowserAutomationFramework]) { + throw new Error( + `BrowserStack MCP Server currently does not support ${detectedBrowserAutomationFramework} for ${detectedLanguage}, ${errorMessageSuffix}`, + ); + } + + if ( + !configuration[detectedBrowserAutomationFramework][detectedTestingFramework] + ) { + throw new Error( + `BrowserStack MCP Server currently does not support ${detectedTestingFramework} for ${detectedBrowserAutomationFramework} on ${detectedLanguage}, ${errorMessageSuffix}`, + ); + } + + const instructionFunction = + configuration[detectedBrowserAutomationFramework][detectedTestingFramework] + .instructions; + + return instructionFunction(username, accessKey); +}; diff --git a/src/tools/sdk-utils/common/schema.ts b/src/tools/sdk-utils/common/schema.ts new file mode 100644 index 00000000..39c5cb4f --- /dev/null +++ b/src/tools/sdk-utils/common/schema.ts @@ -0,0 +1,153 @@ +import { z } from "zod"; +import { PercyIntegrationTypeEnum } from "./types.js"; +import { + SDKSupportedBrowserAutomationFrameworkEnum, + SDKSupportedTestingFrameworkEnum, + SDKSupportedLanguageEnum, +} from "./types.js"; + +// Platform enums for better validation +export const PlatformEnum = { + WINDOWS: "windows", + MACOS: "macos", + ANDROID: "android", + IOS: "ios", +} as const; + +export const WindowsPlatformEnum = { + WINDOWS: "windows", +} as const; + +export const MacOSPlatformEnum = { + MACOS: "macos", +} as const; + +export const SetUpPercyParamsShape = { + projectName: z.string().describe("A unique name for your Percy project."), + detectedLanguage: z.nativeEnum(SDKSupportedLanguageEnum), + detectedBrowserAutomationFramework: z.nativeEnum( + SDKSupportedBrowserAutomationFrameworkEnum, + ), + detectedTestingFramework: z.nativeEnum(SDKSupportedTestingFrameworkEnum), + integrationType: z + .nativeEnum(PercyIntegrationTypeEnum) + .describe( + "Specify the Percy integration type: web (Percy Web) or automate (Percy Automate). If not provided, always prompt the user with: 'Please specify the Percy integration type.' Do not proceed without an explicit selection. Never use a default.", + ), + folderPaths: z + .array(z.string()) + .optional() + .describe( + "An array of absolute folder paths containing UI test files. If not provided, analyze codebase for UI test folders by scanning for test patterns which contain UI test cases as per framework. Return empty array if none found.", + ), + filePaths: z + .array(z.string()) + .optional() + .describe( + "An array of absolute file paths to specific UI test files. Use this when you want to target specific test files rather than entire folders. If not provided, will use folderPaths instead.", + ), +}; + +export const RunTestsOnBrowserStackParamsShape = { + projectName: z + .string() + .describe("A single name for your project to organize all your tests."), + detectedLanguage: z.nativeEnum(SDKSupportedLanguageEnum), + detectedBrowserAutomationFramework: z.nativeEnum( + SDKSupportedBrowserAutomationFrameworkEnum, + ), + detectedTestingFramework: z.nativeEnum(SDKSupportedTestingFrameworkEnum), + devices: z + .array( + z.union([ + // Windows: [windows, osVersion, browser, browserVersion] + z.tuple([ + z + .nativeEnum(WindowsPlatformEnum) + .describe("Platform identifier: 'windows'"), + z.string().describe("Windows version, e.g. '10', '11'"), + z.string().describe("Browser name, e.g. 'chrome', 'firefox', 'edge'"), + z + .string() + .describe("Browser version, e.g. '132', 'latest', 'oldest'"), + ]), + // Android: [android, name, model, osVersion, browser] + z.tuple([ + z + .literal(PlatformEnum.ANDROID) + .describe("Platform identifier: 'android'"), + z + .string() + .describe( + "Device name, e.g. 'Samsung Galaxy S24', 'Google Pixel 8'", + ), + z.string().describe("Android version, e.g. '14', '16', 'latest'"), + z.string().describe("Browser name, e.g. 'chrome', 'samsung browser'"), + ]), + // iOS: [ios, name, model, osVersion, browser] + z.tuple([ + z.literal(PlatformEnum.IOS).describe("Platform identifier: 'ios'"), + z.string().describe("Device name, e.g. 'iPhone 12 Pro'"), + z.string().describe("iOS version, e.g. '17', 'latest'"), + z.string().describe("Browser name, typically 'safari'"), + ]), + // macOS: [mac|macos, name, model, browser, browserVersion] + z.tuple([ + z + .nativeEnum(MacOSPlatformEnum) + .describe("Platform identifier: 'mac' or 'macos'"), + z.string().describe("macOS version name, e.g. 'Sequoia', 'Ventura'"), + z.string().describe("Browser name, e.g. 'safari', 'chrome'"), + z.string().describe("Browser version, e.g. 'latest'"), + ]), + ]), + ) + .max(3) + .default([]) + .describe( + "Preferred tuples of target devices.Add device only when user asks explicitly for it. Defaults to [] . Example: [['windows', '11', 'chrome', 'latest']]", + ), +}; + +export const SetUpPercySchema = z.object(SetUpPercyParamsShape); + +export const RunTestsOnBrowserStackSchema = z.object( + RunTestsOnBrowserStackParamsShape, +); + +export type SetUpPercyInput = z.infer; +export type RunTestsOnBrowserStackInput = z.infer< + typeof RunTestsOnBrowserStackSchema +>; + +export const RunPercyScanParamsShape = { + projectName: z.string().describe("The name of the project to run Percy on."), + percyRunCommand: z + .string() + .optional() + .describe( + "The test command to run with Percy. Optional — the LLM should try to infer it first from project context.", + ), + integrationType: z + .nativeEnum(PercyIntegrationTypeEnum) + .describe( + "Specifies whether to integrate with Percy Web or Percy Automate. If not explicitly provided, prompt the user to select the desired integration type.", + ), +}; + +export const FetchPercyChangesParamsShape = { + project_name: z + .string() + .describe( + "The name of the BrowserStack project. If not found, ask user directly.", + ), +}; + +export const ManagePercyBuildApprovalParamsShape = { + buildId: z + .string() + .describe("The ID of the Percy build to approve or reject."), + action: z + .enum(["approve", "unapprove", "reject"]) + .describe("The action to perform on the Percy build."), +}; diff --git a/src/tools/sdk-utils/common/types.ts b/src/tools/sdk-utils/common/types.ts new file mode 100644 index 00000000..3994a899 --- /dev/null +++ b/src/tools/sdk-utils/common/types.ts @@ -0,0 +1,94 @@ +export enum PercyIntegrationTypeEnum { + WEB = "web", + AUTOMATE = "automate", +} + +export enum SDKSupportedLanguageEnum { + nodejs = "nodejs", + python = "python", + java = "java", + csharp = "csharp", + ruby = "ruby", +} +export type SDKSupportedLanguage = keyof typeof SDKSupportedLanguageEnum; + +export enum SDKSupportedBrowserAutomationFrameworkEnum { + playwright = "playwright", + selenium = "selenium", + cypress = "cypress", +} +export type SDKSupportedBrowserAutomationFramework = + keyof typeof SDKSupportedBrowserAutomationFrameworkEnum; + +export enum SDKSupportedTestingFrameworkEnum { + jest = "jest", + codeceptjs = "codeceptjs", + playwright = "playwright", + pytest = "pytest", + robot = "robot", + behave = "behave", + cucumber = "cucumber", + nightwatch = "nightwatch", + webdriverio = "webdriverio", + mocha = "mocha", + junit4 = "junit4", + junit5 = "junit5", + testng = "testng", + cypress = "cypress", + nunit = "nunit", + mstest = "mstest", + xunit = "xunit", + specflow = "specflow", + reqnroll = "reqnroll", + rspec = "rspec", + serenity = "serenity", +} + +export const SDKSupportedLanguages = Object.values(SDKSupportedLanguageEnum); +export type SDKSupportedTestingFramework = + keyof typeof SDKSupportedTestingFrameworkEnum; +export const SDKSupportedTestingFrameworks = Object.values( + SDKSupportedTestingFrameworkEnum, +); + +export type ConfigMapping = Partial< + Record< + SDKSupportedLanguageEnum, + Partial< + Record< + SDKSupportedBrowserAutomationFrameworkEnum, + Partial< + Record< + SDKSupportedTestingFrameworkEnum, + { + instructions: ( + username: string, + accessKey: string, + ) => { setup: string; run: string }; + } + > + > + > + > + > +>; + +// Common interfaces for instruction results +export interface RunTestsStep { + type: "instruction" | "error" | "warning"; + title: string; + content: string; + isError?: boolean; +} + +export interface RunTestsInstructionResult { + steps: RunTestsStep[]; + requiresPercy: boolean; + missingDependencies: string[]; + shouldSkipFormatting?: boolean; +} + +export enum PercyAutomateNotImplementedType { + LANGUAGE = "language", + FRAMEWORK = "framework", +} diff --git a/src/tools/sdk-utils/common/utils.ts b/src/tools/sdk-utils/common/utils.ts new file mode 100644 index 00000000..ba167c1a --- /dev/null +++ b/src/tools/sdk-utils/common/utils.ts @@ -0,0 +1,153 @@ +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { PercyIntegrationTypeEnum } from "../common/types.js"; +import { isPercyAutomateFrameworkSupported } from "../percy-automate/frameworks.js"; +import { isPercyWebFrameworkSupported } from "../percy-web/frameworks.js"; +import { + formatInstructionsWithNumbers, + generateVerificationMessage, +} from "./formatUtils.js"; +import { + RunTestsInstructionResult, + PercyAutomateNotImplementedType, +} from "./types.js"; +import { IMPORTANT_SETUP_WARNING } from "./index.js"; +import { PackageJsonVersion } from "../../../index.js"; + +export function checkPercyIntegrationSupport(input: { + integrationType: string; + detectedLanguage: string; + detectedTestingFramework?: string; + detectedBrowserAutomationFramework?: string; +}): { supported: boolean; errorMessage?: string } { + if (input.integrationType === PercyIntegrationTypeEnum.AUTOMATE) { + const isSupported = isPercyAutomateFrameworkSupported( + input.detectedLanguage, + input.detectedBrowserAutomationFramework || "", + input.detectedTestingFramework || "", + ); + if (!isSupported) { + return { + supported: false, + errorMessage: `Percy Automate is not supported for this configuration. Language: ${input.detectedLanguage} Testing Framework: ${input.detectedTestingFramework}`, + }; + } + } else if (input.integrationType === PercyIntegrationTypeEnum.WEB) { + const isSupported = isPercyWebFrameworkSupported( + input.detectedLanguage, + input.detectedBrowserAutomationFramework || "", + ); + if (!isSupported) { + return { + supported: false, + errorMessage: `Percy Web is not supported for this configuration. Language: ${input.detectedLanguage} Browser Automation Framework: ${input.detectedBrowserAutomationFramework}`, + }; + } + } + return { supported: true }; +} + +export async function formatToolResult( + resultPromise: Promise | RunTestsInstructionResult, + runType?: "percy-web" | "bstack" | "percy-bstack" | "percy-automate", +): Promise { + const { steps, requiresPercy, missingDependencies, shouldSkipFormatting } = + await resultPromise; + + if (shouldSkipFormatting) { + return { + content: steps.map((step) => ({ + type: "text" as const, + text: step.content, + })), + isError: steps.some((s) => s.isError), + steps, + requiresPercy, + missingDependencies, + }; + } + + const combinedInstructions = steps.map((step) => step.content).join("\n"); + const { formattedSteps, stepCount } = + formatInstructionsWithNumbers(combinedInstructions); + const verificationMessage = generateVerificationMessage(stepCount); + + const finalContent = [ + { type: "text" as const, text: IMPORTANT_SETUP_WARNING }, + { type: "text" as const, text: formattedSteps }, + { type: "text" as const, text: verificationMessage }, + ]; + + if (runType != "percy-web") { + finalContent.push({ + type: "text", + text: "CRITICAL: After the build is run, you MUST explicitly ask the user if they want to perform RCA (Root Cause Analysis) for any failed tests. This step is mandatory and should not be skipped.", + }); + } + + return { + content: finalContent, + isError: steps.some((s) => s.isError), + requiresPercy, + missingDependencies, + }; +} + +export function getPercyAutomateNotImplementedMessage( + type: PercyAutomateNotImplementedType, + input: { + detectedLanguage: string; + detectedBrowserAutomationFramework: string; + }, + supported: string[], +): string { + if (type === PercyAutomateNotImplementedType.LANGUAGE) { + return `Percy Automate does not support the language: ${input.detectedLanguage}. Supported languages are: ${supported.join(", ")}.`; + } else { + return `Percy Automate does not support ${input.detectedBrowserAutomationFramework} for ${input.detectedLanguage}. Supported frameworks for ${input.detectedLanguage} are: ${supported.join(", ")}.`; + } +} + +export function getBootstrapFailedMessage( + error: unknown, + context: { config: unknown; percyMode?: string }, +): string { + const error_message = + error instanceof Error ? error.message : "unknown error"; + return `Failed to bootstrap project with BrowserStack SDK. +Error: ${error_message} +Percy Mode: ${context.percyMode ?? "automate"} +MCP Version: ${PackageJsonVersion} +Please open an issue on GitHub if the problem persists.`; +} + +export function percyUnsupportedResult( + integrationType: PercyIntegrationTypeEnum, + supportCheck?: { errorMessage?: string }, +): CallToolResult { + const defaultMessage = `Percy ${integrationType} integration is not supported for this configuration.`; + + return { + content: [ + { + type: "text", + text: supportCheck?.errorMessage || defaultMessage, + }, + ], + isError: true, + shouldSkipFormatting: true, + }; +} + +export function validatePercyPathandFolders(input: any): void { + const hasFolderPaths = input.folderPaths && input.folderPaths.length > 0; + const hasFilePaths = input.filePaths && input.filePaths.length > 0; + + if (!hasFolderPaths && !hasFilePaths) { + throw new Error( + "Please provide either:\n" + + "• folderPaths: Array of directory paths containing test files\n" + + "• filePaths: Array of specific test file paths\n\n" + + "Example: { filePaths: ['/path/to/test.spec.js'] }", + ); + } +} diff --git a/src/tools/sdk-utils/handler.ts b/src/tools/sdk-utils/handler.ts new file mode 100644 index 00000000..317e2310 --- /dev/null +++ b/src/tools/sdk-utils/handler.ts @@ -0,0 +1,228 @@ +import { formatToolResult } from "./common/utils.js"; +import { BrowserStackConfig } from "../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { PercyIntegrationTypeEnum } from "./common/types.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { fetchPercyToken } from "./percy-web/fetchPercyToken.js"; +import { runPercyWeb } from "./percy-web/handler.js"; +import { runPercyAutomateOnly } from "./percy-automate/handler.js"; +import { runBstackSDKOnly } from "./bstack/sdkHandler.js"; +import { runPercyWithBrowserstackSDK } from "./percy-bstack/handler.js"; +import { + checkPercyIntegrationSupport, + validatePercyPathandFolders, +} from "./common/utils.js"; +import { + SetUpPercySchema, + RunTestsOnBrowserStackSchema, +} from "./common/schema.js"; +import { storedPercyResults } from "../../lib/inmemory-store.js"; +import { + getBootstrapFailedMessage, + percyUnsupportedResult, +} from "./common/utils.js"; +import { + PERCY_SIMULATE_INSTRUCTION, + PERCY_REPLACE_REGEX, + PERCY_SIMULATION_DRIVER_INSTRUCTION, + PERCY_VERIFICATION_REGEX, +} from "./common/constants.js"; + +export async function runTestsOnBrowserStackHandler( + rawInput: unknown, + config: BrowserStackConfig, +): Promise { + const input = RunTestsOnBrowserStackSchema.parse(rawInput); + const result = await runBstackSDKOnly(input, config); + return await formatToolResult(result); +} + +export async function setUpPercyHandler( + rawInput: unknown, + config: BrowserStackConfig, +): Promise { + try { + const input = SetUpPercySchema.parse(rawInput); + validatePercyPathandFolders(input); + + // Clear any previous Percy results for a fresh start + storedPercyResults.clear(); + + storedPercyResults.set({ + projectName: input.projectName, + detectedLanguage: input.detectedLanguage, + detectedBrowserAutomationFramework: + input.detectedBrowserAutomationFramework, + detectedTestingFramework: input.detectedTestingFramework, + integrationType: input.integrationType, + folderPaths: input.folderPaths || [], + filePaths: input.filePaths || [], + testFiles: {}, + }); + + const authorization = getBrowserStackAuth(config); + + const folderPaths = input.folderPaths || []; + const filePaths = input.filePaths || []; + + const percyInput = { + projectName: input.projectName, + detectedLanguage: input.detectedLanguage, + detectedBrowserAutomationFramework: + input.detectedBrowserAutomationFramework, + detectedTestingFramework: input.detectedTestingFramework, + integrationType: input.integrationType, + folderPaths, + filePaths, + }; + + // Check for Percy Web integration support + if (input.integrationType === PercyIntegrationTypeEnum.WEB) { + const supportCheck = checkPercyIntegrationSupport(percyInput); + if (!supportCheck.supported) { + return percyUnsupportedResult( + PercyIntegrationTypeEnum.WEB, + supportCheck, + ); + } + + // Fetch the Percy token + const percyToken = await fetchPercyToken( + input.projectName, + authorization, + { type: PercyIntegrationTypeEnum.WEB }, + ); + + const result = runPercyWeb(percyInput, percyToken); + return await formatToolResult(result, "percy-web"); + } else if (input.integrationType === PercyIntegrationTypeEnum.AUTOMATE) { + // First try Percy with BrowserStack SDK + const percyWithBrowserstackSDKResult = runPercyWithBrowserstackSDK( + { + ...percyInput, + devices: [], + }, + config, + ); + const hasPercySDKError = + percyWithBrowserstackSDKResult.steps && + percyWithBrowserstackSDKResult.steps.some((step) => step.isError); + + if (!hasPercySDKError) { + // Percy with SDK is supported, prepend warning and return those steps + if (percyWithBrowserstackSDKResult.steps) { + percyWithBrowserstackSDKResult.steps.unshift({ + type: "instruction" as const, + title: "Important: Existing SDK Setup", + content: + "If you have already set up the BrowserStack SDK, do not override it unless you have explicitly decided to do so.", + }); + } + return await formatToolResult(percyWithBrowserstackSDKResult); + } else { + // Fallback to standalone Percy Automate if supported + const supportCheck = checkPercyIntegrationSupport({ + ...percyInput, + integrationType: PercyIntegrationTypeEnum.AUTOMATE, + }); + if (!supportCheck.supported) { + return percyUnsupportedResult( + PercyIntegrationTypeEnum.AUTOMATE, + supportCheck, + ); + } + // SDK setup instructions (for Automate, without Percy) + const sdkInput = { + projectName: input.projectName, + detectedLanguage: input.detectedLanguage, + detectedBrowserAutomationFramework: + input.detectedBrowserAutomationFramework, + detectedTestingFramework: input.detectedTestingFramework, + devices: [], + }; + const sdkResult = await runBstackSDKOnly(sdkInput, config, true); + // Percy Automate instructions + const percyToken = await fetchPercyToken( + input.projectName, + authorization, + { type: PercyIntegrationTypeEnum.AUTOMATE }, + ); + const percyAutomateResult = runPercyAutomateOnly( + percyInput, + percyToken, + ); + + // Combine steps: warning, SDK steps, Percy Automate steps + const steps = [ + { + type: "instruction" as const, + title: "Important: Existing SDK Setup", + content: + "If you have already set up the BrowserStack SDK, do not override it unless you have explicitly decided to do so.", + }, + ...(sdkResult.steps || []), + ...(percyAutomateResult.steps || []), + ]; + + // Combine all steps into the final result + return await formatToolResult({ + ...percyAutomateResult, + steps, + }); + } + } else { + return { + content: [ + { + type: "text", + text: "Unknown or unsupported Percy integration type requested.", + }, + ], + isError: true, + shouldSkipFormatting: true, + }; + } + } catch (error) { + throw new Error(getBootstrapFailedMessage(error, { config })); + } +} + +export async function simulatePercyChangeHandler( + rawInput: unknown, + config: BrowserStackConfig, +): Promise { + try { + let percyInstruction; + + try { + percyInstruction = await setUpPercyHandler(rawInput, config); + } catch { + throw new Error("Failed to set up Percy"); + } + + if (percyInstruction.isError) { + return percyInstruction; + } + + if (Array.isArray(percyInstruction.content)) { + percyInstruction.content = percyInstruction.content.map((item) => { + if (typeof item.text === "string") { + const updatedText = item.text + .replace(PERCY_REPLACE_REGEX, PERCY_SIMULATE_INSTRUCTION) + .replace(PERCY_VERIFICATION_REGEX, ""); + return { ...item, text: updatedText }; + } + return item; + }); + } + + percyInstruction.content?.push({ + type: "text" as const, + text: PERCY_SIMULATION_DRIVER_INSTRUCTION, + }); + + return percyInstruction; + } catch (error) { + throw new Error(getBootstrapFailedMessage(error, { config })); + } +} diff --git a/src/tools/sdk-utils/instructions.ts b/src/tools/sdk-utils/instructions.ts deleted file mode 100644 index d98c872c..00000000 --- a/src/tools/sdk-utils/instructions.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { SUPPORTED_CONFIGURATIONS } from "./constants.js"; -import { SDKSupportedLanguage } from "./types.js"; -import { SDKSupportedBrowserAutomationFramework } from "./types.js"; -import { SDKSupportedTestingFramework } from "./types.js"; - -const errorMessageSuffix = - "Please open an issue at our Github repo: https://github.com/browserstack/browserstack-mcp-server/issues to request support for your project configuration"; - -export const getInstructionsForProjectConfiguration = ( - detectedBrowserAutomationFramework: SDKSupportedBrowserAutomationFramework, - detectedTestingFramework: SDKSupportedTestingFramework, - detectedLanguage: SDKSupportedLanguage, - username: string, - accessKey: string, -) => { - const configuration = SUPPORTED_CONFIGURATIONS[detectedLanguage]; - - if (!configuration) { - throw new Error( - `BrowserStack MCP Server currently does not support ${detectedLanguage}, ${errorMessageSuffix}`, - ); - } - - if (!configuration[detectedBrowserAutomationFramework]) { - throw new Error( - `BrowserStack MCP Server currently does not support ${detectedBrowserAutomationFramework} for ${detectedLanguage}, ${errorMessageSuffix}`, - ); - } - - if ( - !configuration[detectedBrowserAutomationFramework][detectedTestingFramework] - ) { - throw new Error( - `BrowserStack MCP Server currently does not support ${detectedTestingFramework} for ${detectedBrowserAutomationFramework} on ${detectedLanguage}, ${errorMessageSuffix}`, - ); - } - - const instructionFunction = - configuration[detectedBrowserAutomationFramework][detectedTestingFramework] - .instructions; - - return instructionFunction(username, accessKey); -}; - -export function generateBrowserStackYMLInstructions( - desiredPlatforms: string[], - enablePercy: boolean = false, -) { - let ymlContent = ` -# ====================== -# BrowserStack Reporting -# ====================== -# Project and build names help organize your test runs in BrowserStack dashboard and Percy. -# TODO: Replace these sample values with your actual project details -projectName: Sample Project -buildName: Sample Build - -# ======================================= -# Platforms (Browsers / Devices to test) -# ======================================= -# Platforms object contains all the browser / device combinations you want to test on. -# Generate this on the basis of the following platforms requested by the user: -# Requested platforms: ${desiredPlatforms} -platforms: - - os: Windows - osVersion: 11 - browserName: chrome - browserVersion: latest - -# ======================= -# Parallels per Platform -# ======================= -# The number of parallel threads to be used for each platform set. -# BrowserStack's SDK runner will select the best strategy based on the configured value -# -# Example 1 - If you have configured 3 platforms and set \`parallelsPerPlatform\` as 2, a total of 6 (2 * 3) parallel threads will be used on BrowserStack -# -# Example 2 - If you have configured 1 platform and set \`parallelsPerPlatform\` as 5, a total of 5 (1 * 5) parallel threads will be used on BrowserStack -parallelsPerPlatform: 1 - -# ================= -# Local Testing -# ================= -# Set to true to test local -browserstackLocal: true - -# =================== -# Debugging features -# =================== -debug: true # Visual logs, text logs, etc. -testObservability: true # For Test Observability`; - - if (enablePercy) { - ymlContent += ` - -# ===================== -# Percy Visual Testing -# ===================== -# Set percy to true to enable visual testing. -# Set percyCaptureMode to 'manual' to control when screenshots are taken. -percy: true -percyCaptureMode: manual`; - } - return ` - Create a browserstack.yml file in the project root. The file should be in the following format: - - \`\`\`yaml${ymlContent} - \`\`\` - \n`; -} - -export function formatInstructionsWithNumbers( - instructionText: string, - separator: string = "---STEP---", -): string { - // Split the instructions by the separator - const steps = instructionText - .split(separator) - .map((step) => step.trim()) - .filter((step) => step.length > 0); - - // If no separators found, treat the entire text as one step - if (steps.length === 1 && !instructionText.includes(separator)) { - return `**Step 1:**\n${instructionText.trim()}\n\n**✅ Verification:**\nPlease verify that you have completed all the steps above to ensure proper setup.`; - } - - // Format each step with numbering - const formattedSteps = steps - .map((step, index) => { - return `**Step ${index + 1}:**\n${step.trim()}`; - }) - .join("\n\n"); - - // Add verification statement at the end - const verificationText = `\n\n**✅ Verification:**\nPlease verify that you have completed all ${steps.length} steps above to ensure proper setup. If you encounter any issues, double-check each step and ensure all commands executed successfully.`; - - return formattedSteps + verificationText; -} diff --git a/src/tools/sdk-utils/percy-automate/constants.ts b/src/tools/sdk-utils/percy-automate/constants.ts new file mode 100644 index 00000000..e73e7bde --- /dev/null +++ b/src/tools/sdk-utils/percy-automate/constants.ts @@ -0,0 +1,348 @@ +import { PERCY_SNAPSHOT_INSTRUCTION } from "../common/constants.js"; +export const percyAutomateReviewSnapshotsStep = ` +---STEP--- +Review the snapshots + - Go to your Percy project on https://percy.io to review snapshots and approve/reject any visual changes. +`; + +export const pythonPytestSeleniumInstructions = ` +Install Percy Automate dependencies + - Install Percy CLI: + npm install --save-dev @percy/cli + - Install Percy Python SDK for Automate: + pip install percy-selenium + +---STEP--- +Update your Pytest test script +${PERCY_SNAPSHOT_INSTRUCTION} + - Import the Percy snapshot helper: + from percy import percy_screenshot + - In your test, take snapshots at key points: + percy_screenshot(driver, "Your snapshot name") + +Example: +\`\`\`python +import pytest +from selenium import webdriver +from percy import percy_screenshot + +@pytest.fixture +def driver(): + driver = webdriver.Chrome() + yield driver + driver.quit() + +def test_homepage(driver): + driver.get("http://localhost:8000") + percy_screenshot(driver, "Home page") + # ... more test steps ... + percy_screenshot(driver, "After login") +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- browserstack-sdk pytest'). +${percyAutomateReviewSnapshotsStep} +`; + +export const pythonPytestPlaywrightInstructions = ` +Install Percy Automate dependencies + - Install Percy CLI: + npm install --save @percy/cli + - Install Percy Playwright SDK for Automate: + pip install percy-playwright + +---STEP--- +Update your Playwright test script +${PERCY_SNAPSHOT_INSTRUCTION} + - Import the Percy screenshot helper: + from percy import percy_screenshot + - In your test, take snapshots at key points: + percy_screenshot(page, name="Your snapshot name") + # You can pass \`options\`: + percy_screenshot(page, name="Your snapshot name", options={ "full_page": True }) + +Example: +\`\`\`python +from playwright.sync_api import sync_playwright +from percy import percy_screenshot + +def test_visual_regression(): + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto("http://localhost:8000") + percy_screenshot(page, name="Home page") + # ... more test steps ... + percy_screenshot(page, name="After login", options={ "full_page": True }) + browser.close() +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- '). +${percyAutomateReviewSnapshotsStep} +`; + +export const jsCypressPercyAutomateInstructions = ` +Install Percy Automate dependencies + - Install Percy CLI: + npm install --save-dev @percy/cli + - Install Percy Cypress SDK: + npm install --save-dev @percy/cypress + +---STEP--- +Update your Cypress test script +${PERCY_SNAPSHOT_INSTRUCTION} + - Import and initialize Percy in your cypress/support/index.js: + import '@percy/cypress'; + - In your test, take snapshots at key points: + cy.percySnapshot('Your snapshot name'); + +Example: +\`\`\`javascript +describe('Percy Automate Cypress Example', () => { + it('should take Percy snapshots', () => { + cy.visit('http://localhost:8000'); + cy.percySnapshot('Home page'); + // ... more test steps ... + cy.percySnapshot('After login'); + }); +}); +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- cypress run'). +${percyAutomateReviewSnapshotsStep} +`; + +export const mochaPercyAutomateInstructions = ` +Install Percy Automate dependencies + - Install Percy CLI: + npm install --save @percy/cli + - Install Percy Selenium SDK: + npm install @percy/selenium-webdriver@2.0.1 + +---STEP--- +Update your Mocha Automate test script + - Import the Percy screenshot helper: + const { percyScreenshot } = require('@percy/selenium-webdriver'); + - Use the Percy screenshot command to take required screenshots in your Automate session: + await percyScreenshot(driver, 'Screenshot 1'); + options = { percyCSS: 'h1{color:red;}' }; + await percyScreenshot(driver, 'Screenshot 2', options); + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- mocha'). +${percyAutomateReviewSnapshotsStep} +`; + +// Mocha Percy Playwright Instructions +export const mochaPercyPlaywrightInstructions = ` +Install Percy Automate dependencies + - Install the latest Percy CLI: + npm install --save @percy/cli + - Install the Percy Playwright SDK: + npm install @percy/playwright + +---STEP--- +Update your Mocha Playwright test script + - Import the Percy screenshot helper: + const { percyScreenshot } = require("@percy/playwright"); + - Use the Percy screenshot command to take required screenshots in your Automate session. + +Example: +\`\`\`javascript +const { percyScreenshot } = require("@percy/playwright"); +await percyScreenshot(page, "Screenshot 1"); +// With options +await percyScreenshot(page, "Screenshot 2", { percyCSS: "h1{color:green;}" }); +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- '). +${percyAutomateReviewSnapshotsStep} +`; + +export const jestPercyAutomateInstructions = ` +Install or upgrade the BrowserStack SDK: + - Install the SDK: + npm i -D browserstack-node-sdk@latest + - Run the setup: + npx setup --username "YOUR_USERNAME" --key "YOUR_ACCESS_KEY" + +---STEP--- +Manually capture screenshots: + 1. Import the BrowserStack Percy SDK in your test script: + const { percy } = require('browserstack-node-sdk'); + 2. Use \`percy.screenshot(driver, name)\` at desired points in your test. + +Example: +\`\`\`javascript +const { percy } = require('browserstack-node-sdk'); +describe("JestJS test", () => { + let driver; + const caps = require("../" + conf_file).capabilities; + + beforeAll(() => { + driver = new Builder() + .usingServer("http://example-servername/hub") + .withCapabilities(caps) + .build(); + }); + + test("my test", async () => { + // ... + await percy.screenshot(driver, "My Screenshot"); + // ... + }); +}); +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npm run [your-test-script-name]-browserstack'). +${percyAutomateReviewSnapshotsStep} +`; + +export const webdriverioPercyAutomateInstructions = ` +Install or upgrade BrowserStack SDK + - Install the BrowserStack SDK: + npm i -D @wdio/browserstack-service + +---STEP--- +Update your WebdriverIO config file + 1. Set \`percy: true\` + 2. Set a \`projectName\` + 3. Set \`percyCaptureMode: auto\` (or another mode as needed) + +Example WebdriverIO config: +\`\`\`js +exports.config = { + user: process.env.BROWSERSTACK_USERNAME || 'YOUR_USERNAME', + key: process.env.BROWSERSTACK_ACCESS_KEY || 'YOUR_ACCESS_KEY', + hostname: 'hub.browserstack.com', + services: [ + [ + 'browserstack', + { browserstackLocal: true, opts: { forcelocal: false }, percy: true, percyCaptureMode: 'auto' } + ], + ], + // add path to the test file +} +\`\`\` + +---STEP--- +(Optional) Manually capture screenshots + 1. Import the BrowserStack Percy SDK in your test script: + const { percy } = require('browserstack-node-sdk'); + 2. Add the \`await percy.screenshot(driver, name)\` method at required points in your test script. + +Example: +\`\`\`javascript + const { percy } = require('browserstack-node-sdk'); + 2. Add the \`await percy.screenshot(driver, name)\` method at required points in your test script. + +Example: +\`\`\`javascript +const { percy } = require('browserstack-node-sdk'); +describe("WebdriverIO Test", () => { + it("my test", async () => { + // .... + await percy.screenshot(driver, "My Screenshot") + // .... + }); +}); +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command as defined in your package.json file. +${percyAutomateReviewSnapshotsStep} +`; + +export const testcafePercyAutomateInstructions = ` +Install Percy dependencies + - Install the required dependencies: + npm install --save-dev @percy/cli @percy/testcafe + +---STEP--- +Update your test script +${PERCY_SNAPSHOT_INSTRUCTION} + - Import the Percy library and use the percySnapshot function to take screenshots. + +Example: +\`\`\`javascript +import percySnapshot from '@percy/testcafe'; +fixture('MyFixture') + .page('https://devexpress.github.io/testcafe/example/'); +test('Test1', async t => { + await t.typeText('#developer-name', 'John Doe'); + await percySnapshot(t, 'TestCafe Example'); +}); +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- testcafe chrome:headless tests'). +${percyAutomateReviewSnapshotsStep} +`; + +// Java Playwright Percy Automate Instructions +export const javaPlaywrightJunitInstructions = ` +Install Percy Automate dependencies + - Install the latest Percy CLI: + npm install --save @percy/cli + - Add the Percy Playwright Java SDK to your pom.xml: +\`\`\`xml + + io.percy + percy-playwright-java + 1.0.0 + +\`\`\` + +---STEP--- +Update your Automate test script + - Import the Percy library: + import io.percy.playwright.Percy; + - Use the Percy screenshot command to take required screenshots in your Automate session. + +Example: +\`\`\`java +Percy percy = new Percy(page); +percy.screenshot("screenshot_1"); +// With options +percy.screenshot("screenshot_2", options); +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- '). + +${percyAutomateReviewSnapshotsStep} +`; + +// C# Playwright NUnit Percy Automate Instructions +export const csharpPlaywrightNunitInstructions = ` +Install Percy Automate dependencies + - Install the latest Percy CLI: + npm install --save @percy/cli + - Add the Percy Playwright SDK to your .csproj file: +\`\`\`xml + +\`\`\` + +---STEP--- +Update your NUnit Playwright test script + - Import the Percy library: + using PercyIO.Playwright; + - Use the Percy screenshot command to take required screenshots in your Automate session. + +Example: +\`\`\`csharp +using PercyIO.Playwright; +Percy.Screenshot(page, "example_screenshot_1"); +// With options +Percy.Screenshot(page, "example_screenshot_2", options); +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- '). + +${percyAutomateReviewSnapshotsStep} +`; diff --git a/src/tools/sdk-utils/percy-automate/frameworks.ts b/src/tools/sdk-utils/percy-automate/frameworks.ts new file mode 100644 index 00000000..50ba8444 --- /dev/null +++ b/src/tools/sdk-utils/percy-automate/frameworks.ts @@ -0,0 +1,56 @@ +import { ConfigMapping } from "./types.js"; +import * as instructions from "./constants.js"; + +export const SUPPORTED_CONFIGURATIONS: ConfigMapping = { + python: { + selenium: { + pytest: { + instructions: instructions.pythonPytestSeleniumInstructions, + }, + }, + playwright: { + pytest: { + instructions: instructions.pythonPytestPlaywrightInstructions, + }, + }, + }, + java: { + playwright: { + junit: { instructions: instructions.javaPlaywrightJunitInstructions }, + }, + }, + nodejs: { + selenium: { + mocha: { instructions: instructions.mochaPercyAutomateInstructions }, + jest: { instructions: instructions.jestPercyAutomateInstructions }, + webdriverio: { + instructions: instructions.webdriverioPercyAutomateInstructions, + }, + testcafe: { + instructions: instructions.testcafePercyAutomateInstructions, + }, + }, + playwright: { + mocha: { instructions: instructions.mochaPercyPlaywrightInstructions }, + jest: { instructions: instructions.jestPercyAutomateInstructions }, + }, + }, +}; + +/** + * Utility function to check if a given language, driver, and testing framework + * are supported by Percy Automate. + * This now expects the structure: language -> driver -> framework + */ +export function isPercyAutomateFrameworkSupported( + language: string, + driver: string, + framework: string, +): boolean { + const languageConfig = + SUPPORTED_CONFIGURATIONS[language as keyof typeof SUPPORTED_CONFIGURATIONS]; + if (!languageConfig) return false; + const driverConfig = languageConfig[driver as keyof typeof languageConfig]; + if (!driverConfig) return false; + return !!driverConfig[framework as keyof typeof driverConfig]; +} diff --git a/src/tools/sdk-utils/percy-automate/handler.ts b/src/tools/sdk-utils/percy-automate/handler.ts new file mode 100644 index 00000000..f5e43135 --- /dev/null +++ b/src/tools/sdk-utils/percy-automate/handler.ts @@ -0,0 +1,43 @@ +import { RunTestsInstructionResult, RunTestsStep } from "../common/types.js"; +import { SetUpPercyInput } from "../common/schema.js"; +import { SUPPORTED_CONFIGURATIONS } from "./frameworks.js"; +import { SDKSupportedLanguage } from "../common/types.js"; + +export function runPercyAutomateOnly( + input: SetUpPercyInput, + percyToken: string, +): RunTestsInstructionResult { + const steps: RunTestsStep[] = []; + + // Assume configuration is supported due to guardrails at orchestration layer + const languageConfig = + SUPPORTED_CONFIGURATIONS[input.detectedLanguage as SDKSupportedLanguage]; + const driverConfig = languageConfig[input.detectedBrowserAutomationFramework]; + const testingFrameworkConfig = driverConfig + ? driverConfig[input.detectedTestingFramework] + : undefined; + + // Generate instructions for the supported configuration with project name + const instructions = testingFrameworkConfig + ? testingFrameworkConfig.instructions + : ""; + + // Prepend a step to set the Percy token in the environment + steps.push({ + type: "instruction", + title: "Set Percy Token in Environment", + content: `Here is percy token if required {${percyToken}}`, + }); + + steps.push({ + type: "instruction", + title: `Percy Automate Setup for ${input.detectedLanguage} with ${input.detectedTestingFramework}`, + content: instructions, + }); + + return { + steps, + requiresPercy: true, + missingDependencies: [], + }; +} diff --git a/src/tools/sdk-utils/percy-automate/index.ts b/src/tools/sdk-utils/percy-automate/index.ts new file mode 100644 index 00000000..230bfcb5 --- /dev/null +++ b/src/tools/sdk-utils/percy-automate/index.ts @@ -0,0 +1,2 @@ +// Percy Automate utilities +export { runPercyAutomateOnly } from "./handler.js"; diff --git a/src/tools/sdk-utils/percy-automate/types.ts b/src/tools/sdk-utils/percy-automate/types.ts new file mode 100644 index 00000000..c1860f80 --- /dev/null +++ b/src/tools/sdk-utils/percy-automate/types.ts @@ -0,0 +1,13 @@ +/** + * Type for Percy Automate configuration mapping. + * Structure: language -> driver -> testingFramework -> { instructions: string } + */ +export type ConfigMapping = { + [language: string]: { + [driver: string]: { + [framework: string]: { + instructions: string; + }; + }; + }; +}; diff --git a/src/tools/sdk-utils/percy/constants.ts b/src/tools/sdk-utils/percy-bstack/constants.ts similarity index 72% rename from src/tools/sdk-utils/percy/constants.ts rename to src/tools/sdk-utils/percy-bstack/constants.ts index 39dea6a1..7f9da914 100644 --- a/src/tools/sdk-utils/percy/constants.ts +++ b/src/tools/sdk-utils/percy-bstack/constants.ts @@ -1,10 +1,10 @@ -import { PercyConfigMapping } from "./types.js"; +import { PERCY_SNAPSHOT_INSTRUCTION } from "../common/constants.js"; -const javaSeleniumInstructions = ` +export const javaSeleniumInstructions = ` Import the BrowserStack Percy SDK in your test script: Add the Percy import to your test file. ----STEP--- +${PERCY_SNAPSHOT_INSTRUCTION} Add screenshot capture method at required points: Use the \`PercySDK.screenshot(driver, name)\` method at points in your test script where you want to capture screenshots. @@ -35,6 +35,8 @@ export const nodejsSeleniumInstructions = ` Import the BrowserStack Percy SDK in your test script: Add the Percy import to your test file. +${PERCY_SNAPSHOT_INSTRUCTION} + ---STEP--- Add screenshot capture method at required points: @@ -47,20 +49,20 @@ describe("sample Test", () => { test("my test", async () => { // .... - await percy.snapshot(driver, "My Snapshot") + await percy.screenshot(driver, "My Snapshot") // .... }); }) \`\`\` `; -const webdriverioPercyInstructions = ` +export const webdriverioPercyInstructions = ` Enable Percy in \`wdio.conf.js\`: In your WebdriverIO configuration file, modify the 'browserstack' service options to enable Percy. - Set \`percy: true\`. - Set a \`projectName\`. This is required and will be used for both your Automate and Percy projects. -- Set \`percyCaptureMode\`. The default \`auto\` mode is recommended, which captures screenshots on events like clicks. Other modes are \`testcase\`, \`click\`, \`screenshot\`, and \`manual\`. +- Set \`percyCaptureMode\`. The default \`manual\` as we are adding screenshot commands manually. Here's how to modify the service configuration: \`\`\`javascript @@ -74,7 +76,7 @@ exports.config = { { // ... other service options percy: true, - percyCaptureMode: 'auto' // or 'manual', 'testcase', etc. + percyCaptureMode: 'manual' // or 'auto', etc. }, ], ], @@ -89,6 +91,8 @@ exports.config = { }; \`\`\` +${PERCY_SNAPSHOT_INSTRUCTION} + ---STEP--- Manually Capturing Screenshots (Optional): @@ -117,11 +121,11 @@ describe("My WebdriverIO Test", () => { \`\`\` `; -const csharpSeleniumInstructions = ` +export const csharpSeleniumInstructions = ` Import the BrowserStack Percy SDK in your test script: Add the Percy import to your test file. ----STEP--- +${PERCY_SNAPSHOT_INSTRUCTION} Add screenshot capture method at required points: Use the \`PercySDK.Screenshot(driver, name)\` method at points in your test script where you want to capture screenshots. @@ -130,8 +134,6 @@ Here's an example: \`\`\`csharp using BrowserStackSDK.Percy; -using NUnit.Framework; - namespace Tests; public class MyTest @@ -151,33 +153,3 @@ public class MyTest } \`\`\` `; - -export const PERCY_INSTRUCTIONS: PercyConfigMapping = { - java: { - selenium: { - testng: { script_updates: javaSeleniumInstructions }, - cucumber: { script_updates: javaSeleniumInstructions }, - junit4: { script_updates: javaSeleniumInstructions }, - junit5: { script_updates: javaSeleniumInstructions }, - serenity: { script_updates: javaSeleniumInstructions }, - }, - }, - csharp: { - selenium: { - nunit: { script_updates: csharpSeleniumInstructions }, - }, - }, - nodejs: { - selenium: { - mocha: { - script_updates: nodejsSeleniumInstructions, - }, - jest: { - script_updates: nodejsSeleniumInstructions, - }, - webdriverio: { - script_updates: webdriverioPercyInstructions, - }, - }, - }, -}; diff --git a/src/tools/sdk-utils/percy-bstack/frameworks.ts b/src/tools/sdk-utils/percy-bstack/frameworks.ts new file mode 100644 index 00000000..ed51557e --- /dev/null +++ b/src/tools/sdk-utils/percy-bstack/frameworks.ts @@ -0,0 +1,29 @@ +import { ConfigMapping } from "./types.js"; +import * as constants from "./constants.js"; + +export const PERCY_INSTRUCTIONS: ConfigMapping = { + java: { + selenium: { + testng: { instructions: constants.javaSeleniumInstructions }, + cucumber: { instructions: constants.javaSeleniumInstructions }, + junit4: { instructions: constants.javaSeleniumInstructions }, + junit5: { instructions: constants.javaSeleniumInstructions }, + selenide: { instructions: constants.javaSeleniumInstructions }, + jbehave: { instructions: constants.javaSeleniumInstructions }, + }, + }, + csharp: { + selenium: { + nunit: { instructions: constants.csharpSeleniumInstructions }, + xunit: { instructions: constants.csharpSeleniumInstructions }, + specflow: { instructions: constants.csharpSeleniumInstructions }, + }, + }, + nodejs: { + selenium: { + mocha: { instructions: constants.nodejsSeleniumInstructions }, + jest: { instructions: constants.nodejsSeleniumInstructions }, + webdriverio: { instructions: constants.webdriverioPercyInstructions }, + }, + }, +}; diff --git a/src/tools/sdk-utils/percy-bstack/handler.ts b/src/tools/sdk-utils/percy-bstack/handler.ts new file mode 100644 index 00000000..9ee200fc --- /dev/null +++ b/src/tools/sdk-utils/percy-bstack/handler.ts @@ -0,0 +1,164 @@ +// Percy + BrowserStack SDK combined handler +import { RunTestsInstructionResult, RunTestsStep } from "../common/types.js"; +import { RunTestsOnBrowserStackInput } from "../common/schema.js"; +import { getBrowserStackAuth } from "../../../lib/get-auth.js"; +import { getSDKPrefixCommand } from "../bstack/commands.js"; +import { generateBrowserStackYMLInstructions } from "../bstack/configUtils.js"; +import { getInstructionsForProjectConfiguration } from "../common/instructionUtils.js"; +import { + formatPercyInstructions, + getPercyInstructions, +} from "./instructions.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { + SDKSupportedBrowserAutomationFramework, + SDKSupportedTestingFramework, + SDKSupportedLanguage, +} from "../common/types.js"; + +export function runPercyWithBrowserstackSDK( + input: RunTestsOnBrowserStackInput, + config: BrowserStackConfig, +): RunTestsInstructionResult { + const steps: RunTestsStep[] = []; + const authString = getBrowserStackAuth(config); + const [username, accessKey] = authString.split(":"); + + // Check if Percy is supported for this configuration + const percyResult = getPercyInstructions( + input.detectedLanguage as SDKSupportedLanguage, + input.detectedBrowserAutomationFramework as SDKSupportedBrowserAutomationFramework, + input.detectedTestingFramework as SDKSupportedTestingFramework, + ); + + if (!percyResult) { + // Percy not supported for this configuration + return { + steps: [ + { + type: "error", + title: "Percy Not Supported", + content: `Percy is not supported for this ${input.detectedBrowserAutomationFramework} framework configuration. Please use BrowserStack SDK only mode or try a different framework combination.`, + isError: true, + }, + ], + requiresPercy: true, + shouldSkipFormatting: true, + missingDependencies: [], + }; + } + + // Handle frameworks with unique setup instructions that don't use browserstack.yml + if ( + input.detectedBrowserAutomationFramework === "cypress" || + input.detectedTestingFramework === "webdriverio" + ) { + const frameworkInstructions = getInstructionsForProjectConfiguration( + input.detectedBrowserAutomationFramework as SDKSupportedBrowserAutomationFramework, + input.detectedTestingFramework as SDKSupportedTestingFramework, + input.detectedLanguage as SDKSupportedLanguage, + username, + accessKey, + ); + + if (frameworkInstructions && frameworkInstructions.setup) { + steps.push({ + type: "instruction", + title: "Framework-Specific Setup", + content: frameworkInstructions.setup, + }); + } + + steps.push({ + type: "instruction", + title: "Percy Setup (BrowserStack SDK + Percy)", + content: formatPercyInstructions(percyResult), + }); + + if (frameworkInstructions && frameworkInstructions.run) { + steps.push({ + type: "instruction", + title: "Run the tests", + content: frameworkInstructions.run, + }); + } + + return { + steps, + requiresPercy: true, + missingDependencies: [], + }; + } + + // Default flow using browserstack.yml with Percy + const sdkSetupCommand = getSDKPrefixCommand( + input.detectedLanguage as SDKSupportedLanguage, + input.detectedTestingFramework as SDKSupportedTestingFramework, + username, + accessKey, + ); + + if (sdkSetupCommand) { + steps.push({ + type: "instruction", + title: "Install BrowserStack SDK", + content: sdkSetupCommand, + }); + } + + const ymlInstructions = generateBrowserStackYMLInstructions( + { + platforms: + ((input as any).devices as string[][] | undefined)?.map((t) => + t.join(" "), + ) || [], + enablePercy: true, + projectName: input.projectName, + }, + config, + ); + + if (ymlInstructions) { + steps.push({ + type: "instruction", + title: "Configure browserstack.yml", + content: ymlInstructions, + }); + } + + const frameworkInstructions = getInstructionsForProjectConfiguration( + input.detectedBrowserAutomationFramework as SDKSupportedBrowserAutomationFramework, + input.detectedTestingFramework as SDKSupportedTestingFramework, + input.detectedLanguage as SDKSupportedLanguage, + username, + accessKey, + ); + + if (frameworkInstructions && frameworkInstructions.setup) { + steps.push({ + type: "instruction", + title: "Framework-Specific Setup", + content: frameworkInstructions.setup, + }); + } + + steps.push({ + type: "instruction", + title: "Percy Setup (BrowserStack SDK + Percy)", + content: formatPercyInstructions(percyResult), + }); + + if (frameworkInstructions && frameworkInstructions.run) { + steps.push({ + type: "instruction", + title: "Run the tests", + content: frameworkInstructions.run, + }); + } + + return { + steps, + requiresPercy: true, + missingDependencies: [], + }; +} diff --git a/src/tools/sdk-utils/percy-bstack/index.ts b/src/tools/sdk-utils/percy-bstack/index.ts new file mode 100644 index 00000000..6338ed2e --- /dev/null +++ b/src/tools/sdk-utils/percy-bstack/index.ts @@ -0,0 +1,8 @@ +// Percy + BrowserStack SDK utilities +export { runPercyWithBrowserstackSDK } from "./handler.js"; +export { + getPercyInstructions, + formatPercyInstructions, +} from "./instructions.js"; +export { PERCY_INSTRUCTIONS } from "./frameworks.js"; +export type { ConfigMapping } from "./types.js"; diff --git a/src/tools/sdk-utils/percy/instructions.ts b/src/tools/sdk-utils/percy-bstack/instructions.ts similarity index 60% rename from src/tools/sdk-utils/percy/instructions.ts rename to src/tools/sdk-utils/percy-bstack/instructions.ts index f642efa5..dab7941b 100644 --- a/src/tools/sdk-utils/percy/instructions.ts +++ b/src/tools/sdk-utils/percy-bstack/instructions.ts @@ -1,19 +1,17 @@ +// Percy + BrowserStack SDK instructions and utilities import { SDKSupportedBrowserAutomationFramework, SDKSupportedLanguage, SDKSupportedTestingFramework, -} from "../types.js"; -import { PERCY_INSTRUCTIONS } from "./constants.js"; -import { PercyInstructions } from "./types.js"; +} from "../common/types.js"; +import { PERCY_INSTRUCTIONS } from "./frameworks.js"; -/** - * Retrieves Percy-specific instructions for a given language and framework. - */ +// Retrieves Percy-specific instructions for a given language and framework export function getPercyInstructions( language: SDKSupportedLanguage, automationFramework: SDKSupportedBrowserAutomationFramework, testingFramework: SDKSupportedTestingFramework, -): PercyInstructions | null { +): { instructions: string } | null { const langConfig = PERCY_INSTRUCTIONS[language]; if (!langConfig) { return null; @@ -32,14 +30,12 @@ export function getPercyInstructions( return percyInstructions; } -/** - * Formats the retrieved Percy instructions into a user-friendly string. - */ -export function formatPercyInstructions( - instructions: PercyInstructions, -): string { - return `\n\n## Percy Visual Testing Setup +// Formats the retrieved Percy instructions into a user-friendly string +export function formatPercyInstructions(instructions: { + instructions: string; +}): string { + return `---STEP--- Percy Visual Testing Setup To enable visual testing with Percy, you need to make the following changes to your project configuration and test scripts. -${instructions.script_updates} +${instructions.instructions} `; } diff --git a/src/tools/sdk-utils/percy-bstack/types.ts b/src/tools/sdk-utils/percy-bstack/types.ts new file mode 100644 index 00000000..b45f9628 --- /dev/null +++ b/src/tools/sdk-utils/percy-bstack/types.ts @@ -0,0 +1,14 @@ +/** + * Type for Percy + BrowserStack SDK configuration mapping. + * Structure: language -> automationFramework -> testingFramework -> { instructions: (bsdkToken: string) => string } + */ + +export type ConfigMapping = { + [language: string]: { + [automationFramework: string]: { + [testingFramework: string]: { + instructions: string; + }; + }; + }; +}; diff --git a/src/tools/sdk-utils/percy-web/constants.ts b/src/tools/sdk-utils/percy-web/constants.ts new file mode 100644 index 00000000..fbbb2b89 --- /dev/null +++ b/src/tools/sdk-utils/percy-web/constants.ts @@ -0,0 +1,971 @@ +import { PERCY_SNAPSHOT_INSTRUCTION } from "../common/constants.js"; +export const percyReviewSnapshotsStep = ` +---STEP--- +Review the snapshots + - Go to your Percy project on https://percy.io to review snapshots and approve/reject any visual changes. +`; + +export const pythonInstructionsSnapshot = ` +Example: +\`\`\`python +- Import the Percy snapshot helper: +from selenium import webdriver +from percy import percy_snapshot + +driver = webdriver.Chrome() +driver.get('http://localhost:8000') +percy_snapshot(driver, 'Home page') +# ... more test steps ... +percy_snapshot(driver, 'After login') +\`\`\` +`; + +export const nodejsInstructionsSnapshot = ` +- Import the Percy snapshot helper: + const { percySnapshot } = require('@percy/selenium-js'); + - In your test, take snapshots like this: + await percySnapshot(driver, "Your snapshot name"); + +Example: +\`\`\`javascript +const { Builder } = require('selenium-webdriver'); +const percySnapshot = require('@percy/selenium-webdriver'); + +const driver = await new Builder().forBrowser('chrome').build(); +await driver.get('http://localhost:8000'); +await percySnapshot(driver, 'Home page'); +\`\`\` +`; + +export const javaInstructionsSnapshot = ` + - Import the Percy snapshot helper: + import io.percy.selenium.Percy; + - In your test, take snapshots like this: + Percy percy = new Percy(driver); + percy.snapshot("Your snapshot name"); + Example: +\`\`\`java +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.chrome.ChromeDriver; +import io.percy.selenium.Percy; + +public class PercyExample { + public static void main(String[] args) { + WebDriver driver = new ChromeDriver(); + driver.get("http://localhost:8000"); + Percy percy = new Percy(driver); + percy.snapshot("Home page"); + driver.quit(); + } +} +\`\`\``; + +export const rubyInstructionsSnapshot = ` + - Require the Percy snapshot helper: + require 'percy' + - In your test, take snapshots like this: + Percy.snapshot(page, 'Your snapshot name') + +Example: +\`\`\`ruby +require 'selenium-webdriver' +require 'percy' + +driver = Selenium::WebDriver.for :chrome +driver.get('http://localhost:8000') +Percy.snapshot(driver, 'Your snapshot name') +driver.quit +\`\`\` +`; + +export const rubyCapybaraInstructionsSnapshot = ` + - In your test setup file, require percy/capybara: + require 'percy/capybara' + - In your test, take snapshots like this: + page.percy_snapshot('Capybara snapshot') + +Example: +\`\`\`ruby +require 'percy/capybara' + +describe 'my feature', type: :feature do + it 'renders the page' do + visit 'https://example.com' + page.percy_snapshot('Capybara snapshot') + end +end +\`\`\` + + - The snapshot method arguments are: + page.percy_snapshot(name[, options]) + name - The snapshot name; must be unique to each snapshot; defaults to the test title + options - See per-snapshot configuration options +`; + +export const csharpInstructionsSnapshot = ` + - Import the Percy snapshot helper: + using PercyIO.Selenium; + - In your test, take snapshots like this: + Percy.Snapshot(driver,"Your snapshot name"); + +Example: +\`\`\`csharp +using OpenQA.Selenium; +using OpenQA.Selenium.Chrome; +using PercyIO.Selenium; + +class PercyExample +{ + static void Main() + { + IWebDriver driver = new ChromeDriver(); + driver.Navigate().GoToUrl("http://localhost:8000"); + Percy.Snapshot(driver,"Empty Todo State"); + driver.Quit(); + } +} +\`\`\` +`; + +export const javaPlaywrightInstructionsSnapshot = ` + - Import the Percy library and use the snapshot method: + percy.snapshot("snapshot_1"); + - You can also pass options: + Map options = new HashMap<>(); + options.put("testCase", "Should add product to cart"); + percy.snapshot("snapshot_2", options); + +Example: +\`\`\`java +import com.microsoft.playwright.*; +import io.percy.playwright.*; + +public class PercyPlaywrightExample { + public static void main(String[] args) { + try (Playwright playwright = Playwright.create()) { + Browser browser = playwright.chromium().launch(); + Page page = browser.newPage(); + Percy percy = new Percy(page); + + page.navigate("http://localhost:8000"); + percy.snapshot("Home page"); + + // ... more test steps ... + percy.snapshot("After login"); + + browser.close(); + } + } +} +\`\`\` +`; + +export const nodejsPlaywrightInstructionsSnapshot = ` + - Import the Percy snapshot helper: + const percySnapshot = require('@percy/playwright'); + - In your test, take snapshots like this: + await percySnapshot(page, "Your snapshot name"); + +Example: +\`\`\`javascript +const { chromium } = require('playwright'); +const percySnapshot = require('@percy/playwright'); + +(async () => { + const browser = await chromium.launch(); + const page = await browser.newPage(); + await page.goto('http://example.com/', { waitUntil: 'networkidle' }); + await percySnapshot(page, 'Example Site'); + await browser.close(); +})(); +\`\`\` +`; + +export const nodejsWebdriverioInstructionsSnapshot = ` + - Import the Percy snapshot helper: + const percySnapshot = require('@percy/selenium-webdriver'); + - In your test, take snapshots like this: + await percySnapshot(driver, "Your snapshot name"); + +Example: +\`\`\`javascript +const { remote } = require('webdriverio'); +const percySnapshot = require('@percy/selenium-webdriver'); + +(async () => { + const browser = await remote({ + logLevel: 'error', + capabilities: { browserName: 'chrome' } + }); + + await browser.url('https://example.com'); + await percySnapshot(browser, 'WebdriverIO example'); + await browser.deleteSession(); +})(); +\`\`\` +`; + +export const nodejsEmberInstructionsSnapshot = ` + - Import the Percy snapshot helper: + import percySnapshot from '@percy/ember'; + - In your test, take snapshots like this: + await percySnapshot('My Snapshot'); + +Example: +\`\`\`javascript +import percySnapshot from '@percy/ember'; +describe('My ppp', () => { + // ...app setup + it('about page should look good', async () => { + await visit('/about'); + await percySnapshot('My Snapshot'); + }); +}); +\`\`\` + + - The snapshot method arguments are: + percySnapshot(name[, options]) + name - The snapshot name; must be unique to each snapshot; defaults to the test title + options - See per-snapshot configuration options +`; + +export const nodejsCypressInstructionsSnapshot = ` + - Import the Percy snapshot helper in your cypress/support/e2e.js file: + import '@percy/cypress'; + - If you’re using TypeScript, include "types": ["cypress", "@percy/cypress"] in your tsconfig.json file. + - In your test, take snapshots like this: + cy.percySnapshot(); + +Example: +\`\`\`javascript +import '@percy/cypress'; + +describe('Integration test with visual testing', function() { + it('Loads the homepage', function() { + // Load the page or perform any other interactions with the app. + cy.visit(''); + // Take a snapshot for visual diffing + cy.percySnapshot(); + }); +}); +\`\`\` + + - The snapshot method arguments are: + cy.percySnapshot([name][, options]) + name - The snapshot name; must be unique to each snapshot; defaults to the test title + options - See per-snapshot configuration options + + - For example: + cy.percySnapshot(); + cy.percySnapshot('Homepage test'); + cy.percySnapshot('Homepage responsive test', { widths: [768, 992, 1200] }); +`; + +export const nodejsPuppeteerInstructionsSnapshot = ` + - Import the Percy snapshot helper: + const percySnapshot = require('@percy/puppeteer'); + - In your test, take snapshots like this: + await percySnapshot(page, 'Snapshot name'); + +Example: +\`\`\`javascript +const puppeteer = require('puppeteer'); +const percySnapshot = require('@percy/puppeteer'); + +describe('Integration test with visual testing', function() { + it('Loads the homepage', async function() { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto('https://example.com'); + await percySnapshot(page, this.test.fullTitle()); + await browser.close(); + }); +}); +\`\`\` + + - The snapshot method arguments are: + percySnapshot(page, name[, options]) + page (required) - A puppeteer page instance + name (required) - The snapshot name; must be unique to each snapshot + options - See per-snapshot configuration options + + - For example: + percySnapshot(page, 'Homepage test'); + percySnapshot(page, 'Homepage responsive test', { widths: [768, 992, 1200] }); +`; + +export const nodejsNightmareInstructionsSnapshot = ` + - Import the Percy snapshot helper: + const Nightmare = require('nightmare'); + const percySnapshot = require('@percy/nightmare'); + - In your test, take snapshots like this: + .use(percySnapshot('Snapshot name')) + +Example: +\`\`\`javascript +const Nightmare = require('nightmare'); +const percySnapshot = require('@percy/nightmare'); + +Nightmare() + .goto('http://example.com') + // ... other actions ... + .use(percySnapshot('Example Snapshot')) + // ... more actions ... + .end() + .then(() => { + // ... + }); +\`\`\` + + - The snapshot method arguments are: + percySnapshot(name[, options]) + name (required) - The snapshot name; must be unique to each snapshot + options - See per-snapshot configuration options +`; + +export const nodejsNightwatchInstructionsSnapshot = ` + - Import the Percy library and add the path exported by @percy/nightwatch to your Nightwatch configuration’s custom_commands_path property: + const percy = require('@percy/nightwatch'); + module.exports = { + // ... + custom_commands_path: [percy.path], + // ... + }; + - In your test, take snapshots like this: + browser.percySnapshot('Snapshot name'); + +Example: +\`\`\`javascript +const percy = require('@percy/nightwatch'); +module.exports = { + // ... + custom_commands_path: [percy.path], + // ... +}; + +// Example test +module.exports = { + 'Snapshots pages': function(browser) { + browser + .url('http://example.com') + .assert.containsText('h1', 'Example Domain') + .percySnapshot('Example snapshot'); + browser + .url('http://google.com') + .assert.elementPresent('img[alt="Google"]') + .percySnapshot('Google homepage'); + browser.end(); + } +}; +\`\`\` + + - The snapshot method arguments are: + percySnapshot([name][, options]) + name (required) - The snapshot name; must be unique to each snapshot + options - See per-snapshot configuration options +`; + +export const nodejsProtractorInstructionsSnapshot = ` + - Import the Percy snapshot helper: + import percySnapshot from '@percy/protractor'; + - In your test, take snapshots like this: + await percySnapshot('Snapshot name'); + // or + await percySnapshot(browser, 'Snapshot name'); + +Example: +\`\`\`javascript +import percySnapshot from '@percy/protractor'; +describe('angularjs homepage', function() { + it('should greet the named user', async function() { + await browser.get('https://www.angularjs.org'); + await percySnapshot('AngularJS homepage'); + await element(by.model('yourName')).sendKeys('Percy'); + var greeting = element(by.binding('yourName')); + expect(await greeting.getText()).toEqual('Hello Percy!'); + await percySnapshot('AngularJS homepage greeting'); + }); +}); +\`\`\` + + - The snapshot method arguments are: + percySnapshot(name[, options]) + Standalone mode: + percySnapshot(browser, name[, options]) + browser (required) - The Protractor browser object + name (required) - The snapshot name; must be unique to each snapshot + options - See per-snapshot configuration options +`; + +export const nodejsTestcafeInstructionsSnapshot = ` + - Import the Percy snapshot helper: + import percySnapshot from '@percy/testcafe'; + - In your test, take snapshots like this: + await percySnapshot(t, 'Snapshot name'); + +Example: +\`\`\`javascript +import percySnapshot from '@percy/testcafe'; +fixture('MyFixture') + .page('https://devexpress.github.io/testcafe/example'); +test('Test1', async t => { + await t.typeText('#developer-name', 'John Doe'); + await percySnapshot(t, 'TestCafe Example'); +}); +\`\`\` + + - The snapshot method arguments are: + percySnapshot(t, name[, options]) + t (required) - The test controller instance passed from test + name (required) - The snapshot name; must be unique to each snapshot + options - See per-snapshot configuration options +`; + +export const nodejsGatsbyInstructionsSnapshot = ` + - Add the Percy plugin to your gatsby-config.js file: + module.exports = { + plugins: [\`gatsby-plugin-percy\`] + } + + - The plugin will take snapshots of discovered pages during the build process. + + - Example gatsby-config.js with options: +\`\`\`javascript +module.exports = { + plugins: [{ + resolve: \`gatsby-plugin-percy\`, + options: { + // gatsby specific options + query: \`{ + allSitePage { nodes { path } } + allOtherPage { nodes { path } } + }\`, + resolvePages: ({ + allSitePage: { nodes: allPages }, + allOtherPage: { nodes: otherPages } + }) => { + return [...allPages, ...otherPages] + .map(({ path }) => path); + }, + // percy static snapshot options + exclude: [ + '/dev-404-page/', + '/offline-plugin-app-shell-fallback/' + ], + overrides: [{ + include: '/foobar/', + waitForSelector: '.done-loading', + additionalSnapshots: [{ + suffix: ' - after btn click', + execute: () => document.querySelector('.btn').click() + }] + }] + } + }] +} +\`\`\` +`; + +export const nodejsStorybookInstructionsSnapshot = ` + - Add Percy parameters to your stories to customize snapshots: +\`\`\`js +MyStory.parameters = { + percy: { + name: 'My snapshot', + additionalSnapshots: [ + { prefix: '[Dark mode] ', args: { colorScheme: 'dark' } }, + { suffix: ' with globals', globals: { textDirection: 'rtl' } }, + { name: 'Search snapshot', queryParams: { search: 'foobar' } } + ] + } +}; +\`\`\` + - Use argument names and values defined in your codebase. +`; + +export const pythonPlaywrightInstructionsSnapshot = ` + - Import the Percy snapshot helper and use the snapshot method: + percy_snapshot(page, name="Your snapshot name") + - You can also use: + percy_screenshot(page, name="Your snapshot name", options={}) + +Example: +\`\`\`python +from playwright.sync_api import sync_playwright +from percy import percy_snapshot + +with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto("http://localhost:8000") + percy_snapshot(page, name="Home page") + + # ... more test steps ... + percy_snapshot(page, name="After login") + + browser.close() +\`\`\` +`; + +export const csharpPlaywrightInstructionsSnapshot = ` + - Import the Percy snapshot helper and use the snapshot method: + Percy.Snapshot(page, "Your snapshot name"); + - You can also pass options: + Percy.Snapshot(page, "Your snapshot name", options); + +Example: +\`\`\`csharp +using Microsoft.Playwright; +using PercyIO.Playwright; + +class PercyPlaywrightExample +{ + public static async Task Main() + { + using var playwright = await Playwright.CreateAsync(); + var browser = await playwright.Chromium.LaunchAsync(); + var page = await browser.NewPageAsync(); + + await page.GotoAsync("http://localhost:8000"); + Percy.Snapshot(page, "Home page"); + + // ... more test steps ... + Percy.Snapshot(page, "After login"); + + await browser.CloseAsync(); + } +} +\`\`\` +`; + +export const pythonInstructions = ` +Install Percy dependencies + - Install Percy CLI: + npm install --save-dev @percy/cli + - Install Percy Selenium Python package: + pip install percy-selenium +If faced any issue create a virtual environment and proceed. +Update your Python Selenium script +${PERCY_SNAPSHOT_INSTRUCTION} +${pythonInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- python tests.py'). +${percyReviewSnapshotsStep} +`; + +export const nodejsInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI: + npm install --save-dev @percy/cli + - Install Percy SDK for Node.js: + npm install @percy/selenium-webdriver +---STEP--- +Update your Node.js Selenium script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- node script.js'). + +${percyReviewSnapshotsStep} +`; + +export const javaInstructions = ` +---STEP--- +Add Percy dependencies to your project + - For Maven, add to your pom.xml: + + io.percy + percy-java-selenium + 1.0.0 + + - For Gradle, add to your build.gradle: + implementation 'io.percy:percy-java-selenium:1.0.0' + - For CLI usage, install Percy CLI: + npm install --save-dev @percy/cli + +---STEP--- +Update your Java Selenium test +${PERCY_SNAPSHOT_INSTRUCTION} +${javaInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- mvn test'). + +${percyReviewSnapshotsStep} +`; + +export const rubyInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI: + npm install --save-dev @percy/cli + - Install Percy Ruby Selenium gem: + gem install percy-selenium + +---STEP--- +Update your Ruby Selenium test +${PERCY_SNAPSHOT_INSTRUCTION} +${rubyInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- bundle exec rspec'). + +${percyReviewSnapshotsStep} +`; + +// Percy Capybara instructions for Ruby +export const rubyCapybaraInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI: + npm install --save-dev @percy/cli + - Install Percy Capybara gem: + gem install percy-capybara + +---STEP--- +Update your Capybara or Rails test script +${PERCY_SNAPSHOT_INSTRUCTION} +${rubyCapybaraInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- bundle exec rspec'). + +${percyReviewSnapshotsStep} +`; + +export const csharpInstructions = ` +Install Percy CLI by running the following command: +npm install --save-dev @percy/cli + +---STEP--- +Add Percy dependencies to your project + - Add the Percy .NET Selenium NuGet package: + dotnet add package PercyIO.Selenium + +---STEP--- +Update your C# Selenium test +${PERCY_SNAPSHOT_INSTRUCTION} +${csharpInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- dotnet test'). + +${percyReviewSnapshotsStep} +`; + +export const javaPlaywrightInstructions = ` +Install Percy dependencies + - For Maven, add to your pom.xml: + + io.percy + percy-playwright-java + 1.0.0 + + +---STEP--- +Update your Java Playwright test +${PERCY_SNAPSHOT_INSTRUCTION} +${javaPlaywrightInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g. npx percy exec -- ). + +${percyReviewSnapshotsStep} +`; + +export const nodejsPlaywrightInstructions = ` +Install Percy dependencies + - Install Percy Playwright SDK: + npm install @percy/playwright + +---STEP--- +Update your Playwright JavaScript test +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsPlaywrightInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., npx percy exec -- ). +${percyReviewSnapshotsStep} +`; + +// Percy WebdriverIO instructions for JavaScript +export const nodejsWebdriverioInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI: + npm install --save-dev @percy/cli + - Install Percy Selenium Webdriver package: + npm install --save-dev @percy/selenium-webdriver + +---STEP--- +Update your WebdriverIO test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsWebdriverioInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- wdio run wdio.conf.js'). + +${percyReviewSnapshotsStep} +`; + +// Percy Ember instructions for JavaScript +export const nodejsEmberInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Ember SDK: + npm install --save-dev @percy/cli @percy/ember + +---STEP--- +Update your Ember test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsEmberInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- ember test'). + +${percyReviewSnapshotsStep} +`; + +// Percy Cypress instructions for JavaScript +export const nodejsCypressInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Cypress SDK: + npm install --save-dev @percy/cli @percy/cypress + +---STEP--- +Update your Cypress test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsCypressInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- cypress run'). + +${percyReviewSnapshotsStep} +`; + +// Percy Puppeteer instructions for JavaScript +export const nodejsPuppeteerInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Puppeteer SDK: + npm install --save-dev @percy/cli @percy/puppeteer + +---STEP--- +Update your Puppeteer test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsPuppeteerInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- '). + +${percyReviewSnapshotsStep} +`; + +// Percy Nightmare instructions for JavaScript +export const nodejsNightmareInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Nightmare SDK: + npm install --save-dev @percy/cli @percy/nightmare + +---STEP--- +Update your Nightmare test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsNightmareInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- node script.js'). + +${percyReviewSnapshotsStep} +`; + +// Percy Nightwatch instructions for JavaScript +export const nodejsNightwatchInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Nightwatch SDK: + npm install --save-dev @percy/cli @percy/nightwatch + +---STEP--- +Update your Nightwatch configuration and test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsNightwatchInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- nightwatch'). + +${percyReviewSnapshotsStep} +`; + +// Percy Protractor instructions for JavaScript +export const nodejsProtractorInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Protractor SDK: + npm install --save-dev @percy/cli @percy/protractor + +---STEP--- +Update your Protractor test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsProtractorInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- protractor conf.js'). + +${percyReviewSnapshotsStep} +`; + +// Percy TestCafe instructions for JavaScript +export const nodejsTestcafeInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and TestCafe SDK: + npm install --save-dev @percy/cli @percy/testcafe + +---STEP--- +Update your TestCafe test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsTestcafeInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- testcafe chrome:headless tests'). +${percyReviewSnapshotsStep} +`; + +// Percy Gatsby instructions for JavaScript +export const nodejsGatsbyInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Gatsby plugin: + npm install --save @percy/cli gatsby-plugin-percy + +---STEP--- +Update your Gatsby configuration +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsGatsbyInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- gatsby build'). +${percyReviewSnapshotsStep} +`; + +// Percy Storybook instructions for JavaScript +export const nodejsStorybookInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Storybook SDK: + npm install --save-dev @percy/cli @percy/storybook + +---STEP--- +Update your Storybook stories +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsStorybookInstructionsSnapshot} + +---STEP--- +Run Percy with your Storybook + - With a static Storybook build: + percy storybook ./storybook-build + - With a local or live Storybook URL: + percy storybook http://localhost:9009 + percy storybook https://storybook.foobar.com + - Automatically run start-storybook: + Run this scan using tool runPercyScan with 'npx percy exec -- percy storybook:start --port=9009'. + +${percyReviewSnapshotsStep} +`; + +export const pythonPlaywrightInstructions = ` +---STEP--- +Create a Percy project + - Sign in to Percy and create a project of type "Web". Name the project and note the generated token. + +---STEP--- +Set the project token as an environment variable + - On macOS/Linux: + export PERCY_TOKEN="" + - On Windows PowerShell: + $env:PERCY_TOKEN="" + - On Windows CMD: + set PERCY_TOKEN= + +---STEP--- +Install Percy dependencies + - Install Percy Playwright SDK: + pip install percy-playwright + +---STEP--- +Update your Playwright Python test +${PERCY_SNAPSHOT_INSTRUCTION} +${pythonPlaywrightInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g. npx percy exec -- ). +${percyReviewSnapshotsStep} +`; + +export const csharpPlaywrightInstructions = ` +Install Percy dependencies + - Add the Percy Playwright NuGet package: + + +---STEP--- +Update your Playwright .NET test +${PERCY_SNAPSHOT_INSTRUCTION} +${csharpPlaywrightInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g. npx percy exec -- ). +${percyReviewSnapshotsStep} +`; + +export function getFrameworkTestCommand( + language: string, + framework: string, +): string { + const percyPrefix = "npx percy exec --labels=mcp --"; + + const nodeCommands: Record = { + cypress: "cypress run", + playwright: "playwright test", + webdriverio: "wdio", + puppeteer: "node", + testcafe: "testcafe", + nightwatch: "nightwatch", + protractor: "protractor", + gatsby: "gatsby build", + storybook: "storybook build", + ember: "ember test", + default: "npm test", + }; + + const languageMap: Record = { + python: "python -m pytest", + java: "mvn test", + ruby: "bundle exec rspec", + csharp: "dotnet test", + }; + + if (language === "nodejs") { + const cmd = nodeCommands[framework] ?? nodeCommands.default; + return `${percyPrefix} ${cmd}`; + } + + const cmd = languageMap[language]; + return cmd ? `${percyPrefix} ${cmd}` : `${percyPrefix} `; +} + +export const PERCY_FALLBACK_STEPS = [ + `Attempt to infer the project's test command from context (high confidence commands first): +- Node.js: npm test, cypress run, npx playwright test, npx wdio, npx testcafe, npx nightwatch, npx protractor, ember test, npx gatsby build, npx storybook build +- Python: python -m pytest +- Java: mvn test +- Ruby: bundle exec rspec +- C#: dotnet test +or from package.json scripts`, + `Wrap the inferred command with Percy along with label: \nnpx percy exec --labels=mcp -- `, + `If the test command cannot be inferred confidently, ask the user directly for the correct test command.`, +]; diff --git a/src/tools/sdk-utils/percy-web/fetchPercyToken.ts b/src/tools/sdk-utils/percy-web/fetchPercyToken.ts new file mode 100644 index 00000000..b6ed135f --- /dev/null +++ b/src/tools/sdk-utils/percy-web/fetchPercyToken.ts @@ -0,0 +1,55 @@ +import { PercyIntegrationTypeEnum } from "../common/types.js"; + +let globalPercyToken: string | null = null; +let globalProjectName: string | null = null; + +async function fetchTokenFromAPI( + projectName: string, + authorization: string, + options: { type?: PercyIntegrationTypeEnum } = {}, +): Promise { + const authHeader = `Basic ${Buffer.from(authorization).toString("base64")}`; + const baseUrl = + "https://api.browserstack.com/api/app_percy/get_project_token"; + const params = new URLSearchParams({ name: projectName }); + + if (options.type) { + params.append("type", options.type); + } + + const url = `${baseUrl}?${params.toString()}`; + const response = await fetch(url, { headers: { Authorization: authHeader } }); + + if (!response.ok) { + throw new Error(`Failed to fetch Percy token (status: ${response.status})`); + } + + const data = await response.json(); + + if (!data?.token || !data?.success) { + throw new Error( + "Project exists but is likely set up for Automate. Please use a different project name.", + ); + } + + return data.token; +} + +export async function fetchPercyToken( + projectName: string, + authorization: string, + options: { type?: PercyIntegrationTypeEnum } = {}, +): Promise { + if (globalProjectName !== projectName) { + globalProjectName = projectName; + globalPercyToken = null; + } + + if (globalPercyToken) { + return globalPercyToken; + } + + const token = await fetchTokenFromAPI(projectName, authorization, options); + globalPercyToken = token; + return token; +} diff --git a/src/tools/sdk-utils/percy-web/frameworks.ts b/src/tools/sdk-utils/percy-web/frameworks.ts new file mode 100644 index 00000000..4484eed9 --- /dev/null +++ b/src/tools/sdk-utils/percy-web/frameworks.ts @@ -0,0 +1,109 @@ +import { ConfigMapping } from "./types.js"; +import * as constants from "./constants.js"; + +export const SUPPORTED_CONFIGURATIONS: ConfigMapping = { + python: { + selenium: { + instructions: constants.pythonInstructions, + snapshotInstruction: constants.pythonInstructionsSnapshot, + }, + playwright: { + instructions: constants.pythonPlaywrightInstructions, + snapshotInstruction: constants.pythonPlaywrightInstructionsSnapshot, + }, + }, + nodejs: { + selenium: { + instructions: constants.nodejsInstructions, + snapshotInstruction: constants.nodejsInstructionsSnapshot, + }, + playwright: { + instructions: constants.nodejsPlaywrightInstructions, + snapshotInstruction: constants.nodejsPlaywrightInstructionsSnapshot, + }, + webdriverio: { + instructions: constants.nodejsWebdriverioInstructions, + snapshotInstruction: constants.nodejsWebdriverioInstructionsSnapshot, + }, + ember: { + instructions: constants.nodejsEmberInstructions, + snapshotInstruction: constants.nodejsEmberInstructionsSnapshot, + }, + cypress: { + instructions: constants.nodejsCypressInstructions, + snapshotInstruction: constants.nodejsCypressInstructionsSnapshot, + }, + puppeteer: { + instructions: constants.nodejsPuppeteerInstructions, + snapshotInstruction: constants.nodejsPuppeteerInstructionsSnapshot, + }, + nightmare: { + instructions: constants.nodejsNightmareInstructions, + snapshotInstruction: constants.nodejsNightmareInstructionsSnapshot, + }, + nightwatch: { + instructions: constants.nodejsNightwatchInstructions, + snapshotInstruction: constants.nodejsNightwatchInstructionsSnapshot, + }, + protractor: { + instructions: constants.nodejsProtractorInstructions, + snapshotInstruction: constants.nodejsProtractorInstructionsSnapshot, + }, + testcafe: { + instructions: constants.nodejsTestcafeInstructions, + snapshotInstruction: constants.nodejsTestcafeInstructionsSnapshot, + }, + gatsby: { + instructions: constants.nodejsGatsbyInstructions, + snapshotInstruction: constants.nodejsGatsbyInstructionsSnapshot, + }, + storybook: { + instructions: constants.nodejsStorybookInstructions, + snapshotInstruction: constants.nodejsStorybookInstructionsSnapshot, + }, + }, + java: { + selenium: { + instructions: constants.javaInstructions, + snapshotInstruction: constants.javaInstructionsSnapshot, + }, + playwright: { + instructions: constants.javaPlaywrightInstructions, + snapshotInstruction: constants.javaPlaywrightInstructionsSnapshot, + }, + }, + ruby: { + selenium: { + instructions: constants.rubyInstructions, + snapshotInstruction: constants.rubyInstructionsSnapshot, + }, + capybara: { + instructions: constants.rubyCapybaraInstructions, + snapshotInstruction: constants.rubyCapybaraInstructionsSnapshot, + }, + }, + csharp: { + selenium: { + instructions: constants.csharpInstructions, + snapshotInstruction: constants.csharpInstructionsSnapshot, + }, + playwright: { + instructions: constants.csharpPlaywrightInstructions, + snapshotInstruction: constants.csharpPlaywrightInstructionsSnapshot, + }, + }, +}; + +/** + * Utility function to check if a given language and testing framework + * are supported by Percy Web. + */ +export function isPercyWebFrameworkSupported( + language: string, + framework: string, +): boolean { + const languageConfig = + SUPPORTED_CONFIGURATIONS[language as keyof typeof SUPPORTED_CONFIGURATIONS]; + if (!languageConfig) return false; + return !!languageConfig[framework as keyof typeof languageConfig]; +} diff --git a/src/tools/sdk-utils/percy-web/handler.ts b/src/tools/sdk-utils/percy-web/handler.ts new file mode 100644 index 00000000..681fa614 --- /dev/null +++ b/src/tools/sdk-utils/percy-web/handler.ts @@ -0,0 +1,51 @@ +// Handler for Percy Web only mode - Visual testing without BrowserStack infrastructure +import { RunTestsInstructionResult, RunTestsStep } from "../common/types.js"; +import { SetUpPercyInput } from "../common/schema.js"; +import { SUPPORTED_CONFIGURATIONS } from "./frameworks.js"; + +import { + SDKSupportedBrowserAutomationFramework, + SDKSupportedLanguage, +} from "../common/types.js"; + +export let percyWebSetupInstructions = ""; + +export function runPercyWeb( + input: SetUpPercyInput, + percyToken: string, +): RunTestsInstructionResult { + const steps: RunTestsStep[] = []; + + // Assume configuration is supported due to guardrails at orchestration layer + const languageConfig = + SUPPORTED_CONFIGURATIONS[input.detectedLanguage as SDKSupportedLanguage]; + const frameworkConfig = + languageConfig[ + input.detectedBrowserAutomationFramework as SDKSupportedBrowserAutomationFramework + ]; + + // Generate instructions for the supported configuration + const instructions = frameworkConfig.instructions; + percyWebSetupInstructions = frameworkConfig.snapshotInstruction; + + // Prepend a step to set the Percy token in the environment + steps.push({ + type: "instruction", + title: "Set Percy Token in Environment", + content: `Set the environment variable for your project: + export PERCY_TOKEN="${percyToken}" + (For Windows: use 'setx PERCY_TOKEN "${percyToken}"' or 'set PERCY_TOKEN=${percyToken}' as appropriate.)`, + }); + + steps.push({ + type: "instruction", + title: `Percy Web Setup Instructions`, + content: instructions, + }); + + return { + steps, + requiresPercy: true, + missingDependencies: [], + }; +} diff --git a/src/tools/sdk-utils/percy-web/index.ts b/src/tools/sdk-utils/percy-web/index.ts new file mode 100644 index 00000000..6dd10c6b --- /dev/null +++ b/src/tools/sdk-utils/percy-web/index.ts @@ -0,0 +1,5 @@ +// Percy Web utilities +export { runPercyWeb } from "./handler.js"; +export { SUPPORTED_CONFIGURATIONS } from "./frameworks.js"; +export * as constants from "./constants.js"; +export type { ConfigMapping } from "./types.js"; diff --git a/src/tools/sdk-utils/percy-web/types.ts b/src/tools/sdk-utils/percy-web/types.ts new file mode 100644 index 00000000..0445800a --- /dev/null +++ b/src/tools/sdk-utils/percy-web/types.ts @@ -0,0 +1,12 @@ +/** + * Type for Percy Web configuration mapping. + * Structure: language -> automationFramework -> { instructions: string } + */ +export type ConfigMapping = { + [language: string]: { + [automationFramework: string]: { + instructions: string; + snapshotInstruction: string; + }; + }; +}; diff --git a/src/tools/sdk-utils/percy/types.ts b/src/tools/sdk-utils/percy/types.ts deleted file mode 100644 index 1ddd464f..00000000 --- a/src/tools/sdk-utils/percy/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - SDKSupportedBrowserAutomationFramework, - SDKSupportedLanguage, - SDKSupportedTestingFramework, -} from "../types.js"; - -export interface PercyInstructions { - script_updates: string; -} - -export type PercyConfigMapping = Partial< - Record< - SDKSupportedLanguage, - Partial< - Record< - SDKSupportedBrowserAutomationFramework, - Partial> - > - > - > ->; diff --git a/src/tools/sdk-utils/types.ts b/src/tools/sdk-utils/types.ts deleted file mode 100644 index caba6eaa..00000000 --- a/src/tools/sdk-utils/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -export enum SDKSupportedLanguageEnum { - nodejs = "nodejs", - python = "python", - java = "java", - csharp = "csharp", -} -export type SDKSupportedLanguage = keyof typeof SDKSupportedLanguageEnum; - -export enum SDKSupportedBrowserAutomationFrameworkEnum { - playwright = "playwright", - selenium = "selenium", - cypress = "cypress", - webdriverio = "webdriverio", -} -export type SDKSupportedBrowserAutomationFramework = - keyof typeof SDKSupportedBrowserAutomationFrameworkEnum; - -export enum SDKSupportedTestingFrameworkEnum { - jest = "jest", - codeceptjs = "codeceptjs", - playwright = "playwright", - pytest = "pytest", - robot = "robot", - behave = "behave", - cucumber = "cucumber", - nightwatch = "nightwatch", - webdriverio = "webdriverio", - mocha = "mocha", - junit4 = "junit4", - junit5 = "junit5", - testng = "testng", - serenity = "serenity", - cypress = "cypress", - nunit = "nunit", - mstest = "mstest", - xunit = "xunit", - specflow = "specflow", - reqnroll = "reqnroll", -} -export type SDKSupportedTestingFramework = - keyof typeof SDKSupportedTestingFrameworkEnum; - -export type ConfigMapping = Record< - SDKSupportedLanguageEnum, - Partial< - Record< - SDKSupportedBrowserAutomationFrameworkEnum, - Partial< - Record< - SDKSupportedTestingFrameworkEnum, - { instructions: (username: string, accessKey: string) => string } - > - > - > - > ->; diff --git a/tests/tools/appautomate.test.ts b/tests/tools/appautomate.test.ts index 8598ecfa..cd7de09e 100644 --- a/tests/tools/appautomate.test.ts +++ b/tests/tools/appautomate.test.ts @@ -3,7 +3,7 @@ import { getDeviceVersions, resolveVersion, validateArgs, -} from '../../src/tools/appautomate-utils/appautomate'; +} from '../../src/tools/appautomate-utils/native-execution/appautomate'; import { beforeEach, it, expect, describe, vi } from 'vitest'