diff --git a/.gitignore b/.gitignore index 5e85fb3..a2e98e8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ tests.md *.apk *.ipa .npmrc -.vscode/ \ No newline at end of file +.vscode/ diff --git a/docs/percy-tools.md b/docs/percy-tools.md new file mode 100644 index 0000000..7a865ec --- /dev/null +++ b/docs/percy-tools.md @@ -0,0 +1,581 @@ +# Percy MCP Tools — Quick Reference + +> 22 tools | BrowserStack Basic Auth | All commands use natural language + +--- + +## Setup + +### Published package (recommended) +```json +{ + "mcpServers": { + "browserstack": { + "command": "npx", + "args": ["-y", "@browserstack/mcp-server@latest"], + "env": { + "BROWSERSTACK_USERNAME": "", + "BROWSERSTACK_ACCESS_KEY": "" + } + } + } +} +``` + +### Local development (testing your changes) +```bash +cd mcp-server +npm run build +``` + +Then in Claude Code: +``` +/mcp add-server +``` +Select "command" transport, enter: +- Command: `node` +- Args: `/path/to/mcp-server/dist/index.js` +- Env: `BROWSERSTACK_USERNAME=xxx`, `BROWSERSTACK_ACCESS_KEY=xxx` + +Or use MCP Inspector for testing: +```bash +npx @modelcontextprotocol/inspector node dist/index.js +``` +Set env vars in the Inspector UI. + +No Percy token needed — BrowserStack credentials handle everything. + +--- + +## All Commands + +### percy_auth_status + +Check if your credentials are working. + +``` +Use percy_auth_status +``` + +No parameters needed. Shows: credential status, API connectivity, what you can do. + +--- + +### percy_create_project + +Create a new Percy project or get token for existing one. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `name` | Yes | Project name | `"my-web-app"` | +| `type` | No | `web` or `automate` | `"web"` | + +**Examples:** + +``` +Use percy_create_project with name "my-app" +``` + +``` +Use percy_create_project with name "my-app" and type "web" +``` + +``` +Use percy_create_project with name "mobile-tests" and type "automate" +``` + +Returns: project token (save it for Percy CLI use). + +--- + +### percy_create_build + +Create a Percy build with snapshots. ONE tool handles everything. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `project_name` | Yes | Project name (auto-creates if new) | `"my-app"` | +| `urls` | No* | URLs to snapshot (launches real browser) | `"http://localhost:3000"` | +| `screenshots_dir` | No* | Folder with PNG/JPG files | `"./screenshots"` | +| `screenshot_files` | No* | Comma-separated file paths | `"./home.png,./login.png"` | +| `test_command` | No* | Test command to wrap with Percy | `"npx cypress run"` | +| `branch` | No | Git branch (auto-detected) | `"feature-x"` | +| `widths` | No | Viewport widths (default: 375,1280) | `"375,768,1280"` | +| `snapshot_names` | No | Custom names for snapshots (comma-separated, maps 1:1 with urls/files) | `"Homepage,Login,Dashboard"` | +| `test_case` | No | Test case name(s). Single = applies to all. Comma-separated = maps 1:1 with urls/files. Works with both URLs and screenshots. | `"smoke-test"` or `"test-1,test-2"` | +| `type` | No | Project type | `"web"` | + +*Provide ONE of: `urls`, `screenshots_dir`, `screenshot_files`, or `test_command` + +**When Percy CLI is installed:** tool executes automatically and returns build URL. +**When Percy CLI is NOT installed:** tool returns install instructions. + +**Snapshot URLs (auto-executes, returns build URL):** + +``` +Use percy_create_build with project_name "my-app" and urls "http://localhost:3000" +``` + +``` +Use percy_create_build with project_name "my-app" and urls "http://localhost:3000,http://localhost:3000/login,http://localhost:3000/dashboard" +``` + +**With custom widths:** + +``` +Use percy_create_build with project_name "my-app" and urls "http://localhost:3000" and widths "375,768,1280" +``` + +**With custom snapshot names:** + +``` +Use percy_create_build with project_name "my-app" and urls "http://localhost:3000,http://localhost:3000/login" and snapshot_names "Home Page,Login Page" +``` + +**With test case:** + +``` +Use percy_create_build with project_name "my-app" and urls "http://localhost:3000" and snapshot_names "Homepage" and test_case "smoke-test" +``` + +**Upload screenshots from folder:** + +``` +Use percy_create_build with project_name "my-app" and screenshots_dir "./screenshots" +``` + +**Upload with custom names:** + +``` +Use percy_create_build with project_name "my-app" and screenshot_files "./home.png,./login.png" and snapshot_names "Homepage,Login Page" +``` + +**Run tests with Percy (auto-executes):** + +``` +Use percy_create_build with project_name "my-app" and test_command "npx cypress run" +``` + +``` +Use percy_create_build with project_name "my-app" and test_command "npx playwright test" +``` + +**Just get setup (no snapshots yet):** + +``` +Use percy_create_build with project_name "my-app" +``` + +--- + +### percy_get_projects + +List all Percy projects in your organization. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `search` | No | Filter by name | `"my-app"` | +| `limit` | No | Max results (default: 20) | `10` | + +**Examples:** + +``` +Use percy_get_projects +``` + +``` +Use percy_get_projects with search "dashboard" +``` + +``` +Use percy_get_projects with limit 5 +``` + +Returns: table with project name, type, and slug. Use the slug in `percy_get_builds`. + +--- + +### percy_get_build + +**THE unified build details tool.** One command for everything about a build. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `build_id` | Yes | Build ID | `"48436286"` | +| `detail` | No | What to show (default: overview) | `"ai_summary"` | +| `comparison_id` | No | Needed for rca and network | `"99999"` | + +**Detail options:** `overview`, `ai_summary`, `changes`, `rca`, `logs`, `network`, `snapshots` + +**Examples:** + +``` +Use percy_get_build with build_id "48436286" +``` + +``` +Use percy_get_build with build_id "48436286" and detail "ai_summary" +``` + +``` +Use percy_get_build with build_id "48436286" and detail "changes" +``` + +``` +Use percy_get_build with build_id "48436286" and detail "logs" +``` + +``` +Use percy_get_build with build_id "48436286" and detail "rca" and comparison_id "99999" +``` + +``` +Use percy_get_build with build_id "48436286" and detail "network" and comparison_id "99999" +``` + +``` +Use percy_get_build with build_id "48436286" and detail "snapshots" +``` + +--- + +### percy_get_builds + +List builds for a project. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `project_slug` | No* | From percy_get_projects output | `"9560f98d/my-app-abc123"` | +| `branch` | No | Filter by branch | `"main"` | +| `state` | No | Filter: pending/processing/finished/failed | `"finished"` | +| `limit` | No | Max results (default: 10) | `5` | + +*Get project_slug from `percy_get_projects` output. + +**Examples:** + +``` +Use percy_get_builds with project_slug "9560f98d/my-app-abc123" +``` + +``` +Use percy_get_builds with project_slug "9560f98d/my-app-abc123" and branch "main" +``` + +``` +Use percy_get_builds with project_slug "9560f98d/my-app-abc123" and state "failed" +``` + +``` +Use percy_get_builds with project_slug "9560f98d/my-app-abc123" and limit 5 +``` + +Returns: table with build number, ID, branch, state, review status, snapshot count, diff count. + +--- + +### percy_figma_build + +Create a Percy build from Figma design files. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `project_slug` | Yes | Project slug | `"org-id/my-project"` | +| `branch` | Yes | Branch name | `"main"` | +| `figma_url` | Yes | Figma file URL | `"https://www.figma.com/file/..."` | + +``` +Use percy_figma_build with project_slug "org-id/my-project" and branch "main" and figma_url "https://www.figma.com/file/abc123" +``` + +--- + +### percy_figma_baseline + +Update the Figma design baseline. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `project_slug` | Yes | Project slug | `"org-id/my-project"` | +| `branch` | Yes | Branch | `"main"` | +| `build_id` | Yes | Build ID for new baseline | `"12345"` | + +``` +Use percy_figma_baseline with project_slug "org-id/my-project" and branch "main" and build_id "12345" +``` + +--- + +### percy_figma_link + +Get Figma design link for a snapshot or comparison. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `snapshot_id` | No* | Snapshot ID | `"67890"` | +| `comparison_id` | No* | Comparison ID | `"99999"` | + +``` +Use percy_figma_link with snapshot_id "67890" +``` + +--- + +### percy_get_insights + +Get testing health metrics for an organization. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `org_slug` | Yes | Organization slug | `"my-org"` | +| `period` | No | last_7_days / last_30_days / last_90_days | `"last_30_days"` | +| `product` | No | web / app | `"web"` | + +``` +Use percy_get_insights with org_slug "my-org" +``` + +``` +Use percy_get_insights with org_slug "my-org" and period "last_90_days" and product "app" +``` + +--- + +### percy_manage_insights_email + +Configure weekly insights email recipients. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `org_id` | Yes | Organization ID | `"12345"` | +| `action` | No | get / create / update | `"create"` | +| `emails` | No | Comma-separated emails | `"a@b.com,c@d.com"` | +| `enabled` | No | Enable/disable | `true` | + +``` +Use percy_manage_insights_email with org_id "12345" +``` + +``` +Use percy_manage_insights_email with org_id "12345" and action "create" and emails "team@company.com" +``` + +--- + +### percy_get_test_cases + +List test cases for a project. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `project_id` | Yes | Project ID | `"12345"` | +| `build_id` | No | Build ID for execution details | `"67890"` | + +``` +Use percy_get_test_cases with project_id "12345" +``` + +``` +Use percy_get_test_cases with project_id "12345" and build_id "67890" +``` + +--- + +### percy_get_test_case_history + +Get execution history of a test case across builds. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `test_case_id` | Yes | Test case ID | `"99999"` | + +``` +Use percy_get_test_case_history with test_case_id "99999" +``` + +--- + +### percy_discover_urls + +Discover URLs from a sitemap for visual testing. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `project_id` | Yes | Project ID | `"12345"` | +| `sitemap_url` | No | Sitemap XML URL to crawl | `"https://example.com/sitemap.xml"` | +| `action` | No | create / list | `"create"` | + +``` +Use percy_discover_urls with project_id "12345" and sitemap_url "https://example.com/sitemap.xml" +``` + +--- + +### percy_get_devices + +List available browsers and devices. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `build_id` | No | Build ID for device details | `"12345"` | + +``` +Use percy_get_devices +``` + +--- + +### percy_manage_domains + +Get or update allowed/error domains for a project. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `project_id` | Yes | Project ID | `"12345"` | +| `action` | No | get / update | `"get"` | +| `allowed_domains` | No | Comma-separated domains | `"cdn.example.com,api.example.com"` | + +``` +Use percy_manage_domains with project_id "12345" +``` + +--- + +### percy_manage_usage_alerts + +Configure usage alert thresholds. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `org_id` | Yes | Organization ID | `"12345"` | +| `action` | No | get / create / update | `"create"` | +| `threshold` | No | Screenshot count threshold | `5000` | +| `emails` | No | Comma-separated emails | `"team@co.com"` | + +``` +Use percy_manage_usage_alerts with org_id "12345" and action "create" and threshold 5000 and emails "team@co.com" +``` + +--- + +### percy_preview_comparison + +Trigger on-demand diff recomputation. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `comparison_id` | Yes | Comparison ID | `"99999"` | + +``` +Use percy_preview_comparison with comparison_id "99999" +``` + +--- + +### percy_search_builds + +Advanced build item search with filters. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `build_id` | Yes | Build ID | `"12345"` | +| `category` | No | changed / new / removed / unchanged / failed | `"changed"` | +| `browser_ids` | No | Comma-separated browser IDs | `"63,73"` | +| `widths` | No | Comma-separated widths | `"375,1280"` | +| `os` | No | OS filter | `"iOS"` | +| `device_name` | No | Device filter | `"iPhone 13"` | +| `sort_by` | No | diff_ratio / bug_count | `"diff_ratio"` | + +``` +Use percy_search_builds with build_id "12345" and category "changed" and sort_by "diff_ratio" +``` + +--- + +### percy_list_integrations + +List all integrations for an organization. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `org_id` | Yes | Organization ID | `"12345"` | + +``` +Use percy_list_integrations with org_id "12345" +``` + +--- + +### percy_get_ai_summary + +Get AI-generated build summary with potential bugs, visual diffs, and change descriptions. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `build_id` | Yes | Percy build ID | `"48436286"` | + +``` +Use percy_get_ai_summary with build_id "48436286" +``` + +Returns: potential bugs count, AI visual diffs count, change descriptions with occurrences, affected snapshots. + +--- + +### percy_migrate_integrations + +Migrate integrations between organizations. + +| Parameter | Required | Description | Example | +|-----------|----------|-------------|---------| +| `source_org_id` | Yes | Source organization ID | `"12345"` | +| `target_org_id` | Yes | Target organization ID | `"67890"` | + +``` +Use percy_migrate_integrations with source_org_id "12345" and target_org_id "67890" +``` + +--- + +## Common Workflows + +### First time setup +``` +Use percy_auth_status +Use percy_create_project with name "my-app" +``` + +### Snapshot my local app +``` +Use percy_create_build with project_name "my-app" and urls "http://localhost:3000" +``` + +### Upload existing screenshots +``` +Use percy_create_build with project_name "my-app" and screenshots_dir "./screenshots" +``` + +### Run tests with visual testing +``` +Use percy_create_build with project_name "my-app" and test_command "npx cypress run" +``` + +### Check my builds +``` +Use percy_get_projects +Use percy_get_builds with project_slug "org-id/project-slug" +``` + +--- + +## Prerequisites + +| Requirement | Needed For | How to Get | +|---|---|---| +| BrowserStack credentials | All tools | Set `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY` in `.mcp.json` env | +| @percy/cli installed | URL snapshots, test commands | `npm install -g @percy/cli` | +| Local dev server running | URL snapshots | Start your app first | + +## Switching Orgs + +Update `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY` in your MCP config and restart the client. diff --git a/package.json b/package.json index 015d2f5..fdd0f17 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "dev": "tsx watch --clear-screen=false src/index.ts", "test": "vitest run", "lint": "eslint . --ext .ts", - "format": "prettier --write \"src/**/*.ts\"" + "format": "prettier --write \"src/**/*.ts\"", + "generate-app-samples": "node scripts/generate-app-samples.mjs" }, "bin": { "browserstack-mcp-server": "dist/index.js" diff --git a/resources/app-percy-samples/.gitignore b/resources/app-percy-samples/.gitignore new file mode 100644 index 0000000..835ceb0 --- /dev/null +++ b/resources/app-percy-samples/.gitignore @@ -0,0 +1,2 @@ +# Generated sample screenshots — run `npm run generate-app-samples` to create +*.png diff --git a/resources/app-percy-samples/Pixel_7/device.json b/resources/app-percy-samples/Pixel_7/device.json new file mode 100644 index 0000000..680a506 --- /dev/null +++ b/resources/app-percy-samples/Pixel_7/device.json @@ -0,0 +1,9 @@ +{ + "deviceName": "Pixel 7", + "osName": "Android", + "osVersion": "13", + "orientation": "portrait", + "deviceScreenSize": "1080x2400", + "statusBarHeight": 118, + "navBarHeight": 63 +} diff --git a/resources/app-percy-samples/Samsung_Galaxy_S23/device.json b/resources/app-percy-samples/Samsung_Galaxy_S23/device.json new file mode 100644 index 0000000..7a5d10d --- /dev/null +++ b/resources/app-percy-samples/Samsung_Galaxy_S23/device.json @@ -0,0 +1,9 @@ +{ + "deviceName": "Samsung Galaxy S23", + "osName": "Android", + "osVersion": "13", + "orientation": "portrait", + "deviceScreenSize": "1080x2340", + "statusBarHeight": 110, + "navBarHeight": 63 +} diff --git a/resources/app-percy-samples/iPhone_14_Pro/device.json b/resources/app-percy-samples/iPhone_14_Pro/device.json new file mode 100644 index 0000000..d0357c6 --- /dev/null +++ b/resources/app-percy-samples/iPhone_14_Pro/device.json @@ -0,0 +1,9 @@ +{ + "deviceName": "iPhone 14 Pro", + "osName": "iOS", + "osVersion": "16", + "orientation": "portrait", + "deviceScreenSize": "1179x2556", + "statusBarHeight": 132, + "navBarHeight": 0 +} diff --git a/scripts/generate-app-samples.mjs b/scripts/generate-app-samples.mjs new file mode 100644 index 0000000..b248df1 --- /dev/null +++ b/scripts/generate-app-samples.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * Generate sample PNG screenshots for App Percy BYOS testing. + * + * Reads device.json from each folder under resources/app-percy-samples/ + * and generates matching-dimension PNGs using sharp. + * + * Usage: node scripts/generate-app-samples.mjs + */ + +import { readdir, readFile, stat } from "fs/promises"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import sharp from "sharp"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SAMPLES_DIR = join(__dirname, "..", "resources", "app-percy-samples"); + +const SCREENSHOT_NAMES = ["Home Screen", "Login Screen"]; +const COLORS = [ + { r: 230, g: 230, b: 250 }, // lavender + { r: 230, g: 250, b: 230 }, // green + { r: 250, g: 240, b: 230 }, // peach +]; + +async function main() { + const entries = await readdir(SAMPLES_DIR); + let colorIdx = 0; + + for (const entry of entries) { + const folderPath = join(SAMPLES_DIR, entry); + const folderStat = await stat(folderPath); + if (!folderStat.isDirectory()) continue; + + const configPath = join(folderPath, "device.json"); + try { + await stat(configPath); + } catch { + continue; + } + + const config = JSON.parse(await readFile(configPath, "utf-8")); + const [width, height] = config.deviceScreenSize.split("x").map(Number); + const bg = COLORS[colorIdx % COLORS.length]; + colorIdx++; + + for (const name of SCREENSHOT_NAMES) { + const filePath = join(folderPath, `${name}.png`); + await sharp({ + create: { width, height, channels: 3, background: bg }, + }) + .png({ compressionLevel: 9 }) + .toFile(filePath); + + console.log(` ✓ ${entry}/${name}.png (${width}×${height})`); + } + } + + console.log("\nDone. Sample PNGs generated in resources/app-percy-samples/"); +} + +main().catch((err) => { + console.error("Failed:", err.message); + process.exit(1); +}); diff --git a/src/lib/percy-api/auth.ts b/src/lib/percy-api/auth.ts new file mode 100644 index 0000000..510ba72 --- /dev/null +++ b/src/lib/percy-api/auth.ts @@ -0,0 +1,132 @@ +/** + * Percy API authentication module. + * Resolves Percy tokens via environment variables or BrowserStack credential fallback. + * + * SECURITY: Token values are NEVER logged or included in error messages. + * Masked format (****) is used when referencing tokens in diagnostics. + */ + +import { BrowserStackConfig } from "../types.js"; +import { fetchPercyToken } from "../../tools/sdk-utils/percy-web/fetchPercyToken.js"; + +type TokenScope = "project" | "org" | "auto"; + +interface ResolveTokenOptions { + projectName?: string; + scope?: TokenScope; +} + +/** + * Masks a token for safe display in error messages. + * Shows only the last 4 characters. + */ +export function maskToken(token: string): string { + if (token.length <= 4) { + return "****"; + } + return `****${token.slice(-4)}`; +} + +/** + * Resolves a Percy token using the following priority: + * + * 1. `process.env.PERCY_TOKEN` (for project or auto scope) + * 2. `process.env.PERCY_ORG_TOKEN` (for org scope) + * 3. Fallback: fetch via BrowserStack API using `fetchPercyToken()` + * 4. If nothing works, throws an enriched error with guidance + */ +export async function resolvePercyToken( + config: BrowserStackConfig, + options: ResolveTokenOptions = {}, +): Promise { + const { projectName, scope = "auto" } = options; + + // For project or auto scope, check PERCY_TOKEN first + if (scope === "project" || scope === "auto") { + const envToken = process.env.PERCY_TOKEN; + if (envToken) { + return envToken; + } + } + + // For org scope, check PERCY_ORG_TOKEN + if (scope === "org") { + const orgToken = process.env.PERCY_ORG_TOKEN; + if (orgToken) { + return orgToken; + } + } + + // For auto scope, also check PERCY_ORG_TOKEN as secondary + if (scope === "auto") { + const orgToken = process.env.PERCY_ORG_TOKEN; + if (orgToken) { + return orgToken; + } + } + + // Fallback: fetch via BrowserStack credentials + const username = config["browserstack-username"]; + const accessKey = config["browserstack-access-key"]; + + if (username && accessKey) { + const auth = `${username}:${accessKey}`; + const resolvedProjectName = projectName || "default"; + + try { + const token = await fetchPercyToken(resolvedProjectName, auth, {}); + return token; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to fetch Percy token via BrowserStack API: ${message}. ` + + `Set PERCY_TOKEN or PERCY_ORG_TOKEN environment variable as an alternative.`, + ); + } + } + + // Nothing worked — provide actionable guidance + if (scope === "project") { + throw new Error( + "Percy project token not available. Set PERCY_TOKEN environment variable, " + + "or provide BrowserStack credentials to fetch a token automatically.", + ); + } + + if (scope === "org") { + throw new Error( + "Percy org token not available. Set PERCY_ORG_TOKEN environment variable.", + ); + } + + throw new Error( + "Percy token not available. Set PERCY_TOKEN (project) or PERCY_ORG_TOKEN (org) " + + "environment variable, or provide BrowserStack credentials (browserstack-username " + + "and browserstack-access-key) to fetch a token automatically.", + ); +} + +/** + * Returns headers for Percy API requests. + * Includes Authorization, Content-Type, and User-Agent. + */ +export async function getPercyHeaders( + config: BrowserStackConfig, + options: { scope?: TokenScope; projectName?: string } = {}, +): Promise> { + const token = await resolvePercyToken(config, options); + + return { + Authorization: `Token token=${token}`, + "Content-Type": "application/json", + "User-Agent": "browserstack-mcp-server", + }; +} + +/** + * Returns the Percy API base URL. + * Defaults to `https://percy.io/api/v1`, overridable via `PERCY_API_URL` env var. + */ +export function getPercyApiBaseUrl(): string { + return process.env.PERCY_API_URL || "https://percy.io/api/v1"; +} diff --git a/src/lib/percy-api/cache.ts b/src/lib/percy-api/cache.ts new file mode 100644 index 0000000..3a75a7e --- /dev/null +++ b/src/lib/percy-api/cache.ts @@ -0,0 +1,60 @@ +/** + * Simple in-memory cache with per-entry TTL. + * + * Used to cache Percy API responses and avoid redundant network calls + * within short time windows (e.g., multiple tools querying the same build). + */ + +interface CacheEntry { + value: T; + expiresAt: number; +} + +const DEFAULT_TTL_MS = 30_000; // 30 seconds + +export class PercyCache { + private store: Map = new Map(); + + /** + * Returns the cached value if it exists and has not expired. + * Expired entries are deleted on access. + */ + get(key: string): T | null { + const entry = this.store.get(key); + if (!entry) { + return null; + } + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return null; + } + return entry.value as T; + } + + /** + * Stores a value with an optional TTL (defaults to 30 seconds). + */ + set(key: string, value: unknown, ttlMs: number = DEFAULT_TTL_MS): void { + this.store.set(key, { + value, + expiresAt: Date.now() + ttlMs, + }); + } + + /** + * Removes all entries from the cache. + */ + clear(): void { + this.store.clear(); + } + + /** + * Removes a single entry from the cache. + */ + delete(key: string): void { + this.store.delete(key); + } +} + +/** Singleton cache instance shared across Percy API tools. */ +export const percyCache = new PercyCache(); diff --git a/src/lib/percy-api/client.ts b/src/lib/percy-api/client.ts new file mode 100644 index 0000000..d85a124 --- /dev/null +++ b/src/lib/percy-api/client.ts @@ -0,0 +1,379 @@ +/** + * Percy API HTTP client. + * + * Uses native `fetch` (consistent with existing Percy tools in this repo). + * Handles JSON:API deserialization, rate limiting, and error enrichment. + * + * SECURITY: Token values are NEVER logged or exposed in error messages. + */ + +import { BrowserStackConfig } from "../types.js"; +import { getPercyHeaders, getPercyApiBaseUrl } from "./auth.js"; +import { enrichPercyError } from "./errors.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type TokenScope = "project" | "org" | "auto"; + +interface ClientOptions { + scope?: TokenScope; + projectName?: string; +} + +interface JsonApiResource { + id: string; + type: string; + attributes?: Record; + relationships?: Record< + string, + { + data: + | { id: string; type: string } + | Array<{ id: string; type: string }> + | null; + } + >; +} + +interface JsonApiEnvelope { + data: JsonApiResource | JsonApiResource[] | null; + included?: JsonApiResource[]; + meta?: Record; +} + +// --------------------------------------------------------------------------- +// Helpers – kebab-case to camelCase +// --------------------------------------------------------------------------- + +function kebabToCamel(str: string): string { + return str.replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase()); +} + +function camelCaseKeys(obj: unknown): unknown { + if (obj === null || obj === undefined) { + return obj; + } + if (Array.isArray(obj)) { + return obj.map(camelCaseKeys); + } + if (typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + const camelKey = kebabToCamel(key); + result[camelKey] = + value !== null && typeof value === "object" + ? camelCaseKeys(value) + : value; + } + return result; + } + return obj; +} + +// --------------------------------------------------------------------------- +// JSON:API Deserializer +// --------------------------------------------------------------------------- + +/** + * Builds a lookup index of included resources keyed by `type:id`. + */ +function buildIncludedIndex( + included: JsonApiResource[], +): Map> { + const index = new Map>(); + for (const resource of included) { + const flattened = flattenResource(resource); + index.set(`${resource.type}:${resource.id}`, flattened); + } + return index; +} + +/** + * Flattens a single JSON:API resource — merges `attributes` into the top + * level alongside `id` and `type`, converting keys to camelCase. + */ +function flattenResource(resource: JsonApiResource): Record { + const attrs = resource.attributes + ? (camelCaseKeys(resource.attributes) as Record) + : {}; + return { + id: resource.id, + type: resource.type, + ...attrs, + }; +} + +/** + * Resolves relationships for a resource against the included index. + * Returns the resolved object(s) or the raw { id, type } ref when not found. + */ +function resolveRelationships( + resource: JsonApiResource, + index: Map>, +): Record { + if (!resource.relationships) { + return {}; + } + + const resolved: Record = {}; + + for (const [relName, relValue] of Object.entries(resource.relationships)) { + const camelName = kebabToCamel(relName); + const { data } = relValue; + + if (data === null || data === undefined) { + resolved[camelName] = null; + } else if (Array.isArray(data)) { + resolved[camelName] = data.map( + (ref) => + index.get(`${ref.type}:${ref.id}`) ?? { id: ref.id, type: ref.type }, + ); + } else { + resolved[camelName] = index.get(`${data.type}:${data.id}`) ?? { + id: data.id, + type: data.type, + }; + } + } + + return resolved; +} + +/** + * Deserializes a JSON:API envelope into plain objects. + * + * - `data: null` → returns `null` + * - `data: []` → returns `[]` + * - `data: { ... }` → returns a single deserialized object + * - `data: [{ ... }, ...]` → returns an array of deserialized objects + */ +export function deserialize(envelope: JsonApiEnvelope): { + data: Record | Record[] | null; + meta?: Record; +} { + const included = envelope.included ?? []; + const index = buildIncludedIndex(included); + + if (envelope.data === null || envelope.data === undefined) { + return { data: null, meta: envelope.meta }; + } + + if (Array.isArray(envelope.data)) { + const records = envelope.data.map((resource) => ({ + ...flattenResource(resource), + ...resolveRelationships(resource, index), + })); + return { data: records, meta: envelope.meta }; + } + + const record = { + ...flattenResource(envelope.data), + ...resolveRelationships(envelope.data, index), + }; + return { data: record, meta: envelope.meta }; +} + +// --------------------------------------------------------------------------- +// Rate Limit / Retry +// --------------------------------------------------------------------------- + +const MAX_RETRIES = 3; +const BASE_RETRY_DELAY_MS = 1_000; + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// --------------------------------------------------------------------------- +// PercyClient +// --------------------------------------------------------------------------- + +export class PercyClient { + private config: BrowserStackConfig; + private options: ClientOptions; + + constructor(config: BrowserStackConfig, options?: ClientOptions) { + this.config = config; + this.options = options ?? {}; + } + + // ----------------------------------------------------------------------- + // Public HTTP methods + // ----------------------------------------------------------------------- + + /** + * GET request with optional query params and JSON:API `include`. + */ + async get>( + path: string, + params?: Record, + includes?: string[], + ): Promise { + const url = this.buildUrl(path, params, includes); + return this.request("GET", url); + } + + /** + * POST request with an optional JSON body. + */ + async post>( + path: string, + body?: unknown, + ): Promise { + const url = this.buildUrl(path); + return this.request("POST", url, body); + } + + /** + * PATCH request with an optional JSON body. + */ + async patch>( + path: string, + body?: unknown, + ): Promise { + const url = this.buildUrl(path); + return this.request("PATCH", url, body); + } + + /** + * DELETE request. + */ + async del(path: string): Promise { + const url = this.buildUrl(path); + await this.request("DELETE", url); + } + + // ----------------------------------------------------------------------- + // Internal + // ----------------------------------------------------------------------- + + private buildUrl( + path: string, + params?: Record, + includes?: string[], + ): string { + const base = getPercyApiBaseUrl(); + // Ensure no double slashes between base and path + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const url = new URL(`${base}${normalizedPath}`); + + if (params) { + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + } + + if (includes && includes.length > 0) { + url.searchParams.set("include", includes.join(",")); + } + + return url.toString(); + } + + private async request( + method: string, + url: string, + body?: unknown, + ): Promise { + const headers = await getPercyHeaders(this.config, { + scope: this.options.scope, + projectName: this.options.projectName, + }); + + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const fetchOptions: RequestInit = { + method, + headers, + }; + + if (body !== undefined) { + fetchOptions.body = JSON.stringify(body); + } + + let response: Response; + try { + response = await fetch(url, fetchOptions); + } catch (networkError) { + lastError = + networkError instanceof Error + ? networkError + : new Error(String(networkError)); + // Network errors are not retryable via the rate-limit path, + // but we still respect the retry loop for consistency. + if (attempt < MAX_RETRIES) { + await sleep(BASE_RETRY_DELAY_MS * Math.pow(2, attempt)); + continue; + } + throw lastError; + } + + // 204 No Content + if (response.status === 204) { + return undefined as T; + } + + // Rate limited — retry with backoff + if (response.status === 429) { + const retryAfter = response.headers.get("Retry-After"); + const delayMs = retryAfter + ? parseFloat(retryAfter) * 1_000 + : BASE_RETRY_DELAY_MS * Math.pow(2, attempt); + + if (attempt < MAX_RETRIES) { + await sleep(delayMs); + continue; + } + + // Exhausted retries — throw enriched error + let errorBody: unknown; + try { + errorBody = await response.json(); + } catch { + errorBody = undefined; + } + throw enrichPercyError(429, errorBody, `${method} ${url}`); + } + + // Non-2xx error + if (!response.ok) { + let errorBody: unknown; + try { + errorBody = await response.json(); + } catch { + errorBody = undefined; + } + throw enrichPercyError(response.status, errorBody, `${method} ${url}`); + } + + // Successful JSON response — deserialize JSON:API + const json = await response.json(); + + // If the response has a JSON:API `data` key, deserialize it + if (json && typeof json === "object" && "data" in json) { + const deserialized = deserialize(json as JsonApiEnvelope); + + // Unwrap: return the data directly (single object or array) + // Attach meta as a non-enumerable property so it's accessible but doesn't clutter + const result = deserialized.data; + if (result && typeof result === "object" && deserialized.meta) { + Object.defineProperty(result, "__meta", { + value: deserialized.meta, + enumerable: false, + writable: false, + }); + } + return result as T; + } + + // Non-JSON:API response — return as-is + return json as T; + } + + // Should not reach here, but satisfy TypeScript + throw lastError ?? new Error("Request failed after retries"); + } +} diff --git a/src/lib/percy-api/errors.ts b/src/lib/percy-api/errors.ts new file mode 100644 index 0000000..b094a84 --- /dev/null +++ b/src/lib/percy-api/errors.ts @@ -0,0 +1,117 @@ +/** + * Percy API error enrichment module. + * Maps Percy API error responses to actionable, user-friendly messages. + */ + +export class PercyApiError extends Error { + statusCode: number; + errorCode?: string; + body?: unknown; + + constructor( + message: string, + statusCode: number, + errorCode?: string, + body?: unknown, + ) { + super(message); + this.name = "PercyApiError"; + this.statusCode = statusCode; + this.errorCode = errorCode; + this.body = body; + } +} + +/** + * Maps Percy API error responses to actionable messages. + * Handles known error codes from Percy's JSON:API responses. + */ +export function enrichPercyError( + status: number, + body: unknown, + context?: string, +): PercyApiError { + const prefix = context ? `${context}: ` : ""; + const errorBody = body as Record | undefined; + const errors = (errorBody?.errors ?? []) as Array>; + const firstError = errors[0]; + const errorCode = (firstError?.code ?? firstError?.source) as + | string + | undefined; + const detail = (firstError?.detail ?? firstError?.title ?? "") as string; + + switch (status) { + case 401: + return new PercyApiError( + `${prefix}Percy token is invalid or expired. Check PERCY_TOKEN environment variable.`, + 401, + errorCode, + body, + ); + + case 403: { + if (errorCode === "project_rbac_access_denied") { + return new PercyApiError( + `${prefix}Insufficient permissions. This operation requires write access to the project.`, + 403, + errorCode, + body, + ); + } + if (errorCode === "build_deleted") { + return new PercyApiError( + `${prefix}This build has been deleted.`, + 403, + errorCode, + body, + ); + } + if (errorCode === "plan_history_exceeded") { + return new PercyApiError( + `${prefix}This build is outside your plan's history limit.`, + 403, + errorCode, + body, + ); + } + return new PercyApiError( + `${prefix}Forbidden: ${detail || "Access denied."}`, + 403, + errorCode, + body, + ); + } + + case 404: + return new PercyApiError( + `${prefix}Resource not found. Check the ID and try again.`, + 404, + errorCode, + body, + ); + + case 422: + return new PercyApiError( + `${prefix}Invalid request: ${detail || "Unprocessable entity."}`, + 422, + errorCode, + body, + ); + + case 429: + return new PercyApiError( + `${prefix}Rate limit exceeded. Try again shortly.`, + 429, + errorCode, + body, + ); + + default: + return new PercyApiError( + `${prefix}Percy API error (${status}): ${detail || "Unknown error"}`, + status, + errorCode, + body, + ); + } +} diff --git a/src/lib/percy-api/formatter.ts b/src/lib/percy-api/formatter.ts new file mode 100644 index 0000000..1ee04a4 --- /dev/null +++ b/src/lib/percy-api/formatter.ts @@ -0,0 +1,401 @@ +/** + * Markdown formatting utilities for Percy API responses. + * + * Each function transforms typed Percy API data into concise, + * agent-readable markdown. All functions handle null/undefined + * fields gracefully — showing "N/A" or omitting the section. + */ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function pct(value: number | null | undefined): string { + if (value == null) return "N/A"; + return `${(value * 100).toFixed(1)}%`; +} + +function na(value: unknown): string { + if (value == null || value === "") return "N/A"; + return String(value); +} + +function formatDuration( + startIso: string | null, + endIso: string | null, +): string { + if (!startIso || !endIso) return "N/A"; + const ms = new Date(endIso).getTime() - new Date(startIso).getTime(); + if (Number.isNaN(ms) || ms < 0) return "N/A"; + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes === 0) return `${seconds}s`; + return `${minutes}m ${seconds}s`; +} + +// --------------------------------------------------------------------------- +// formatBuild +// --------------------------------------------------------------------------- + +export function formatBuild(build: any): string { + if (!build) return "_No build data available._"; + + const num = build.buildNumber ?? "?"; + const state = (build.state ?? "unknown").toUpperCase(); + + const lines: string[] = []; + + // Header — state-aware + if (build.state === "processing") { + const total = build.totalComparisons ?? 0; + const finished = build.totalComparisonsFinished ?? 0; + const percent = total > 0 ? Math.round((finished / total) * 100) : 0; + lines.push(`## Build #${num} — PROCESSING (${percent}% complete)`); + } else if (build.state === "failed") { + lines.push(`## Build #${num} — FAILED`); + } else { + lines.push(`## Build #${num} — ${state}`); + } + + // Branch / SHA + const branch = na(build.branch); + const sha = na(build.commit?.sha ?? build.sha); + lines.push(`**Branch:** ${branch} | **SHA:** ${sha}`); + + // Review state + if (build.reviewState) { + lines.push(`**Review:** ${build.reviewState}`); + } + + // Snapshot stats — handle both camelCase and kebab-case + const total = build.totalSnapshots ?? build["total-snapshots"]; + const changed = build.totalComparisonsDiff ?? build["total-comparisons-diff"]; + const totalComparisons = build.totalComparisons ?? build["total-comparisons"]; + const unreviewed = + build.totalSnapshotsUnreviewed ?? build["total-snapshots-unreviewed"]; + const newSnaps = null; // Not in API — derived from build-items category + const removed = null; // Not in API — derived from build-items category + const unchanged = null; // Not in API — derived from build-items category + + if (total != null) { + const parts = [`${total} snapshots`]; + if (totalComparisons != null) parts.push(`${totalComparisons} comparisons`); + if (changed != null) parts.push(`${changed} with diffs`); + if (unreviewed != null) parts.push(`${unreviewed} unreviewed`); + if (newSnaps != null) parts.push(`${newSnaps} new`); + if (removed != null) parts.push(`${removed} removed`); + if (unchanged != null) parts.push(`${unchanged} unchanged`); + lines.push(`**Stats:** ${parts.join(" | ")}`); + } + + // Duration + const duration = formatDuration(build.createdAt, build.finishedAt); + if (duration !== "N/A") { + lines.push(`**Duration:** ${duration}`); + } + + // No visual changes + if ( + build.state === "finished" && + (build.totalComparisonsDiff === 0 || build.totalComparisonsDiff == null) && + (build.totalSnapshotsNew ?? 0) === 0 && + (build.totalSnapshotsRemoved ?? 0) === 0 + ) { + lines.push(""); + lines.push("> **No visual changes detected in this build.**"); + } + + // Failure info + if (build.state === "failed") { + lines.push(""); + if (build.failureReason) { + lines.push(`**Failure Reason:** ${build.failureReason}`); + } + if (build.errorBuckets && build.errorBuckets.length > 0) { + lines.push(""); + lines.push("### Error Buckets"); + for (const bucket of build.errorBuckets) { + const name = bucket.name ?? bucket.errorType ?? "Unknown"; + const count = bucket.count ?? bucket.snapshotCount ?? "?"; + lines.push(`- **${name}** — ${count} snapshot(s)`); + } + } + } + + // AI analysis — handle both camelCase (from deserializer) and kebab-case keys + const ai = build.aiDetails || build["ai-details"]; + if (ai && build.state !== "failed") { + const aiEnabled = ai.aiEnabled ?? ai["ai-enabled"] ?? false; + if (aiEnabled) { + lines.push(""); + lines.push("### AI Analysis"); + const compsWithAi = + ai.totalComparisonsWithAi ?? ai["total-comparisons-with-ai"]; + const bugs = ai.totalPotentialBugs ?? ai["total-potential-bugs"]; + const diffsReduced = + ai.totalDiffsReducedCapped ?? ai["total-diffs-reduced-capped"]; + const aiVisualDiffs = + ai.totalAiVisualDiffs ?? ai["total-ai-visual-diffs"]; + const allCompleted = ai.allAiJobsCompleted ?? ai["all-ai-jobs-completed"]; + const summaryStatus = ai.summaryStatus ?? ai["summary-status"]; + + if (compsWithAi != null) { + lines.push(`- Comparisons analyzed by AI: ${compsWithAi}`); + } + if (bugs != null && bugs > 0) { + lines.push(`- **Potential bugs: ${bugs}**`); + } + if (diffsReduced != null && diffsReduced > 0) { + lines.push(`- Diffs reduced by AI: ${diffsReduced}`); + } + if (aiVisualDiffs != null) { + lines.push(`- AI visual diffs: ${aiVisualDiffs}`); + } + if (allCompleted != null) { + lines.push(`- AI jobs: ${allCompleted ? "completed" : "in progress"}`); + } + if (summaryStatus) { + lines.push(`- Summary: ${summaryStatus}`); + } + } + } + + // Build summary — from included build-summary relationship + const buildSummary = build.buildSummary; + const summaryText = buildSummary?.summary || build.summary; + if (summaryText) { + lines.push(""); + lines.push("### Build Summary"); + try { + const parsed = + typeof summaryText === "string" ? JSON.parse(summaryText) : summaryText; + if (parsed?.title) { + lines.push(`> ${parsed.title}`); + } + if (Array.isArray(parsed?.items)) { + parsed.items.forEach((item: any) => { + lines.push(`- ${item.title || item}`); + }); + } + } catch { + // Not JSON — treat as plain text + const text = String(summaryText); + lines.push( + text + .split("\n") + .map((l: string) => `> ${l}`) + .join("\n"), + ); + } + } + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// formatSnapshot +// --------------------------------------------------------------------------- + +export function formatSnapshot(snapshot: any, comparisons?: any[]): string { + if (!snapshot) return "_No snapshot data available._"; + + const lines: string[] = []; + lines.push(`### ${na(snapshot.name)}`); + + if (snapshot.reviewState) { + lines.push(`**Review:** ${snapshot.reviewState}`); + } + + if (comparisons && comparisons.length > 0) { + lines.push(""); + lines.push("| Browser | Width | Diff | AI Diff | AI Status |"); + lines.push("|---------|-------|------|---------|-----------|"); + for (const c of comparisons) { + const browser = na(c.browser?.name ?? c.browserName); + const width = c.width != null ? `${c.width}px` : "N/A"; + const diff = pct(c.diffRatio); + const aiDiff = pct(c.aiDiffRatio); + const aiStatus = na(c.aiProcessingState); + lines.push( + `| ${browser} | ${width} | ${diff} | ${aiDiff} | ${aiStatus} |`, + ); + } + } + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// formatComparison +// --------------------------------------------------------------------------- + +export function formatComparison( + comparison: any, + options?: { includeRegions?: boolean }, +): string { + if (!comparison) return "_No comparison data available._"; + + const browser = na(comparison.browser?.name ?? comparison.browserName); + const width = comparison.width != null ? `${comparison.width}px` : ""; + const diff = pct(comparison.diffRatio); + + const lines: string[] = []; + + // Header + let header = `**${browser} ${width}** — ${diff} diff`; + if (comparison.aiDiffRatio != null) { + header += ` (AI: ${pct(comparison.aiDiffRatio)})`; + } + lines.push(header); + + // Image URLs + const baseUrl = comparison.baseScreenshot?.url ?? comparison.baseUrl; + const headUrl = comparison.headScreenshot?.url ?? comparison.headUrl; + const diffUrl = comparison.diffImage?.url ?? comparison.diffUrl; + + if (baseUrl || headUrl || diffUrl) { + lines.push(""); + lines.push("Images:"); + if (baseUrl) lines.push(`- Base: ${baseUrl}`); + if (headUrl) lines.push(`- Head: ${headUrl}`); + if (diffUrl) lines.push(`- Diff: ${diffUrl}`); + } + + // AI Regions + if ( + options?.includeRegions && + comparison.appliedRegions && + comparison.appliedRegions.length > 0 + ) { + const regions = comparison.appliedRegions; + lines.push(""); + lines.push(`AI Regions (${regions.length}):`); + regions.forEach((region: any, i: number) => { + const label = na(region.label ?? region.name); + const type = region.type ?? region.changeType ?? "unknown"; + const desc = region.description ?? ""; + let line = `${i + 1}. **${label}** (${type})`; + if (desc) line += ` — ${desc}`; + lines.push(line); + }); + } + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// formatSuggestions +// --------------------------------------------------------------------------- + +export function formatSuggestions(suggestions: any[]): string { + if (!suggestions || suggestions.length === 0) { + return "_No failure suggestions available._"; + } + + const lines: string[] = []; + lines.push("## Build Failure Suggestions"); + lines.push(""); + + suggestions.forEach((s: any, i: number) => { + const title = na(s.title ?? s.name); + const affected = s.affectedSnapshots ?? s.snapshotsAffected ?? null; + let heading = `### ${i + 1}. ${title}`; + if (affected != null) heading += ` (${affected} snapshots affected)`; + lines.push(heading); + + if (s.reason) lines.push(`**Reason:** ${s.reason}`); + if (s.description) lines.push(`**Reason:** ${s.description}`); + + if (s.fixSteps && s.fixSteps.length > 0) { + lines.push("**Fix Steps:**"); + s.fixSteps.forEach((step: string, j: number) => { + lines.push(`${j + 1}. ${step}`); + }); + } + + if (s.docsUrl ?? s.docs) { + lines.push(`**Docs:** ${s.docsUrl ?? s.docs}`); + } + + lines.push(""); + }); + + return lines.join("\n").trimEnd(); +} + +// --------------------------------------------------------------------------- +// formatNetworkLogs +// --------------------------------------------------------------------------- + +export function formatNetworkLogs(logs: any[]): string { + if (!logs || logs.length === 0) { + return "_No network logs available._"; + } + + const lines: string[] = []; + lines.push("## Network Logs"); + lines.push(""); + lines.push("| URL | Base Status | Head Status | Type | Issue |"); + lines.push("|-----|-------------|-------------|------|-------|"); + + for (const log of logs) { + const url = na(log.url); + const baseStatus = na(log.baseStatus ?? log.baseStatusCode); + const headStatus = na(log.headStatus ?? log.headStatusCode); + const type = na(log.resourceType ?? log.type); + const issue = na(log.issue ?? log.error); + lines.push( + `| ${url} | ${baseStatus} | ${headStatus} | ${type} | ${issue} |`, + ); + } + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// formatBuildStatus +// --------------------------------------------------------------------------- + +export function formatBuildStatus(build: any): string { + if (!build) return "Build: N/A"; + + const num = build.buildNumber ?? "?"; + const state = (build.state ?? "unknown").toUpperCase(); + const parts: string[] = []; + + if (build.totalComparisonsDiff != null) { + parts.push(`${build.totalComparisonsDiff} changed`); + } + + const ai = build.aiDetails; + if (ai?.potentialBugs != null) { + parts.push(`${ai.potentialBugs} bugs`); + } + if (ai?.noiseFiltered != null) { + parts.push(`${ai.noiseFiltered}% noise filtered`); + } + + const suffix = parts.length > 0 ? ` — ${parts.join(", ")}` : ""; + return `Build #${num}: ${state}${suffix}`; +} + +// --------------------------------------------------------------------------- +// formatAiWarning +// --------------------------------------------------------------------------- + +export function formatAiWarning(comparisons: any[]): string { + if (!comparisons || comparisons.length === 0) return ""; + + const incomplete = comparisons.filter( + (c: any) => + c.aiProcessingState && + c.aiProcessingState !== "completed" && + c.aiProcessingState !== "not_enabled", + ); + + if (incomplete.length === 0) return ""; + + const total = comparisons.length; + return `> ⚠ AI analysis in progress for ${incomplete.length} of ${total} comparisons. Re-run for complete analysis.`; +} diff --git a/src/lib/percy-api/percy-auth.ts b/src/lib/percy-api/percy-auth.ts new file mode 100644 index 0000000..c792b0b --- /dev/null +++ b/src/lib/percy-api/percy-auth.ts @@ -0,0 +1,195 @@ +/** + * Percy authentication — uses BrowserStack Basic Auth for ALL Percy API calls. + * + * This is the correct auth method. The existing working tools (fetchPercyChanges, + * managePercyBuildApproval) all use Basic Auth successfully. + * + * Percy Token (PERCY_TOKEN) is only needed for: + * - percy CLI commands (percy exec, percy snapshot) + * - Direct build creation when no BrowserStack credentials available + */ + +import { getBrowserStackAuth } from "../get-auth.js"; +import { BrowserStackConfig } from "../types.js"; + +/** + * Get auth headers for Percy API calls. + * Uses BrowserStack Basic Auth (username:accessKey). + */ +export function getPercyAuthHeaders( + config: BrowserStackConfig, +): Record { + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); + + return { + Authorization: `Basic ${auth}`, + "Content-Type": "application/json", + "User-Agent": "browserstack-mcp-server", + }; +} + +/** + * Get Percy Token auth headers (for token-scoped operations). + * Falls back to fetching token via BrowserStack API if not in env. + */ +export function getPercyTokenHeaders(token: string): Record { + return { + Authorization: `Token token=${token}`, + "Content-Type": "application/json", + "User-Agent": "browserstack-mcp-server", + }; +} + +const PERCY_API_BASE = "https://percy.io/api/v1"; + +/** + * Make a GET request to Percy API with Basic Auth. + */ +export async function percyGet( + path: string, + config: BrowserStackConfig, + params?: Record, +): Promise { + const headers = getPercyAuthHeaders(config); + const url = new URL(`${PERCY_API_BASE}${path}`); + + if (params) { + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + } + + const response = await fetch(url.toString(), { headers }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `GET ${path}: ${response.status} ${response.statusText}. ${body}`, + ); + } + + if (response.status === 204) return null; + return response.json(); +} + +/** + * Make a POST request to Percy API with Basic Auth. + */ +export async function percyPost( + path: string, + config: BrowserStackConfig, + body?: unknown, +): Promise { + const headers = getPercyAuthHeaders(config); + const url = `${PERCY_API_BASE}${path}`; + + const response = await fetch(url, { + method: "POST", + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const responseBody = await response.text().catch(() => ""); + throw new Error( + `POST ${path}: ${response.status} ${response.statusText}. ${responseBody}`, + ); + } + + if (response.status === 204) return null; + return response.json(); +} + +/** + * Make a PATCH request to Percy API with Basic Auth. + */ +export async function percyPatch( + path: string, + config: BrowserStackConfig, + body?: unknown, +): Promise { + const headers = getPercyAuthHeaders(config); + const url = `${PERCY_API_BASE}${path}`; + + const response = await fetch(url, { + method: "PATCH", + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const responseBody = await response.text().catch(() => ""); + throw new Error( + `PATCH ${path}: ${response.status} ${response.statusText}. ${responseBody}`, + ); + } + + if (response.status === 204) return null; + return response.json(); +} + +/** + * Make a POST to Percy API using Percy Token auth. + * Used for build creation when a project token is available. + */ +export async function percyTokenPost( + path: string, + token: string, + body?: unknown, +): Promise { + const headers = getPercyTokenHeaders(token); + const url = `${PERCY_API_BASE}${path}`; + + const response = await fetch(url, { + method: "POST", + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const responseBody = await response.text().catch(() => ""); + throw new Error( + `POST ${path}: ${response.status} ${response.statusText}. ${responseBody}`, + ); + } + + if (response.status === 204) return null; + return response.json(); +} + +/** + * Get or create a Percy project token via BrowserStack API. + * Creates the project if it doesn't exist. + */ +export async function getOrCreateProjectToken( + projectName: string, + config: BrowserStackConfig, + type?: string, +): Promise { + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); + + const params = new URLSearchParams({ name: projectName }); + if (type) params.append("type", type); + + const url = `https://api.browserstack.com/api/app_percy/get_project_token?${params.toString()}`; + const response = await fetch(url, { + headers: { Authorization: `Basic ${auth}` }, + }); + + if (!response.ok) { + throw new Error( + `Failed to get token for project "${projectName}": ${response.status}`, + ); + } + + const data = await response.json(); + if (!data?.token || !data?.success) { + throw new Error( + `No token returned for project "${projectName}". Check the project name.`, + ); + } + + return data.token; +} diff --git a/src/lib/percy-api/percy-error-handler.ts b/src/lib/percy-api/percy-error-handler.ts new file mode 100644 index 0000000..43d0bea --- /dev/null +++ b/src/lib/percy-api/percy-error-handler.ts @@ -0,0 +1,326 @@ +/** + * Shared Percy error handler — turns raw API errors into helpful guidance. + * + * Instead of showing "403 Forbidden" or "404 Not Found", returns: + * - What went wrong + * - What the correct input looks like + * - Suggested next steps + */ + +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface ToolParam { + name: string; + required: boolean; + description: string; + example: string; +} + +interface ToolHelp { + name: string; + description: string; + params: ToolParam[]; + examples: string[]; +} + +export function handlePercyToolError( + error: unknown, + toolHelp: ToolHelp, + args: Record, +): CallToolResult { + const message = error instanceof Error ? error.message : String(error); + + let output = `## Error: ${toolHelp.name}\n\n`; + + // Parse the error type + if (message.includes("401") || message.includes("Unauthorized")) { + output += `**Authentication failed.** Your BrowserStack credentials may be invalid or expired.\n\n`; + output += `Check with: \`Use percy_auth_status\`\n`; + } else if (message.includes("403") || message.includes("Forbidden")) { + output += `**Access denied.** Your credentials don't have permission for this operation.\n\n`; + output += `This usually means:\n`; + output += `- The ID you provided doesn't belong to your organization\n`; + output += `- Your account doesn't have access to this feature\n`; + } else if (message.includes("404") || message.includes("Not Found")) { + output += `**Not found.** The ID or slug you provided doesn't exist.\n\n`; + // Show what was passed + const passedArgs = Object.entries(args) + .filter(([, v]) => v != null && v !== "") + .map(([k, v]) => `- \`${k}\`: \`${String(v)}\``) + .join("\n"); + if (passedArgs) { + output += `You provided:\n${passedArgs}\n\n`; + } + output += `Double-check the ID/slug is correct.\n`; + } else if (message.includes("422") || message.includes("Unprocessable")) { + output += `**Invalid input.** The parameters you provided aren't valid.\n\n`; + } else if (message.includes("429") || message.includes("Rate")) { + output += `**Rate limited.** Too many requests. Wait a moment and try again.\n`; + return { content: [{ type: "text", text: output }], isError: true }; + } else if (message.includes("500") || message.includes("Internal Server")) { + output += `**Percy API error.** The server returned an internal error. This is usually temporary.\n\n`; + output += `Try again in a moment. If it persists, the endpoint may not be available for your account.\n`; + } else { + output += `**Error:** ${message}\n\n`; + } + + // Show correct usage + output += `\n### Correct Usage\n\n`; + output += `**${toolHelp.description}**\n\n`; + + output += `| Parameter | Required | Description | Example |\n|---|---|---|---|\n`; + toolHelp.params.forEach((p) => { + output += `| \`${p.name}\` | ${p.required ? "Yes" : "No"} | ${p.description} | \`${p.example}\` |\n`; + }); + + if (toolHelp.examples.length > 0) { + output += `\n### Examples\n\n`; + toolHelp.examples.forEach((ex) => { + output += `\`\`\`\n${ex}\n\`\`\`\n\n`; + }); + } + + // Suggest discovery tools + output += `### How to find the right IDs\n\n`; + output += `- **Project slug:** \`Use percy_get_projects\`\n`; + output += `- **Build ID:** \`Use percy_get_builds with project_slug "org/project"\`\n`; + output += `- **Snapshot ID:** \`Use percy_get_build with build_id "123" and detail "snapshots"\`\n`; + output += `- **Comparison ID:** \`Use percy_get_snapshot with snapshot_id "456"\`\n`; + + return { content: [{ type: "text", text: output }], isError: true }; +} + +// ── Pre-defined tool help for each tool ───────────────────────────────────── + +export const TOOL_HELP: Record = { + percy_get_build: { + name: "percy_get_build", + description: "Get build details with different views", + params: [ + { + name: "build_id", + required: true, + description: "Percy build ID (numeric)", + example: "48436286", + }, + { + name: "detail", + required: false, + description: "View type", + example: "overview", + }, + { + name: "comparison_id", + required: false, + description: "For rca/network detail", + example: "4391856176", + }, + ], + examples: [ + 'Use percy_get_build with build_id "48436286"', + 'Use percy_get_build with build_id "48436286" and detail "ai_summary"', + 'Use percy_get_build with build_id "48436286" and detail "changes"', + ], + }, + percy_get_snapshot: { + name: "percy_get_snapshot", + description: "Get snapshot with all comparisons and AI analysis", + params: [ + { + name: "snapshot_id", + required: true, + description: "Percy snapshot ID (numeric)", + example: "2576885624", + }, + ], + examples: ['Use percy_get_snapshot with snapshot_id "2576885624"'], + }, + percy_get_comparison: { + name: "percy_get_comparison", + description: "Get comparison with AI change descriptions and image URLs", + params: [ + { + name: "comparison_id", + required: true, + description: "Percy comparison ID (numeric)", + example: "4391856176", + }, + ], + examples: ['Use percy_get_comparison with comparison_id "4391856176"'], + }, + percy_get_builds: { + name: "percy_get_builds", + description: "List builds for a project", + params: [ + { + name: "project_slug", + required: false, + description: "Project slug from percy_get_projects", + example: "9560f98d/my-project-abc123", + }, + { + name: "branch", + required: false, + description: "Filter by branch", + example: "main", + }, + { + name: "state", + required: false, + description: "Filter by state", + example: "finished", + }, + ], + examples: [ + 'Use percy_get_builds with project_slug "9560f98d/my-project-abc123"', + "Use percy_get_projects (to find project slugs first)", + ], + }, + percy_get_projects: { + name: "percy_get_projects", + description: "List all Percy projects", + params: [ + { + name: "search", + required: false, + description: "Search by name", + example: "my-app", + }, + ], + examples: [ + "Use percy_get_projects", + 'Use percy_get_projects with search "dashboard"', + ], + }, + percy_create_build: { + name: "percy_create_build", + description: "Create a Percy build with snapshots", + params: [ + { + name: "project_name", + required: true, + description: "Project name", + example: "my-app", + }, + { + name: "urls", + required: false, + description: "URLs to snapshot", + example: "http://localhost:3000", + }, + { + name: "screenshots_dir", + required: false, + description: "Screenshot directory", + example: "./screenshots", + }, + { + name: "test_command", + required: false, + description: "Test command", + example: "npx cypress run", + }, + ], + examples: [ + 'Use percy_create_build with project_name "my-app" and urls "http://localhost:3000"', + ], + }, + percy_create_project: { + name: "percy_create_project", + description: "Create or get a Percy project", + params: [ + { + name: "name", + required: true, + description: "Project name", + example: "my-app", + }, + { + name: "type", + required: false, + description: "web or automate", + example: "web", + }, + ], + examples: ['Use percy_create_project with name "my-app"'], + }, + percy_clone_build: { + name: "percy_clone_build", + description: "Clone snapshots from one build to another project", + params: [ + { + name: "source_build_id", + required: true, + description: "Build ID to clone from", + example: "48436286", + }, + { + name: "target_project_name", + required: true, + description: "Target project name", + example: "my-project", + }, + ], + examples: [ + 'Use percy_clone_build with source_build_id "48436286" and target_project_name "my-project"', + ], + }, + percy_create_app_build: { + name: "percy_create_app_build", + description: "Create an App Percy BYOS build from device screenshots", + params: [ + { + name: "project_name", + required: true, + description: "App Percy project name", + example: "my-mobile-app", + }, + { + name: "resources_dir", + required: true, + description: "Path to resources directory with device folders", + example: "./resources", + }, + { + name: "branch", + required: false, + description: "Git branch (auto-detected)", + example: "main", + }, + { + name: "test_case", + required: false, + description: "Test case name for all snapshots", + example: "Login Flow", + }, + ], + examples: [ + 'Use percy_create_app_build with project_name "my-mobile-app" and resources_dir "./resources"', + ], + }, + percy_get_insights: { + name: "percy_get_insights", + description: "Get testing health metrics", + params: [ + { + name: "org_slug", + required: true, + description: "Organization slug or ID", + example: "9560f98d", + }, + { + name: "period", + required: false, + description: "Time period", + example: "last_30_days", + }, + { + name: "product", + required: false, + description: "web or app", + example: "web", + }, + ], + examples: ['Use percy_get_insights with org_slug "9560f98d"'], + }, +}; diff --git a/src/lib/percy-api/percy-session.ts b/src/lib/percy-api/percy-session.ts new file mode 100644 index 0000000..f9bccf8 --- /dev/null +++ b/src/lib/percy-api/percy-session.ts @@ -0,0 +1,121 @@ +/** + * Percy Session — In-memory state that persists across tool calls. + * + * Stores active project token, build context, and org info so + * subsequent tool calls get richer context automatically. + */ + +export interface PercySessionState { + // Active project + projectName?: string; + projectToken?: string; + projectSlug?: string; + projectId?: string; + projectType?: string; + + // Active build + buildId?: string; + buildNumber?: string; + buildUrl?: string; + buildBranch?: string; + + // Org + orgSlug?: string; + orgId?: string; +} + +let session: PercySessionState = {}; + +// ── Setters ───────────────────────────────────────────────────────────────── + +export function setActiveProject(opts: { + name: string; + token: string; + slug?: string; + id?: string; + type?: string; +}) { + session.projectName = opts.name; + session.projectToken = opts.token; + if (opts.slug) session.projectSlug = opts.slug; + if (opts.id) session.projectId = opts.id; + if (opts.type) session.projectType = opts.type; +} + +export function setActiveBuild(opts: { + id: string; + number?: string; + url?: string; + branch?: string; +}) { + session.buildId = opts.id; + if (opts.number) session.buildNumber = opts.number; + if (opts.url) session.buildUrl = opts.url; + if (opts.branch) session.buildBranch = opts.branch; +} + +export function setOrg(opts: { slug?: string; id?: string }) { + if (opts.slug) session.orgSlug = opts.slug; + if (opts.id) session.orgId = opts.id; +} + +// ── Getters ───────────────────────────────────────────────────────────────── + +export function getSession(): PercySessionState { + return { ...session }; +} + +export function getActiveToken(): string | undefined { + return session.projectToken; +} + +export function getActiveBuildId(): string | undefined { + return session.buildId; +} + +// ── Formatters (append to tool output) ────────────────────────────────────── + +export function formatActiveProject(): string { + if (!session.projectName) return ""; + const masked = session.projectToken + ? `${session.projectToken.slice(0, 8)}...${session.projectToken.slice(-4)}` + : "—"; + let out = `\n### Active Project\n\n`; + out += `| | |\n|---|---|\n`; + out += `| **Project** | ${session.projectName} |\n`; + out += `| **Token** | \`${masked}\` |\n`; + if (session.projectType) out += `| **Type** | ${session.projectType} |\n`; + if (session.projectSlug) out += `| **Slug** | ${session.projectSlug} |\n`; + return out; +} + +export function formatActiveBuild(): string { + if (!session.buildId) return ""; + let out = `\n### Active Build\n\n`; + out += `| | |\n|---|---|\n`; + out += `| **Build ID** | ${session.buildId} |\n`; + if (session.buildNumber) + out += `| **Build #** | ${session.buildNumber} |\n`; + if (session.buildUrl) out += `| **URL** | ${session.buildUrl} |\n`; + if (session.buildBranch) + out += `| **Branch** | ${session.buildBranch} |\n`; + return out; +} + +export function formatSessionSummary(): string { + const parts: string[] = []; + if (session.projectName) { + const masked = session.projectToken + ? `****${session.projectToken.slice(-4)}` + : ""; + parts.push( + `**Project:** ${session.projectName} (${masked})`, + ); + } + if (session.buildId) { + parts.push( + `**Build:** #${session.buildNumber || session.buildId}${session.buildUrl ? ` — ${session.buildUrl}` : ""}`, + ); + } + return parts.length > 0 ? parts.join(" | ") : ""; +} diff --git a/src/lib/percy-api/polling.ts b/src/lib/percy-api/polling.ts new file mode 100644 index 0000000..28bae00 --- /dev/null +++ b/src/lib/percy-api/polling.ts @@ -0,0 +1,64 @@ +/** + * Exponential backoff polling utility for Percy API. + * + * Used when waiting for async operations to complete (e.g., build processing, + * AI analysis finishing). Returns null on timeout rather than throwing. + */ + +export interface PollOptions { + /** Initial delay between polls in milliseconds. Default: 500 */ + initialDelayMs?: number; + /** Maximum delay between polls in milliseconds. Default: 5000 */ + maxDelayMs?: number; + /** Total timeout in milliseconds. Default: 120000 (2 minutes) */ + maxTimeoutMs?: number; + /** Optional callback invoked before each poll attempt. */ + onPoll?: (attempt: number) => void; +} + +/** + * Polls `fn` with exponential backoff until it returns `{ done: true }`. + * + * Backoff schedule: initialDelay → 2x → 4x → ... capped at maxDelay. + * Returns the result when done, or null if the timeout is exceeded. + */ +export async function pollUntil( + fn: () => Promise<{ done: boolean; result?: T }>, + options?: PollOptions, +): Promise { + const initialDelayMs = options?.initialDelayMs ?? 500; + const maxDelayMs = options?.maxDelayMs ?? 5_000; + const maxTimeoutMs = options?.maxTimeoutMs ?? 120_000; + const onPoll = options?.onPoll; + + const startTime = Date.now(); + let delay = initialDelayMs; + let attempt = 0; + + while (Date.now() - startTime < maxTimeoutMs) { + attempt++; + if (onPoll) { + onPoll(attempt); + } + + const response = await fn(); + if (response.done) { + return response.result ?? null; + } + + // Check if waiting another cycle would exceed the timeout + if (Date.now() - startTime + delay >= maxTimeoutMs) { + break; + } + + await sleep(delay); + delay = Math.min(delay * 2, maxDelayMs); + } + + // Timeout exceeded + return null; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/lib/percy-api/types.ts b/src/lib/percy-api/types.ts new file mode 100644 index 0000000..f43d940 --- /dev/null +++ b/src/lib/percy-api/types.ts @@ -0,0 +1,92 @@ +/** + * Percy API Zod schemas and inferred TypeScript types. + * + * All schemas use `.passthrough()` to allow extra fields from the API + * without throwing validation errors. This ensures forward compatibility + * as the Percy API evolves. + */ + +import { z } from "zod"; + +// --------------------------------------------------------------------------- +// Build +// --------------------------------------------------------------------------- +export const PercyBuildSchema = z + .object({ + id: z.string(), + type: z.literal("builds").optional(), + state: z.string(), + branch: z.string().nullable(), + buildNumber: z.number().nullable(), + reviewState: z.string().nullable(), + reviewStateReason: z.string().nullable(), + totalSnapshots: z.number().nullable(), + totalComparisons: z.number().nullable(), + totalComparisonsDiff: z.number().nullable(), + failedSnapshotsCount: z.number().nullable(), + failureReason: z.string().nullable(), + createdAt: z.string().nullable(), + finishedAt: z.string().nullable(), + aiDetails: z.any().nullable(), + errorBuckets: z.array(z.any()).nullable(), + }) + .passthrough(); + +export type PercyBuild = z.infer; + +// --------------------------------------------------------------------------- +// Comparison +// --------------------------------------------------------------------------- +export const PercyComparisonSchema = z + .object({ + id: z.string(), + state: z.string().nullable(), + width: z.number().nullable(), + diffRatio: z.number().nullable(), + aiDiffRatio: z.number().nullable(), + aiProcessingState: z.string().nullable(), + aiDetails: z.any().nullable(), + appliedRegions: z.array(z.any()).nullable(), + }) + .passthrough(); + +export type PercyComparison = z.infer; + +// --------------------------------------------------------------------------- +// Snapshot +// --------------------------------------------------------------------------- +export const PercySnapshotSchema = z + .object({ + id: z.string(), + name: z.string().nullable(), + reviewState: z.string().nullable(), + reviewStateReason: z.string().nullable(), + }) + .passthrough(); + +export type PercySnapshot = z.infer; + +// --------------------------------------------------------------------------- +// Project +// --------------------------------------------------------------------------- +export const PercyProjectSchema = z + .object({ + id: z.string(), + name: z.string().nullable(), + slug: z.string().nullable(), + }) + .passthrough(); + +export type PercyProject = z.infer; + +// --------------------------------------------------------------------------- +// Build Summary +// --------------------------------------------------------------------------- +export const PercyBuildSummarySchema = z + .object({ + id: z.string(), + summary: z.string().nullable(), + }) + .passthrough(); + +export type PercyBuildSummary = z.infer; diff --git a/src/server-factory.ts b/src/server-factory.ts index a5a926d..3d7f72c 100644 --- a/src/server-factory.ts +++ b/src/server-factory.ts @@ -20,6 +20,8 @@ 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"; +// import addPercyMcpTools from "./tools/percy-mcp/index.js"; // v1 disabled +import addPercyMcpToolsV2 from "./tools/percy-mcp/v2/index.js"; /** * Wrapper class for BrowserStack MCP Server @@ -61,6 +63,8 @@ export class BrowserStackMcpServer { addSelfHealTools, addBuildInsightsTools, addRCATools, + // addPercyMcpTools, // v1 — disabled, replaced by v2 + addPercyMcpToolsV2, ]; toolAdders.forEach((adder) => { diff --git a/src/tools/percy-mcp/advanced/branchline-operations.ts b/src/tools/percy-mcp/advanced/branchline-operations.ts new file mode 100644 index 0000000..9768d95 --- /dev/null +++ b/src/tools/percy-mcp/advanced/branchline-operations.ts @@ -0,0 +1,102 @@ +/** + * percy_branchline_operations — Sync, merge, or unmerge Percy branch baselines. + * + * Sync copies approved baselines to target branches. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface BranchlineOperationsArgs { + action: string; + project_id?: string; + build_id?: string; + target_branch_filter?: string; + snapshot_ids?: string; +} + +export async function percyBranchlineOperations( + args: BranchlineOperationsArgs, + config: BrowserStackConfig, +): Promise { + const { action, project_id, build_id, target_branch_filter, snapshot_ids } = + args; + const client = new PercyClient(config); + + const VALID_ACTIONS = ["sync", "merge", "unmerge"]; + if (!VALID_ACTIONS.includes(action)) { + return { + content: [ + { + type: "text", + text: `Invalid action "${action}". Valid actions: ${VALID_ACTIONS.join(", ")}`, + }, + ], + isError: true, + }; + } + + // Build the request body based on action + const attrs: Record = {}; + const relationships: Record = {}; + + if (project_id) { + relationships.project = { + data: { type: "projects", id: project_id }, + }; + } + if (build_id) { + relationships.build = { + data: { type: "builds", id: build_id }, + }; + } + if (target_branch_filter) { + attrs["target-branch-filter"] = target_branch_filter; + } + if (snapshot_ids) { + relationships.snapshots = { + data: snapshot_ids + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + .map((id) => ({ type: "snapshots", id })), + }; + } + + const body = { + data: { + type: "branchline", + attributes: attrs, + ...(Object.keys(relationships).length > 0 ? { relationships } : {}), + }, + }; + + try { + await client.post(`/branchline/${action}`, body); + + const lines: string[] = []; + lines.push( + `## Branchline ${action.charAt(0).toUpperCase() + action.slice(1)} Complete`, + ); + lines.push(""); + if (build_id) lines.push(`**Build:** ${build_id}`); + if (project_id) lines.push(`**Project:** ${project_id}`); + if (target_branch_filter) + lines.push(`**Target Branch Filter:** ${target_branch_filter}`); + if (snapshot_ids) lines.push(`**Snapshot IDs:** ${snapshot_ids}`); + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Failed to ${action} branchline: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/percy-mcp/advanced/manage-variants.ts b/src/tools/percy-mcp/advanced/manage-variants.ts new file mode 100644 index 0000000..73a9fa1 --- /dev/null +++ b/src/tools/percy-mcp/advanced/manage-variants.ts @@ -0,0 +1,194 @@ +/** + * percy_manage_variants — List, create, or update A/B testing variants + * for Percy snapshot comparisons. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface ManageVariantsArgs { + comparison_id?: string; + snapshot_id?: string; + action?: string; + variant_id?: string; + name?: string; + state?: string; +} + +export async function percyManageVariants( + args: ManageVariantsArgs, + config: BrowserStackConfig, +): Promise { + const { + comparison_id, + snapshot_id, + action = "list", + variant_id, + name, + state, + } = args; + const client = new PercyClient(config); + + // ---- List ---- + if (action === "list") { + if (!comparison_id) { + return { + content: [ + { + type: "text", + text: "comparison_id is required for the 'list' action.", + }, + ], + isError: true, + }; + } + + const response = await client.get<{ + data: Record[] | null; + }>("/variants", { comparison_id }); + + const variants = Array.isArray(response?.data) ? response.data : []; + + if (variants.length === 0) { + return { + content: [ + { type: "text", text: "_No variants found for this comparison._" }, + ], + }; + } + + const lines: string[] = []; + lines.push(`## Variants (Comparison: ${comparison_id})`); + lines.push(""); + lines.push("| ID | Name | State |"); + lines.push("|----|------|-------|"); + + for (const variant of variants) { + const attrs = (variant as any).attributes ?? variant; + const vName = attrs.name ?? "Unnamed"; + const vState = attrs.state ?? "—"; + lines.push(`| ${variant.id ?? "?"} | ${vName} | ${vState} |`); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + // ---- Create ---- + if (action === "create") { + if (!snapshot_id) { + return { + content: [ + { + type: "text", + text: "snapshot_id is required for the 'create' action.", + }, + ], + isError: true, + }; + } + if (!name) { + return { + content: [ + { type: "text", text: "name is required for the 'create' action." }, + ], + isError: true, + }; + } + + const body = { + data: { + type: "variants", + attributes: { + name, + }, + relationships: { + snapshot: { + data: { type: "snapshots", id: snapshot_id }, + }, + }, + }, + }; + + try { + const result = (await client.post<{ + data: Record | null; + }>("/variants", body)) as { data: Record | null }; + + const id = (result?.data as any)?.id ?? "?"; + return { + content: [ + { + type: "text", + text: `Variant created (ID: ${id}, name: "${name}").`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { type: "text", text: `Failed to create variant: ${message}` }, + ], + isError: true, + }; + } + } + + // ---- Update ---- + if (action === "update") { + if (!variant_id) { + return { + content: [ + { + type: "text", + text: "variant_id is required for the 'update' action.", + }, + ], + isError: true, + }; + } + + const attrs: Record = {}; + if (name) attrs.name = name; + if (state) attrs.state = state; + + const body = { + data: { + type: "variants", + id: variant_id, + attributes: attrs, + }, + }; + + try { + await client.patch(`/variants/${variant_id}`, body); + return { + content: [ + { + type: "text", + text: `Variant ${variant_id} updated.`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { type: "text", text: `Failed to update variant: ${message}` }, + ], + isError: true, + }; + } + } + + return { + content: [ + { + type: "text", + text: `Invalid action "${action}". Valid actions: list, create, update`, + }, + ], + isError: true, + }; +} diff --git a/src/tools/percy-mcp/advanced/manage-visual-monitoring.ts b/src/tools/percy-mcp/advanced/manage-visual-monitoring.ts new file mode 100644 index 0000000..5d44cc0 --- /dev/null +++ b/src/tools/percy-mcp/advanced/manage-visual-monitoring.ts @@ -0,0 +1,206 @@ +/** + * percy_manage_visual_monitoring — Create, update, or list Visual Monitoring projects + * with URL lists, cron schedules, and auth configuration. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface ManageVisualMonitoringArgs { + org_id?: string; + project_id?: string; + action?: string; + urls?: string; + cron?: string; + schedule?: boolean; +} + +export async function percyManageVisualMonitoring( + args: ManageVisualMonitoringArgs, + config: BrowserStackConfig, +): Promise { + const { org_id, project_id, action = "list", urls, cron, schedule } = args; + const client = new PercyClient(config); + + // ---- List ---- + if (action === "list") { + if (!org_id) { + return { + content: [ + { type: "text", text: "org_id is required for the 'list' action." }, + ], + isError: true, + }; + } + + const response = await client.get<{ + data: Record[] | null; + }>(`/organizations/${org_id}/visual_monitoring_projects`); + + const projects = Array.isArray(response?.data) ? response.data : []; + + if (projects.length === 0) { + return { + content: [ + { + type: "text", + text: "_No Visual Monitoring projects found._", + }, + ], + }; + } + + const lines: string[] = []; + lines.push(`## Visual Monitoring Projects (Org: ${org_id})`); + lines.push(""); + lines.push("| ID | Name | URLs | Schedule | Status |"); + lines.push("|----|------|------|----------|--------|"); + + for (const project of projects) { + const attrs = (project as any).attributes ?? project; + const name = attrs.name ?? "Unnamed"; + const urlCount = Array.isArray(attrs.urls) + ? attrs.urls.length + : (attrs["url-count"] ?? "?"); + const cronSchedule = attrs.cron ?? attrs["cron-schedule"] ?? "—"; + const status = attrs.enabled ?? attrs.status ?? "—"; + lines.push( + `| ${project.id ?? "?"} | ${name} | ${urlCount} URLs | ${cronSchedule} | ${status} |`, + ); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + // ---- Create ---- + if (action === "create") { + if (!org_id) { + return { + content: [ + { type: "text", text: "org_id is required for the 'create' action." }, + ], + isError: true, + }; + } + + const urlArray = urls + ? urls + .split(",") + .map((u) => u.trim()) + .filter(Boolean) + : []; + + const attrs: Record = {}; + if (urlArray.length > 0) attrs.urls = urlArray; + if (cron) attrs.cron = cron; + if (schedule !== undefined) attrs.enabled = schedule; + + const body = { + data: { + type: "visual-monitoring-projects", + attributes: attrs, + relationships: { + organization: { + data: { type: "organizations", id: org_id }, + }, + }, + }, + }; + + try { + const result = (await client.post<{ + data: Record | null; + }>(`/organizations/${org_id}/visual_monitoring_projects`, body)) as { + data: Record | null; + }; + + const id = (result?.data as any)?.id ?? "?"; + return { + content: [ + { + type: "text", + text: `## Visual Monitoring Project Created\n\n**ID:** ${id}\n**URLs:** ${urlArray.length}\n**Cron:** ${cron ?? "not set"}\n**Enabled:** ${schedule ?? "default"}`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Failed to create Visual Monitoring project: ${message}`, + }, + ], + isError: true, + }; + } + } + + // ---- Update ---- + if (action === "update") { + if (!project_id) { + return { + content: [ + { + type: "text", + text: "project_id is required for the 'update' action.", + }, + ], + isError: true, + }; + } + + const attrs: Record = {}; + if (urls) { + attrs.urls = urls + .split(",") + .map((u) => u.trim()) + .filter(Boolean); + } + if (cron) attrs.cron = cron; + if (schedule !== undefined) attrs.enabled = schedule; + + const body = { + data: { + type: "visual-monitoring-projects", + id: project_id, + attributes: attrs, + }, + }; + + try { + await client.patch(`/visual_monitoring_projects/${project_id}`, body); + return { + content: [ + { + type: "text", + text: `Visual Monitoring project ${project_id} updated.`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Failed to update Visual Monitoring project: ${message}`, + }, + ], + isError: true, + }; + } + } + + return { + content: [ + { + type: "text", + text: `Invalid action "${action}". Valid actions: list, create, update`, + }, + ], + isError: true, + }; +} diff --git a/src/tools/percy-mcp/auth/auth-status.ts b/src/tools/percy-mcp/auth/auth-status.ts new file mode 100644 index 0000000..8434183 --- /dev/null +++ b/src/tools/percy-mcp/auth/auth-status.ts @@ -0,0 +1,148 @@ +import { getPercyApiBaseUrl, maskToken } from "../../../lib/percy-api/auth.js"; +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { getBrowserStackAuth } from "../../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyAuthStatus( + _args: Record, + config: BrowserStackConfig, +): Promise { + const baseUrl = getPercyApiBaseUrl(); + let output = `## Percy Auth Status\n\n`; + output += `**API URL:** ${baseUrl}\n\n`; + + const percyToken = process.env.PERCY_TOKEN; + const orgToken = process.env.PERCY_ORG_TOKEN; + const hasBstackCreds = !!( + config["browserstack-username"] && config["browserstack-access-key"] + ); + + // Token table + output += `### Token Configuration\n\n`; + output += `| Token | Status | Value |\n`; + output += `|-------|--------|-------|\n`; + output += `| PERCY_TOKEN | ${percyToken ? "Set" : "Not set"} | ${percyToken ? maskToken(percyToken) : "—"} |\n`; + output += `| PERCY_ORG_TOKEN | ${orgToken ? "Set" : "Not set"} | ${orgToken ? maskToken(orgToken) : "—"} |\n`; + output += `| BrowserStack Credentials | ${hasBstackCreds ? "Set" : "Not set"} | ${hasBstackCreds ? config["browserstack-username"] : "—"} |\n`; + output += "\n"; + + // Detect token type from prefix + if (percyToken) { + const hasPrefix = percyToken.includes("_"); + const prefix = hasPrefix ? percyToken.split("_")[0] : null; + const tokenTypes: Record = { + web: "Web project (full access — can read and write)", + auto: "Automate project (full access)", + app: "App project (full access)", + ss: "Generic/BYOS project", + vmw: "Visual Monitoring project", + }; + if (prefix && tokenTypes[prefix]) { + output += `**Token type:** ${prefix} — ${tokenTypes[prefix]}\n\n`; + } else if (!hasPrefix) { + output += `**Token type:** CI/write-only — can create builds but may not read them\n`; + output += ` Tip: Use \`percy_create_project\` to get a full-access \`web_*\` token\n\n`; + } else { + output += `**Token type:** ${prefix} — custom\n\n`; + } + } + + // Validation + output += `### Validation\n\n`; + + // 1. Try Percy API with token + if (percyToken) { + try { + const client = new PercyClient(config, { scope: "project" }); + const builds = await client.get("/builds", { + "page[limit]": "1", + }); + const buildList = Array.isArray(builds) ? builds : []; + + if (buildList.length > 0) { + const proj = + buildList[0]?.project?.name || + buildList[0]?.project?.slug || + "unknown"; + output += `**Percy API (read):** ✓ Valid — project "${proj}"\n`; + output += `**Latest build:** #${buildList[0]?.buildNumber || buildList[0]?.id} (${buildList[0]?.state || "unknown"})\n`; + } else { + output += `**Percy API (read):** ✓ Valid — no builds yet\n`; + } + } catch { + // Read failed — token might be write-only (CI token) + output += `**Percy API (read):** ✗ No read access (this is normal for CI/write-only tokens)\n`; + } + } + + // 2. Try BrowserStack API (project creation / token fetch) + if (hasBstackCreds) { + try { + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); + const response = await fetch( + "https://api.browserstack.com/api/app_percy/get_project_token?name=__mcp_auth_check__", + { + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/json", + }, + }, + ); + if (response.ok) { + output += `**BrowserStack API:** ✓ Valid — can create projects and get tokens\n`; + } else { + output += `**BrowserStack API:** ✗ Failed (${response.status})\n`; + } + } catch (e: any) { + output += `**BrowserStack API:** ✗ Error — ${e.message}\n`; + } + } + + // 3. Org token check + if (orgToken) { + try { + const client = new PercyClient(config, { scope: "org" }); + await client.get("/projects", { "page[limit]": "1" }); + output += `**Org scope:** ✓ Valid\n`; + } catch (e: any) { + output += `**Org scope:** ✗ Failed — ${e.message}\n`; + } + } + + output += "\n"; + + // Capabilities summary + output += `### What You Can Do\n\n`; + + if (hasBstackCreds) { + output += `✓ **Create projects** — \`percy_create_project\`\n`; + output += `✓ **Create builds with snapshots** — \`percy_create_percy_build\`\n`; + } + + if (percyToken) { + const hasPrefix = percyToken.includes("_"); + const prefix = hasPrefix ? percyToken.split("_")[0] : null; + if (prefix === "web" || prefix === "auto" || prefix === "app") { + output += `✓ **Read builds, snapshots, comparisons** — all read tools\n`; + output += `✓ **Approve/reject builds** — \`percy_approve_build\`\n`; + output += `✓ **AI analysis, RCA, summaries** — all intelligence tools\n`; + output += `✓ **PR visual report** — \`percy_pr_visual_report\`\n`; + } else { + output += `⚠ **Limited read access** — this token can create builds but may not read them\n`; + output += ` Tip: Run \`percy_create_project\` to get a full-access \`web_*\` token\n`; + } + } else if (hasBstackCreds) { + output += `⚠ **No PERCY_TOKEN set** — read operations will use BrowserStack fallback\n`; + output += ` Tip: Run \`percy_create_project\` to get a project token\n`; + } + + if (!percyToken && !orgToken && !hasBstackCreds) { + output += `### Setup Required\n\n`; + output += `No credentials configured. Run:\n`; + output += `\`\`\`bash\ncd mcp-server && ./percy-config/setup.sh\n\`\`\`\n`; + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/core/approve-build.ts b/src/tools/percy-mcp/core/approve-build.ts new file mode 100644 index 0000000..f2d7c90 --- /dev/null +++ b/src/tools/percy-mcp/core/approve-build.ts @@ -0,0 +1,122 @@ +/** + * Percy build approval/rejection tool handler. + * + * Sends a review action (approve, request_changes, unapprove, reject) + * to the Percy Reviews API using JSON:API format. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const VALID_ACTIONS = [ + "approve", + "request_changes", + "unapprove", + "reject", +] as const; +type ReviewAction = (typeof VALID_ACTIONS)[number]; + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +export async function percyApproveBuild( + args: { + build_id: string; + action: string; + snapshot_ids?: string; + reason?: string; + }, + config: BrowserStackConfig, +): Promise { + const { build_id, action, snapshot_ids, reason } = args; + + // Validate action + if (!VALID_ACTIONS.includes(action as ReviewAction)) { + return { + content: [ + { + type: "text", + text: `Invalid action "${action}". Valid actions: ${VALID_ACTIONS.join(", ")}`, + }, + ], + isError: true, + }; + } + + // request_changes requires snapshot_ids (snapshot-level action) + if (action === "request_changes" && !snapshot_ids) { + return { + content: [ + { + type: "text", + text: "request_changes requires snapshot_ids. This action works at snapshot level only.", + }, + ], + isError: true, + }; + } + + // Build JSON:API request body + const body: Record = { + data: { + type: "reviews", + attributes: { + action, + ...(reason ? { reason } : {}), + }, + relationships: { + build: { + data: { type: "builds", id: build_id }, + }, + ...(snapshot_ids + ? { + snapshots: { + data: snapshot_ids + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + .map((id) => ({ type: "snapshots", id })), + }, + } + : {}), + }, + }, + }; + + try { + const client = new PercyClient(config); + const result = (await client.post("/reviews", body)) as Record< + string, + unknown + >; + + const reviewState = + result?.reviewState ?? result?.["review-state"] ?? action; + + return { + content: [ + { + type: "text", + text: `Build #${build_id} ${action} successful. Review state: ${reviewState}`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Failed to ${action} build #${build_id}: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/percy-mcp/core/get-build-items.ts b/src/tools/percy-mcp/core/get-build-items.ts new file mode 100644 index 0000000..1159168 --- /dev/null +++ b/src/tools/percy-mcp/core/get-build-items.ts @@ -0,0 +1,94 @@ +/** + * percy_get_build_items — List snapshots in a Percy build filtered by category. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface GetBuildItemsArgs { + build_id: string; + category?: string; + sort_by?: string; + limit?: number; +} + +function na(value: unknown): string { + if (value == null || value === "") return "N/A"; + return String(value); +} + +function pct(value: number | null | undefined): string { + if (value == null) return "N/A"; + return `${(value * 100).toFixed(1)}%`; +} + +export async function percyGetBuildItems( + args: GetBuildItemsArgs, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config, { scope: "project" }); + const limit = Math.min(args.limit ?? 20, 100); + + const params: Record = { + "filter[build-id]": args.build_id, + "page[limit]": String(limit), + }; + + if (args.category) { + params["filter[category]"] = args.category; + } + if (args.sort_by) { + params["sort"] = args.sort_by; + } + + const response = await client.get<{ + data: Record[] | null; + meta?: Record; + }>("/build-items", params); + + const items = Array.isArray(response.data) ? response.data : []; + + if (items.length === 0) { + const category = args.category ? ` in category "${args.category}"` : ""; + return { + content: [ + { + type: "text", + text: `_No snapshots found${category} for build ${args.build_id}._`, + }, + ], + }; + } + + const lines: string[] = []; + const category = args.category ? ` (${args.category})` : ""; + lines.push(`## Build Snapshots${category} — ${items.length} items`); + lines.push(""); + lines.push("| # | Snapshot Name | ID | Diff | AI Diff | Status |"); + lines.push("|---|---------------|----|----- |---------|--------|"); + + items.forEach((item: any, i: number) => { + const name = na(item.name ?? item.snapshotName); + const id = na(item.id ?? item.snapshotId); + const diff = pct(item.diffRatio); + const aiDiff = pct(item.aiDiffRatio); + const status = na(item.reviewState ?? item.state); + lines.push( + `| ${i + 1} | ${name} | ${id} | ${diff} | ${aiDiff} | ${status} |`, + ); + }); + + if (response.meta) { + const total = + (response.meta as any).totalEntries ?? (response.meta as any).total; + if (total != null && total > items.length) { + lines.push(""); + lines.push(`_Showing ${items.length} of ${total} snapshots._`); + } + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + }; +} diff --git a/src/tools/percy-mcp/core/get-build.ts b/src/tools/percy-mcp/core/get-build.ts new file mode 100644 index 0000000..db960d7 --- /dev/null +++ b/src/tools/percy-mcp/core/get-build.ts @@ -0,0 +1,32 @@ +/** + * percy_get_build — Get detailed Percy build information. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { formatBuild } from "../../../lib/percy-api/formatter.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface GetBuildArgs { + build_id: string; +} + +export async function percyGetBuild( + args: GetBuildArgs, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config, { scope: "project" }); + + const response = await client.get<{ + data: Record | null; + }>(`/builds/${args.build_id}`, { "include-metadata": "true" }, [ + "build-summary", + "browsers", + ]); + + const build = response.data; + + return { + content: [{ type: "text", text: formatBuild(build) }], + }; +} diff --git a/src/tools/percy-mcp/core/get-comparison.ts b/src/tools/percy-mcp/core/get-comparison.ts new file mode 100644 index 0000000..b38770b --- /dev/null +++ b/src/tools/percy-mcp/core/get-comparison.ts @@ -0,0 +1,82 @@ +/** + * percy_get_comparison — Get detailed Percy comparison data. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { formatComparison } from "../../../lib/percy-api/formatter.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface GetComparisonArgs { + comparison_id: string; + include_images?: boolean; +} + +export async function percyGetComparison( + args: GetComparisonArgs, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config, { scope: "project" }); + + const includes = [ + "head-screenshot.image", + "base-screenshot.image", + "diff-image", + "ai-diff-image", + "browser.browser-family", + "comparison-tag", + ]; + + const response = await client.get<{ + data: Record | null; + }>(`/comparisons/${args.comparison_id}`, undefined, includes); + + const comparison = response.data as any; + + if (!comparison) { + return { + content: [ + { + type: "text", + text: `_Comparison ${args.comparison_id} not found._`, + }, + ], + }; + } + + const contentParts: CallToolResult["content"] = []; + + // Always include the formatted text + contentParts.push({ + type: "text", + text: formatComparison(comparison, { includeRegions: true }), + }); + + // If include_images is requested, fetch and include image URLs as text + if (args.include_images) { + const imageLines: string[] = []; + imageLines.push(""); + imageLines.push("### Screenshot URLs"); + + const baseUrl = + comparison.baseScreenshot?.image?.url ?? comparison.baseScreenshot?.url; + const headUrl = + comparison.headScreenshot?.image?.url ?? comparison.headScreenshot?.url; + const diffUrl = comparison.diffImage?.url; + const aiDiffUrl = comparison.aiDiffImage?.url; + + if (baseUrl) imageLines.push(`- **Base:** ${baseUrl}`); + if (headUrl) imageLines.push(`- **Head:** ${headUrl}`); + if (diffUrl) imageLines.push(`- **Diff:** ${diffUrl}`); + if (aiDiffUrl) imageLines.push(`- **AI Diff:** ${aiDiffUrl}`); + + if (imageLines.length > 2) { + contentParts.push({ + type: "text", + text: imageLines.join("\n"), + }); + } + } + + return { content: contentParts }; +} diff --git a/src/tools/percy-mcp/core/get-snapshot.ts b/src/tools/percy-mcp/core/get-snapshot.ts new file mode 100644 index 0000000..59d2ce3 --- /dev/null +++ b/src/tools/percy-mcp/core/get-snapshot.ts @@ -0,0 +1,64 @@ +/** + * percy_get_snapshot — Get a Percy snapshot with all comparisons and screenshots. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { + formatSnapshot, + formatComparison, +} from "../../../lib/percy-api/formatter.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface GetSnapshotArgs { + snapshot_id: string; +} + +export async function percyGetSnapshot( + args: GetSnapshotArgs, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config, { scope: "project" }); + + const includes = [ + "comparisons.head-screenshot.image", + "comparisons.base-screenshot.lossy-image", + "comparisons.diff-image", + "comparisons.browser.browser-family", + "comparisons.comparison-tag", + ]; + + const response = await client.get<{ + data: Record | null; + }>(`/snapshots/${args.snapshot_id}`, undefined, includes); + + const snapshot = response.data as any; + + if (!snapshot) { + return { + content: [ + { type: "text", text: `_Snapshot ${args.snapshot_id} not found._` }, + ], + }; + } + + const comparisons = snapshot.comparisons ?? []; + + const lines: string[] = []; + lines.push(formatSnapshot(snapshot, comparisons)); + + if (comparisons.length > 0) { + lines.push(""); + lines.push("---"); + lines.push(""); + lines.push("### Comparison Details"); + for (const comparison of comparisons) { + lines.push(""); + lines.push(formatComparison(comparison, { includeRegions: true })); + } + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + }; +} diff --git a/src/tools/percy-mcp/core/list-builds.ts b/src/tools/percy-mcp/core/list-builds.ts new file mode 100644 index 0000000..7797d77 --- /dev/null +++ b/src/tools/percy-mcp/core/list-builds.ts @@ -0,0 +1,72 @@ +/** + * percy_list_builds — List Percy builds for a project with filtering. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { formatBuildStatus } from "../../../lib/percy-api/formatter.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface ListBuildsArgs { + project_id?: string; + branch?: string; + state?: string; + sha?: string; + limit?: number; +} + +export async function percyListBuilds( + args: ListBuildsArgs, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config, { scope: "project" }); + const limit = Math.min(args.limit ?? 10, 30); + + const params: Record = { + "page[limit]": String(limit), + }; + + if (args.branch) { + params["filter[branch]"] = args.branch; + } + if (args.state) { + params["filter[state]"] = args.state; + } + if (args.sha) { + params["filter[sha]"] = args.sha; + } + + const path = args.project_id + ? `/projects/${args.project_id}/builds` + : "/builds"; + + const response = await client.get<{ + data: Record[] | null; + meta?: Record; + }>(path, params); + + const builds = Array.isArray(response.data) ? response.data : []; + + if (builds.length === 0) { + return { + content: [ + { + type: "text", + text: "_No builds found matching the specified filters._", + }, + ], + }; + } + + const lines: string[] = []; + lines.push(`## Percy Builds (${builds.length})`); + lines.push(""); + + for (const build of builds) { + lines.push(`- ${formatBuildStatus(build)} (ID: ${build.id})`); + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + }; +} diff --git a/src/tools/percy-mcp/core/list-projects.ts b/src/tools/percy-mcp/core/list-projects.ts new file mode 100644 index 0000000..d174441 --- /dev/null +++ b/src/tools/percy-mcp/core/list-projects.ts @@ -0,0 +1,78 @@ +/** + * percy_list_projects — List Percy projects in an organization. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface ListProjectsArgs { + org_id?: string; + search?: string; + limit?: number; +} + +export async function percyListProjects( + args: ListProjectsArgs, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config, { scope: "auto" }); + const limit = Math.min(args.limit ?? 10, 50); + + const params: Record = { + "page[limit]": String(limit), + }; + + if (args.search) { + params["filter[name]"] = args.search; + } + + const path = args.org_id + ? `/organizations/${args.org_id}/projects` + : "/projects"; + + const response = await client.get<{ + data: Record[] | null; + meta?: Record; + }>(path, params); + + const projects = Array.isArray(response.data) ? response.data : []; + + if (projects.length === 0) { + return { + content: [ + { + type: "text", + text: "_No projects found._", + }, + ], + }; + } + + const lines: string[] = []; + lines.push(`## Percy Projects (${projects.length})`); + lines.push(""); + lines.push("| # | Name | ID | Type | Default Branch |"); + lines.push("|---|------|----|------|----------------|"); + + projects.forEach((project: any, i: number) => { + const name = project.name ?? "Unnamed"; + const id = project.id ?? "?"; + const type = project.type ?? "web"; + const branch = project.defaultBaseBranch ?? "main"; + lines.push(`| ${i + 1} | ${name} | ${id} | ${type} | ${branch} |`); + }); + + if (response.meta) { + const total = + (response.meta as any).totalPages ?? (response.meta as any).total; + if (total != null) { + lines.push(""); + lines.push(`_Showing ${projects.length} of ${total} projects._`); + } + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + }; +} diff --git a/src/tools/percy-mcp/creation/create-app-snapshot.ts b/src/tools/percy-mcp/creation/create-app-snapshot.ts new file mode 100644 index 0000000..6603338 --- /dev/null +++ b/src/tools/percy-mcp/creation/create-app-snapshot.ts @@ -0,0 +1,50 @@ +/** + * percy_create_app_snapshot — Create a snapshot for App Percy or BYOS builds. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface CreateAppSnapshotArgs { + build_id: string; + name: string; + test_case?: string; +} + +export async function percyCreateAppSnapshot( + args: CreateAppSnapshotArgs, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config, { scope: "auto" }); + + const attributes: Record = { + name: args.name, + }; + + if (args.test_case) { + attributes["test-case"] = args.test_case; + } + + const body = { + data: { + type: "snapshots", + attributes, + }, + }; + + const response = await client.post<{ + data: Record | null; + }>(`/builds/${args.build_id}/snapshots`, body); + + const id = response.data?.id ?? "unknown"; + + return { + content: [ + { + type: "text", + text: `App snapshot '${args.name}' created (ID: ${id}). Create comparisons with percy_create_comparison.`, + }, + ], + }; +} diff --git a/src/tools/percy-mcp/creation/create-build.ts b/src/tools/percy-mcp/creation/create-build.ts new file mode 100644 index 0000000..bb166f2 --- /dev/null +++ b/src/tools/percy-mcp/creation/create-build.ts @@ -0,0 +1,97 @@ +/** + * percy_create_build — Create a new Percy build for visual testing. + * + * Supports two modes: + * 1. With project_id: POST /projects/{project_id}/builds + * 2. Without project_id: POST /builds (uses PERCY_TOKEN project scope) + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface CreateBuildArgs { + project_id?: string; + branch: string; + commit_sha: string; + commit_message?: string; + pull_request_number?: string; + type?: string; +} + +export async function percyCreateBuild( + args: CreateBuildArgs, + config: BrowserStackConfig, +): Promise { + const { + project_id, + branch, + commit_sha, + commit_message, + pull_request_number, + type, + } = args; + + const body = { + data: { + type: "builds", + attributes: { + branch, + "commit-sha": commit_sha, + ...(commit_message ? { "commit-message": commit_message } : {}), + ...(pull_request_number + ? { "pull-request-number": pull_request_number } + : {}), + ...(type ? { type } : {}), + }, + relationships: { + resources: { + data: [], + }, + }, + }, + }; + + try { + const client = new PercyClient(config); + + // Use project-scoped endpoint if project_id given, otherwise token-scoped + const endpoint = project_id ? `/projects/${project_id}/builds` : "/builds"; + + const result = await client.post(endpoint, body); + + // Handle both raw JSON:API response and deserialized response + const buildData = result?.data || result; + const buildId = + buildData?.id ?? (typeof buildData === "object" ? "created" : "unknown"); + const buildNumber = + buildData?.buildNumber || buildData?.["build-number"] || ""; + const webUrl = buildData?.webUrl || buildData?.["web-url"] || ""; + + let output = `## Percy Build Created\n\n`; + output += `| Field | Value |\n`; + output += `|-------|-------|\n`; + output += `| **Build ID** | ${buildId} |\n`; + if (buildNumber) output += `| **Build Number** | ${buildNumber} |\n`; + output += `| **Branch** | ${branch} |\n`; + output += `| **Commit** | ${commit_sha} |\n`; + if (webUrl) output += `| **URL** | ${webUrl} |\n`; + output += `\n### Next Steps\n\n`; + output += `1. Create snapshots: \`percy_create_snapshot\` with build_id \`${buildId}\`\n`; + output += `2. Upload resources: \`percy_upload_resource\` for each missing resource\n`; + output += `3. Finalize: \`percy_finalize_build\` with build_id \`${buildId}\`\n`; + + return { content: [{ type: "text", text: output }] }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Failed to create build: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/percy-mcp/creation/create-comparison.ts b/src/tools/percy-mcp/creation/create-comparison.ts new file mode 100644 index 0000000..79493b1 --- /dev/null +++ b/src/tools/percy-mcp/creation/create-comparison.ts @@ -0,0 +1,121 @@ +/** + * percy_create_comparison — Create a comparison with device/browser tag and tile metadata. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface CreateComparisonArgs { + snapshot_id: string; + tag_name: string; + tag_width: number; + tag_height: number; + tag_os_name?: string; + tag_os_version?: string; + tag_browser_name?: string; + tag_orientation?: string; + tiles: string; +} + +interface TileInput { + sha: string; + "status-bar-height"?: number; + "nav-bar-height"?: number; +} + +export async function percyCreateComparison( + args: CreateComparisonArgs, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config, { scope: "auto" }); + + // Parse tiles JSON string + let tilesArray: TileInput[]; + try { + tilesArray = JSON.parse(args.tiles); + if (!Array.isArray(tilesArray)) { + return { + content: [ + { + type: "text", + text: "Error: 'tiles' must be a JSON array of tile objects.", + }, + ], + isError: true, + }; + } + } catch { + return { + content: [ + { + type: "text", + text: "Error: 'tiles' is not valid JSON. Expected a JSON array of tile objects.", + }, + ], + isError: true, + }; + } + + // Build tag attributes + const tagAttributes: Record = { + name: args.tag_name, + width: args.tag_width, + height: args.tag_height, + }; + + if (args.tag_os_name) tagAttributes["os-name"] = args.tag_os_name; + if (args.tag_os_version) tagAttributes["os-version"] = args.tag_os_version; + if (args.tag_browser_name) + tagAttributes["browser-name"] = args.tag_browser_name; + if (args.tag_orientation) tagAttributes["orientation"] = args.tag_orientation; + + // Build tiles data + const tilesData = tilesArray.map((tile) => { + const tileAttributes: Record = { + sha: tile.sha, + }; + if (tile["status-bar-height"] != null) { + tileAttributes["status-bar-height"] = tile["status-bar-height"]; + } + if (tile["nav-bar-height"] != null) { + tileAttributes["nav-bar-height"] = tile["nav-bar-height"]; + } + return { + type: "tiles", + attributes: tileAttributes, + }; + }); + + const body = { + data: { + type: "comparisons", + relationships: { + tag: { + data: { + type: "tag", + attributes: tagAttributes, + }, + }, + tiles: { + data: tilesData, + }, + }, + }, + }; + + const response = await client.post<{ + data: Record | null; + }>(`/snapshots/${args.snapshot_id}/comparisons`, body); + + const id = response.data?.id ?? "unknown"; + + return { + content: [ + { + type: "text", + text: `Comparison created (ID: ${id}). Upload tiles with percy_upload_tile.`, + }, + ], + }; +} diff --git a/src/tools/percy-mcp/creation/create-snapshot.ts b/src/tools/percy-mcp/creation/create-snapshot.ts new file mode 100644 index 0000000..defa4a9 --- /dev/null +++ b/src/tools/percy-mcp/creation/create-snapshot.ts @@ -0,0 +1,127 @@ +/** + * percy_create_snapshot — Create a snapshot in a Percy build with DOM resources. + * + * POST /builds/{build_id}/snapshots with JSON:API body. + * Returns snapshot ID and list of missing resources for upload. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface CreateSnapshotArgs { + build_id: string; + name: string; + widths?: string; + enable_javascript?: boolean; + resources?: string; +} + +export async function percyCreateSnapshot( + args: CreateSnapshotArgs, + config: BrowserStackConfig, +): Promise { + const { build_id, name, widths, enable_javascript, resources } = args; + + // Parse widths from comma-separated string to int array + const parsedWidths = widths + ? widths + .split(",") + .map((w) => parseInt(w.trim(), 10)) + .filter((w) => !isNaN(w)) + : undefined; + + // Parse resources from JSON string + let parsedResources: + | Array<{ id: string; "resource-url": string; "is-root": boolean }> + | undefined; + if (resources) { + try { + parsedResources = JSON.parse(resources); + } catch { + return { + content: [ + { + type: "text", + text: `Invalid resources JSON: could not parse the provided string.`, + }, + ], + isError: true, + }; + } + } + + const attributes: Record = { name }; + if (parsedWidths) { + attributes.widths = parsedWidths; + } + if (enable_javascript !== undefined) { + attributes["enable-javascript"] = enable_javascript; + } + + const body = { + data: { + type: "snapshots", + attributes, + relationships: { + ...(parsedResources + ? { + resources: { + data: parsedResources.map((r) => ({ + type: "resources", + id: r.id, + attributes: { + "resource-url": r["resource-url"], + "is-root": r["is-root"] ?? false, + }, + })), + }, + } + : {}), + }, + }, + }; + + try { + const client = new PercyClient(config); + const result = (await client.post( + `/builds/${build_id}/snapshots`, + body, + )) as { data: Record | null }; + + const snapshotId = result?.data?.id ?? "unknown"; + + // Extract missing resources from relationships + const missingResources = (result?.data as any)?.missingResources ?? []; + const missingCount = Array.isArray(missingResources) + ? missingResources.length + : 0; + const missingShas = Array.isArray(missingResources) + ? missingResources.map((r: any) => r.id ?? r).join(", ") + : ""; + + const lines = [ + `Snapshot '${name}' created (ID: ${snapshotId}). Missing resources: ${missingCount}.`, + ]; + + if (missingCount > 0) { + lines.push(`Upload them with percy_upload_resource.`); + lines.push(`Missing SHAs: ${missingShas}`); + } + + return { + content: [{ type: "text", text: lines.join(" ") }], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Failed to create snapshot '${name}' in build ${build_id}: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/percy-mcp/creation/finalize-build.ts b/src/tools/percy-mcp/creation/finalize-build.ts new file mode 100644 index 0000000..d7e04f3 --- /dev/null +++ b/src/tools/percy-mcp/creation/finalize-build.ts @@ -0,0 +1,45 @@ +/** + * percy_finalize_build — Finalize a Percy build after all snapshots are complete. + * + * POST /builds/{build_id}/finalize — triggers processing. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface FinalizeBuildArgs { + build_id: string; +} + +export async function percyFinalizeBuild( + args: FinalizeBuildArgs, + config: BrowserStackConfig, +): Promise { + const { build_id } = args; + + try { + const client = new PercyClient(config); + await client.post(`/builds/${build_id}/finalize`); + + return { + content: [ + { + type: "text", + text: `Build ${build_id} finalized. Processing will begin.`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Failed to finalize build ${build_id}: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/percy-mcp/creation/finalize-comparison.ts b/src/tools/percy-mcp/creation/finalize-comparison.ts new file mode 100644 index 0000000..d708053 --- /dev/null +++ b/src/tools/percy-mcp/creation/finalize-comparison.ts @@ -0,0 +1,29 @@ +/** + * percy_finalize_comparison — Finalize a Percy comparison after all tiles are uploaded. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface FinalizeComparisonArgs { + comparison_id: string; +} + +export async function percyFinalizeComparison( + args: FinalizeComparisonArgs, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config, { scope: "auto" }); + + await client.post(`/comparisons/${args.comparison_id}/finalize`); + + return { + content: [ + { + type: "text", + text: `Comparison ${args.comparison_id} finalized. Diff processing will begin.`, + }, + ], + }; +} diff --git a/src/tools/percy-mcp/creation/finalize-snapshot.ts b/src/tools/percy-mcp/creation/finalize-snapshot.ts new file mode 100644 index 0000000..7954c75 --- /dev/null +++ b/src/tools/percy-mcp/creation/finalize-snapshot.ts @@ -0,0 +1,45 @@ +/** + * percy_finalize_snapshot — Finalize a Percy snapshot after all resources are uploaded. + * + * POST /snapshots/{snapshot_id}/finalize — triggers rendering. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface FinalizeSnapshotArgs { + snapshot_id: string; +} + +export async function percyFinalizeSnapshot( + args: FinalizeSnapshotArgs, + config: BrowserStackConfig, +): Promise { + const { snapshot_id } = args; + + try { + const client = new PercyClient(config); + await client.post(`/snapshots/${snapshot_id}/finalize`); + + return { + content: [ + { + type: "text", + text: `Snapshot ${snapshot_id} finalized. Rendering will begin.`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Failed to finalize snapshot ${snapshot_id}: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/percy-mcp/creation/upload-resource.ts b/src/tools/percy-mcp/creation/upload-resource.ts new file mode 100644 index 0000000..1cae534 --- /dev/null +++ b/src/tools/percy-mcp/creation/upload-resource.ts @@ -0,0 +1,58 @@ +/** + * percy_upload_resource — Upload a resource to a Percy build. + * + * POST /builds/{build_id}/resources with JSON:API body. + * Percy API validates SHA matches content server-side. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface UploadResourceArgs { + build_id: string; + sha: string; + base64_content: string; +} + +export async function percyUploadResource( + args: UploadResourceArgs, + config: BrowserStackConfig, +): Promise { + const { build_id, sha, base64_content } = args; + + const body = { + data: { + type: "resources", + id: sha, + attributes: { + "base64-content": base64_content, + }, + }, + }; + + try { + const client = new PercyClient(config); + await client.post(`/builds/${build_id}/resources`, body); + + return { + content: [ + { + type: "text", + text: `Resource ${sha} uploaded successfully.`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Failed to upload resource ${sha} to build ${build_id}: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/percy-mcp/creation/upload-tile.ts b/src/tools/percy-mcp/creation/upload-tile.ts new file mode 100644 index 0000000..475719d --- /dev/null +++ b/src/tools/percy-mcp/creation/upload-tile.ts @@ -0,0 +1,72 @@ +/** + * percy_upload_tile — Upload a screenshot tile (PNG or JPEG) to a Percy comparison. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface UploadTileArgs { + comparison_id: string; + base64_content: string; +} + +// PNG magic bytes: 0x89 0x50 0x4E 0x47 +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47]; + +// JPEG magic bytes: 0xFF 0xD8 0xFF +const JPEG_MAGIC = [0xff, 0xd8, 0xff]; + +function isValidImage(base64: string): boolean { + try { + const buffer = Buffer.from(base64, "base64"); + if (buffer.length < 4) return false; + + const isPng = PNG_MAGIC.every((byte, i) => buffer[i] === byte); + const isJpeg = JPEG_MAGIC.every((byte, i) => buffer[i] === byte); + + return isPng || isJpeg; + } catch { + return false; + } +} + +export async function percyUploadTile( + args: UploadTileArgs, + config: BrowserStackConfig, +): Promise { + // Validate image format + if (!isValidImage(args.base64_content)) { + return { + content: [ + { + type: "text", + text: "Only PNG and JPEG images are supported", + }, + ], + isError: true, + }; + } + + const client = new PercyClient(config, { scope: "auto" }); + + const body = { + data: { + type: "tiles", + attributes: { + "base64-content": args.base64_content, + }, + }, + }; + + await client.post(`/comparisons/${args.comparison_id}/tiles`, body); + + return { + content: [ + { + type: "text", + text: `Tile uploaded to comparison ${args.comparison_id}.`, + }, + ], + }; +} diff --git a/src/tools/percy-mcp/diagnostics/analyze-logs-realtime.ts b/src/tools/percy-mcp/diagnostics/analyze-logs-realtime.ts new file mode 100644 index 0000000..c6accc9 --- /dev/null +++ b/src/tools/percy-mcp/diagnostics/analyze-logs-realtime.ts @@ -0,0 +1,81 @@ +/** + * percy_analyze_logs_realtime — Analyze raw log data in real-time. + * + * Accepts a JSON array of log entries, sends them to Percy's suggestion + * engine, and returns instant diagnostics with fix suggestions. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { formatSuggestions } from "../../../lib/percy-api/formatter.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface AnalyzeLogsRealtimeArgs { + logs: string; +} + +export async function percyAnalyzeLogsRealtime( + args: AnalyzeLogsRealtimeArgs, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config); + + let logEntries: unknown[]; + try { + logEntries = JSON.parse(args.logs); + if (!Array.isArray(logEntries)) { + return { + content: [ + { + type: "text", + text: "logs must be a JSON array of log entries.", + }, + ], + isError: true, + }; + } + } catch { + return { + content: [ + { + type: "text", + text: "Invalid JSON in logs parameter. Provide a JSON array of log entries.", + }, + ], + isError: true, + }; + } + + const body = { + data: { + logs: logEntries, + }, + }; + + try { + const result = await client.post("/suggestions/from_logs", body); + + if (!result || (Array.isArray(result) && result.length === 0)) { + return { + content: [ + { + type: "text", + text: "No issues detected in the provided logs.", + }, + ], + }; + } + + const suggestions = Array.isArray(result) ? result : [result]; + const output = + "## Real-Time Log Analysis\n\n" + formatSuggestions(suggestions); + + return { content: [{ type: "text", text: output }] }; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + return { + content: [{ type: "text", text: `Log analysis failed: ${message}` }], + isError: true, + }; + } +} diff --git a/src/tools/percy-mcp/diagnostics/get-build-logs.ts b/src/tools/percy-mcp/diagnostics/get-build-logs.ts new file mode 100644 index 0000000..881c6ba --- /dev/null +++ b/src/tools/percy-mcp/diagnostics/get-build-logs.ts @@ -0,0 +1,94 @@ +/** + * percy_get_build_logs — Download and filter Percy build logs. + * + * Retrieves logs for a build, optionally filtered by service (cli, renderer, + * jackproxy), reference scope, and log level. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface GetBuildLogsArgs { + build_id: string; + service?: string; + reference_type?: string; + reference_id?: string; + level?: string; +} + +export async function percyGetBuildLogs( + args: GetBuildLogsArgs, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config); + + const params: Record = { build_id: args.build_id }; + if (args.service) params.service_name = args.service; + if (args.reference_type && args.reference_id) { + params.reference_id = `${args.reference_type}_${args.reference_id}`; + } + + let data: unknown; + try { + data = await client.get("/logs", params); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + return { + content: [{ type: "text", text: `Failed to fetch logs: ${message}` }], + isError: true, + }; + } + + if (!data) { + return { + content: [{ type: "text", text: "No logs available for this build." }], + }; + } + + let output = `## Build Logs — #${args.build_id}\n\n`; + if (args.service) output += `**Service:** ${args.service}\n`; + if (args.level) output += `**Level filter:** ${args.level}\n`; + output += "\n"; + + // Parse log data — format depends on service + const record = data as Record; + const rendererLogs = record?.renderer as Record | undefined; + const rawLogs = Array.isArray(data) + ? data + : (record?.logs as unknown[]) || + (record?.clilogs as unknown[]) || + (rendererLogs?.logs as unknown[]) || + []; + + const logs = Array.isArray(rawLogs) ? rawLogs : []; + + if (logs.length > 0) { + const filtered = args.level + ? logs.filter((l: unknown) => { + const entry = l as Record; + return entry.level === args.level || entry.debug === args.level; + }) + : logs; + + output += "```\n"; + filtered.slice(0, 100).forEach((log: unknown) => { + const entry = log as Record; + const ts = entry.timestamp ? `[${entry.timestamp}] ` : ""; + const level = (entry.level || entry.debug || "") as string; + const msg = + (entry.message as string) || + (entry.msg as string) || + JSON.stringify(entry); + output += `${ts}${level ? level.toUpperCase() + " " : ""}${msg}\n`; + }); + if (filtered.length > 100) { + output += `\n... (${filtered.length - 100} more log entries)\n`; + } + output += "```\n"; + } else { + output += "No log entries found matching filters.\n"; + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/diagnostics/get-network-logs.ts b/src/tools/percy-mcp/diagnostics/get-network-logs.ts new file mode 100644 index 0000000..caadd75 --- /dev/null +++ b/src/tools/percy-mcp/diagnostics/get-network-logs.ts @@ -0,0 +1,31 @@ +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { formatNetworkLogs } from "../../../lib/percy-api/formatter.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyGetNetworkLogs( + args: { comparison_id: string }, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config); + + const data = await client.get("/network-logs", { + comparison_id: args.comparison_id, + }); + + if (!data || (Array.isArray(data) && data.length === 0)) { + return { + content: [ + { + type: "text", + text: "No network requests recorded for this comparison.", + }, + ], + }; + } + + const logs = Array.isArray(data) ? data : Object.values(data); + const output = formatNetworkLogs(logs); + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/diagnostics/get-suggestions.ts b/src/tools/percy-mcp/diagnostics/get-suggestions.ts new file mode 100644 index 0000000..fa875e6 --- /dev/null +++ b/src/tools/percy-mcp/diagnostics/get-suggestions.ts @@ -0,0 +1,33 @@ +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { formatSuggestions } from "../../../lib/percy-api/formatter.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyGetSuggestions( + args: { build_id: string; reference_type?: string; reference_id?: string }, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config); + + const params: Record = { build_id: args.build_id }; + if (args.reference_type) params.reference_type = args.reference_type; + if (args.reference_id) params.reference_id = args.reference_id; + + const data = await client.get("/suggestions", params); + + if (!data || (Array.isArray(data) && data.length === 0)) { + return { + content: [ + { + type: "text", + text: "No diagnostic suggestions available for this build.", + }, + ], + }; + } + + const suggestions = Array.isArray(data) ? data : [data]; + const output = formatSuggestions(suggestions); + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/index.ts b/src/tools/percy-mcp/index.ts new file mode 100644 index 0000000..e2e5a07 --- /dev/null +++ b/src/tools/percy-mcp/index.ts @@ -0,0 +1,1668 @@ +/** + * Percy MCP tools — CRUD-organized tools for Percy visual testing. + * + * Registers 41 tools organized by CRUD action: + * + * === CREATE (6) === + * percy_create_project, percy_create_percy_build, percy_create_build, + * percy_create_snapshot, percy_create_app_snapshot, percy_create_comparison + * + * === READ (17) === + * percy_list_projects, percy_list_builds, percy_get_build, + * percy_get_build_items, percy_get_snapshot, percy_get_comparison, + * percy_get_ai_analysis, percy_get_build_summary, percy_get_ai_quota, + * percy_get_rca, percy_get_suggestions, percy_get_network_logs, + * percy_get_build_logs, percy_get_usage_stats, percy_auth_status + * + * === UPDATE (12) === + * percy_approve_build, percy_manage_project_settings, + * percy_manage_browser_targets, percy_manage_tokens, + * percy_manage_webhooks, percy_manage_ignored_regions, + * percy_manage_comments, percy_manage_variants, + * percy_manage_visual_monitoring, percy_trigger_ai_recompute, + * percy_suggest_prompt, percy_branchline_operations + * + * === FINALIZE / UPLOAD (6) === + * percy_finalize_build, percy_finalize_snapshot, percy_finalize_comparison, + * percy_upload_resource, percy_upload_tile, percy_analyze_logs_realtime + * + * === WORKFLOWS (4) === + * percy_pr_visual_report, percy_auto_triage, + * percy_debug_failed_build, percy_diff_explain + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { BrowserStackConfig } from "../../lib/types.js"; +import { handleMCPError } from "../../lib/utils.js"; +import { trackMCP } from "../../index.js"; +import { z } from "zod"; + +import { percyListProjects } from "./core/list-projects.js"; +import { percyListBuilds } from "./core/list-builds.js"; +import { percyGetBuild } from "./core/get-build.js"; +import { percyGetBuildItems } from "./core/get-build-items.js"; +import { percyGetSnapshot } from "./core/get-snapshot.js"; +import { percyGetComparison } from "./core/get-comparison.js"; + +import { percyCreateBuild } from "./creation/create-build.js"; +import { percyCreateSnapshot } from "./creation/create-snapshot.js"; +import { percyUploadResource } from "./creation/upload-resource.js"; +import { percyFinalizeSnapshot } from "./creation/finalize-snapshot.js"; +import { percyFinalizeBuild } from "./creation/finalize-build.js"; + +import { percyCreateAppSnapshot } from "./creation/create-app-snapshot.js"; +import { percyCreateComparison } from "./creation/create-comparison.js"; +import { percyUploadTile } from "./creation/upload-tile.js"; +import { percyFinalizeComparison } from "./creation/finalize-comparison.js"; + +import { percyApproveBuild } from "./core/approve-build.js"; + +import { percyGetAiAnalysis } from "./intelligence/get-ai-analysis.js"; +import { percyGetBuildSummary } from "./intelligence/get-build-summary.js"; +import { percyGetAiQuota } from "./intelligence/get-ai-quota.js"; +import { percyGetRca } from "./intelligence/get-rca.js"; +import { percyTriggerAiRecompute } from "./intelligence/trigger-ai-recompute.js"; +import { percySuggestPrompt } from "./intelligence/suggest-prompt.js"; + +import { percyGetSuggestions } from "./diagnostics/get-suggestions.js"; +import { percyGetNetworkLogs } from "./diagnostics/get-network-logs.js"; +import { percyGetBuildLogs } from "./diagnostics/get-build-logs.js"; +import { percyAnalyzeLogsRealtime } from "./diagnostics/analyze-logs-realtime.js"; + +import { percyPrVisualReport } from "./workflows/pr-visual-report.js"; +import { percyCreatePercyBuild } from "./workflows/create-percy-build.js"; +import { percyCloneBuild } from "./workflows/clone-build.js"; +import { percyAutoTriage } from "./workflows/auto-triage.js"; +import { percyDebugFailedBuild } from "./workflows/debug-failed-build.js"; +import { percyDiffExplain } from "./workflows/diff-explain.js"; +import { percySnapshotUrls } from "./workflows/snapshot-urls.js"; +import { percyRunTests } from "./workflows/run-tests.js"; + +import { percyAuthStatus } from "./auth/auth-status.js"; + +import { percyCreateProject } from "./management/create-project.js"; +import { percyManageProjectSettings } from "./management/manage-project-settings.js"; +import { percyManageBrowserTargets } from "./management/manage-browser-targets.js"; +import { percyManageTokens } from "./management/manage-tokens.js"; +import { percyManageWebhooks } from "./management/manage-webhooks.js"; +import { percyManageIgnoredRegions } from "./management/manage-ignored-regions.js"; +import { percyManageComments } from "./management/manage-comments.js"; +import { percyGetUsageStats } from "./management/get-usage-stats.js"; + +import { percyManageVisualMonitoring } from "./advanced/manage-visual-monitoring.js"; +import { percyBranchlineOperations } from "./advanced/branchline-operations.js"; +import { percyManageVariants } from "./advanced/manage-variants.js"; + +export function registerPercyMcpTools( + server: McpServer, + config: BrowserStackConfig, +) { + const tools: Record = {}; + + // ========================================================================= + // === CREATE === + // ========================================================================= + + // ------------------------------------------------------------------------- + // percy_create_project + // ------------------------------------------------------------------------- + tools.percy_create_project = server.tool( + "percy_create_project", + "Create a new Percy project. Auto-creates if doesn't exist, returns project token.", + { + name: z.string().describe("Project name (e.g. 'my-web-app')"), + type: z + .enum(["web", "automate"]) + .optional() + .describe( + "Project type: 'web' for Percy Web, 'automate' for Percy Automate (default: auto-detect)", + ), + }, + async (args) => { + try { + trackMCP( + "percy_create_project", + server.server.getClientVersion()!, + config, + ); + return await percyCreateProject(args, config); + } catch (error) { + return handleMCPError("percy_create_project", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_create_percy_build + // ------------------------------------------------------------------------- + tools.percy_create_percy_build = server.tool( + "percy_create_percy_build", + "Create a complete Percy build with snapshots. Supports URL scanning, screenshot upload, test wrapping, or build cloning. The primary build creation tool.", + { + project_name: z + .string() + .describe("Percy project name (auto-creates if doesn't exist)"), + urls: z + .string() + .optional() + .describe( + "Comma-separated URLs to snapshot, e.g. 'http://localhost:3000,http://localhost:3000/about'", + ), + screenshots_dir: z + .string() + .optional() + .describe("Directory path containing PNG/JPG screenshots to upload"), + screenshot_files: z + .string() + .optional() + .describe("Comma-separated file paths to PNG/JPG screenshots"), + test_command: z + .string() + .optional() + .describe( + "Test command to wrap with Percy, e.g. 'npx cypress run' or 'npm test'", + ), + clone_build_id: z + .string() + .optional() + .describe("Build ID to clone snapshots from"), + branch: z + .string() + .optional() + .describe("Git branch (auto-detected from git if not provided)"), + commit_sha: z + .string() + .optional() + .describe("Git commit SHA (auto-detected from git if not provided)"), + widths: z + .string() + .optional() + .describe( + "Comma-separated viewport widths, e.g. '375,768,1280' (default: 375,1280)", + ), + snapshot_names: z + .string() + .optional() + .describe( + "Comma-separated snapshot names (for screenshots — defaults to filename)", + ), + test_case: z + .string() + .optional() + .describe("Test case name to associate snapshots with"), + type: z + .enum(["web", "app", "automate"]) + .optional() + .describe("Project type (default: web)"), + }, + async (args) => { + try { + trackMCP( + "percy_create_percy_build", + server.server.getClientVersion()!, + config, + ); + return await percyCreatePercyBuild(args, config); + } catch (error) { + return handleMCPError( + "percy_create_percy_build", + server, + config, + error, + ); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_create_build + // ------------------------------------------------------------------------- + tools.percy_create_build = server.tool( + "percy_create_build", + "Create an empty Percy build (low-level). Use percy_create_percy_build for full automation.", + { + project_id: z + .string() + .optional() + .describe( + "Percy project ID (optional if PERCY_TOKEN is project-scoped)", + ), + branch: z.string().describe("Git branch name"), + commit_sha: z.string().describe("Git commit SHA"), + commit_message: z.string().optional().describe("Git commit message"), + pull_request_number: z + .string() + .optional() + .describe("Pull request number"), + type: z + .string() + .optional() + .describe("Project type: web, app, automate, generic"), + }, + async (args) => { + try { + trackMCP( + "percy_create_build", + server.server.getClientVersion()!, + config, + ); + return await percyCreateBuild(args, config); + } catch (error) { + return handleMCPError("percy_create_build", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_create_snapshot + // ------------------------------------------------------------------------- + tools.percy_create_snapshot = server.tool( + "percy_create_snapshot", + "Create a snapshot in a Percy build with DOM resources (low-level web flow).", + { + build_id: z.string().describe("Percy build ID"), + name: z.string().describe("Snapshot name"), + widths: z + .string() + .optional() + .describe("Comma-separated viewport widths, e.g. '375,768,1280'"), + enable_javascript: z.boolean().optional(), + resources: z + .string() + .optional() + .describe( + 'JSON array of resources: [{"id":"sha","resource-url":"url","is-root":true}]', + ), + }, + async (args) => { + try { + trackMCP( + "percy_create_snapshot", + server.server.getClientVersion()!, + config, + ); + return await percyCreateSnapshot(args, config); + } catch (error) { + return handleMCPError("percy_create_snapshot", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_create_app_snapshot + // ------------------------------------------------------------------------- + tools.percy_create_app_snapshot = server.tool( + "percy_create_app_snapshot", + "Create a snapshot for App Percy / screenshot builds (low-level app flow).", + { + build_id: z.string().describe("Percy build ID"), + name: z.string().describe("Snapshot name"), + test_case: z.string().optional().describe("Test case name"), + }, + async (args) => { + try { + trackMCP( + "percy_create_app_snapshot", + server.server.getClientVersion()!, + config, + ); + return await percyCreateAppSnapshot(args, config); + } catch (error) { + return handleMCPError( + "percy_create_app_snapshot", + server, + config, + error, + ); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_create_comparison + // ------------------------------------------------------------------------- + tools.percy_create_comparison = server.tool( + "percy_create_comparison", + "Create a comparison with device tag and tiles for screenshot builds (low-level).", + { + snapshot_id: z.string().describe("Percy snapshot ID"), + tag_name: z.string().describe("Device/browser name, e.g. 'iPhone 13'"), + tag_width: z.number().describe("Tag width in pixels"), + tag_height: z.number().describe("Tag height in pixels"), + tag_os_name: z.string().optional().describe("OS name, e.g. 'iOS'"), + tag_os_version: z.string().optional().describe("OS version, e.g. '16.0'"), + tag_browser_name: z + .string() + .optional() + .describe("Browser name, e.g. 'Safari'"), + tag_orientation: z.string().optional().describe("portrait or landscape"), + tiles: z + .string() + .describe( + "JSON array of tiles: [{sha, status-bar-height?, nav-bar-height?}]", + ), + }, + async (args) => { + try { + trackMCP( + "percy_create_comparison", + server.server.getClientVersion()!, + config, + ); + return await percyCreateComparison(args, config); + } catch (error) { + return handleMCPError("percy_create_comparison", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_clone_build — Cross-project build cloning + // ------------------------------------------------------------------------- + tools.percy_clone_build = server.tool( + "percy_clone_build", + "Clone snapshots from one Percy build to another project. Downloads screenshots from source and re-uploads to target. Works across different projects and orgs. Handles the entire flow: read source → create target build → clone each snapshot with comparisons → finalize.", + { + source_build_id: z + .string() + .describe("Build ID to clone FROM (the source)"), + target_project_name: z + .string() + .describe( + "Project name to clone INTO. Use the EXACT project name from Percy dashboard. If project doesn't exist, a new one is created.", + ), + target_token: z + .string() + .optional() + .describe( + "Percy token for the TARGET project. Use this to clone into an existing project without creating a new one. Get it from project settings.", + ), + source_token: z + .string() + .optional() + .describe( + "Percy token for reading the source build (if different from PERCY_TOKEN). Must be a full-access web_* or auto_* token.", + ), + branch: z + .string() + .optional() + .describe("Branch for the new build (auto-detected from git)"), + commit_sha: z + .string() + .optional() + .describe("Commit SHA for the new build (auto-detected from git)"), + }, + async (args) => { + try { + trackMCP( + "percy_clone_build", + server.server.getClientVersion()!, + config, + ); + return await percyCloneBuild(args, config); + } catch (error) { + return handleMCPError("percy_clone_build", server, config, error); + } + }, + ); + + // ========================================================================= + // === READ === + // ========================================================================= + + // ------------------------------------------------------------------------- + // percy_list_projects + // ------------------------------------------------------------------------- + tools.percy_list_projects = server.tool( + "percy_list_projects", + "List all Percy projects in your organization.", + { + org_id: z + .string() + .optional() + .describe("Percy organization ID. If not provided, uses token scope."), + search: z + .string() + .optional() + .describe("Filter projects by name (substring match)"), + limit: z.number().optional().describe("Max results (default 10, max 50)"), + }, + async (args) => { + try { + trackMCP( + "percy_list_projects", + server.server.getClientVersion()!, + config, + ); + return await percyListProjects(args, config); + } catch (error) { + return handleMCPError("percy_list_projects", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_list_builds + // ------------------------------------------------------------------------- + tools.percy_list_builds = server.tool( + "percy_list_builds", + "List Percy builds with filters (branch, state, SHA, tags).", + { + project_id: z + .string() + .optional() + .describe("Percy project ID. If not provided, uses PERCY_TOKEN scope."), + branch: z.string().optional().describe("Filter by branch name"), + state: z + .string() + .optional() + .describe("Filter by state: pending, processing, finished, failed"), + sha: z.string().optional().describe("Filter by commit SHA"), + limit: z.number().optional().describe("Max results (default 10, max 30)"), + }, + async (args) => { + try { + trackMCP( + "percy_list_builds", + server.server.getClientVersion()!, + config, + ); + return await percyListBuilds(args, config); + } catch (error) { + return handleMCPError("percy_list_builds", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_get_build + // ------------------------------------------------------------------------- + tools.percy_get_build = server.tool( + "percy_get_build", + "Get full Percy build details: state, snapshots, AI metrics, summary.", + { + build_id: z.string().describe("Percy build ID"), + }, + async (args) => { + try { + trackMCP("percy_get_build", server.server.getClientVersion()!, config); + return await percyGetBuild(args, config); + } catch (error) { + return handleMCPError("percy_get_build", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_get_build_items + // ------------------------------------------------------------------------- + tools.percy_get_build_items = server.tool( + "percy_get_build_items", + "List snapshots in a build by category: changed, new, removed, unchanged, failed.", + { + build_id: z.string().describe("Percy build ID"), + category: z + .string() + .optional() + .describe("Filter category: changed, new, removed, unchanged, failed"), + sort_by: z + .string() + .optional() + .describe("Sort field (e.g. diff-ratio, name)"), + limit: z + .number() + .optional() + .describe("Max results (default 20, max 100)"), + }, + async (args) => { + try { + trackMCP( + "percy_get_build_items", + server.server.getClientVersion()!, + config, + ); + return await percyGetBuildItems(args, config); + } catch (error) { + return handleMCPError("percy_get_build_items", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_get_snapshot + // ------------------------------------------------------------------------- + tools.percy_get_snapshot = server.tool( + "percy_get_snapshot", + "Get snapshot with all comparisons, screenshots, and diff data.", + { + snapshot_id: z.string().describe("Percy snapshot ID"), + }, + async (args) => { + try { + trackMCP( + "percy_get_snapshot", + server.server.getClientVersion()!, + config, + ); + return await percyGetSnapshot(args, config); + } catch (error) { + return handleMCPError("percy_get_snapshot", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_get_comparison + // ------------------------------------------------------------------------- + tools.percy_get_comparison = server.tool( + "percy_get_comparison", + "Get comparison details: diff ratio, AI analysis, screenshot URLs.", + { + comparison_id: z.string().describe("Percy comparison ID"), + include_images: z + .boolean() + .optional() + .describe("Include screenshot image URLs in response (default false)"), + }, + async (args) => { + try { + trackMCP( + "percy_get_comparison", + server.server.getClientVersion()!, + config, + ); + return await percyGetComparison(args, config); + } catch (error) { + return handleMCPError("percy_get_comparison", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_get_ai_analysis + // ------------------------------------------------------------------------- + tools.percy_get_ai_analysis = server.tool( + "percy_get_ai_analysis", + "Get AI visual diff analysis: change types, bug classifications, diff reduction.", + { + comparison_id: z + .string() + .optional() + .describe("Get AI analysis for a single comparison"), + build_id: z + .string() + .optional() + .describe("Get aggregated AI analysis for an entire build"), + }, + async (args) => { + try { + trackMCP( + "percy_get_ai_analysis", + server.server.getClientVersion()!, + config, + ); + return await percyGetAiAnalysis(args, config); + } catch (error) { + return handleMCPError("percy_get_ai_analysis", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_get_build_summary + // ------------------------------------------------------------------------- + tools.percy_get_build_summary = server.tool( + "percy_get_build_summary", + "Get AI-generated natural language summary of all visual changes.", + { + build_id: z.string().describe("Percy build ID"), + }, + async (args) => { + try { + trackMCP( + "percy_get_build_summary", + server.server.getClientVersion()!, + config, + ); + return await percyGetBuildSummary(args, config); + } catch (error) { + return handleMCPError("percy_get_build_summary", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_get_ai_quota + // ------------------------------------------------------------------------- + tools.percy_get_ai_quota = server.tool( + "percy_get_ai_quota", + "Check AI regeneration quota: daily usage and limits.", + {}, + async () => { + try { + trackMCP( + "percy_get_ai_quota", + server.server.getClientVersion()!, + config, + ); + return await percyGetAiQuota({}, config); + } catch (error) { + return handleMCPError("percy_get_ai_quota", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_get_rca + // ------------------------------------------------------------------------- + tools.percy_get_rca = server.tool( + "percy_get_rca", + "Get Root Cause Analysis: maps visual diffs to DOM/CSS changes.", + { + comparison_id: z.string().describe("Percy comparison ID"), + trigger_if_missing: z + .boolean() + .optional() + .describe("Auto-trigger RCA if not yet run (default true)"), + }, + async (args) => { + try { + trackMCP("percy_get_rca", server.server.getClientVersion()!, config); + return await percyGetRca(args, config); + } catch (error) { + return handleMCPError("percy_get_rca", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_get_suggestions + // ------------------------------------------------------------------------- + tools.percy_get_suggestions = server.tool( + "percy_get_suggestions", + "Get build failure diagnostics: categorized issues with fix steps.", + { + build_id: z.string().describe("Percy build ID"), + reference_type: z + .string() + .optional() + .describe("Filter: build, snapshot, or comparison"), + reference_id: z + .string() + .optional() + .describe("Specific snapshot or comparison ID"), + }, + async (args) => { + try { + trackMCP( + "percy_get_suggestions", + server.server.getClientVersion()!, + config, + ); + return await percyGetSuggestions(args, config); + } catch (error) { + return handleMCPError("percy_get_suggestions", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_get_network_logs + // ------------------------------------------------------------------------- + tools.percy_get_network_logs = server.tool( + "percy_get_network_logs", + "Get network request logs: per-URL status comparison (base vs head).", + { + comparison_id: z.string().describe("Percy comparison ID"), + }, + async (args) => { + try { + trackMCP( + "percy_get_network_logs", + server.server.getClientVersion()!, + config, + ); + return await percyGetNetworkLogs(args, config); + } catch (error) { + return handleMCPError("percy_get_network_logs", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_get_build_logs + // ------------------------------------------------------------------------- + tools.percy_get_build_logs = server.tool( + "percy_get_build_logs", + "Get raw build logs (CLI, renderer, proxy) with level filtering.", + { + build_id: z.string().describe("Percy build ID"), + service: z + .string() + .optional() + .describe("Filter by service: cli, renderer, jackproxy"), + reference_type: z + .string() + .optional() + .describe("Reference scope: build, snapshot, comparison"), + reference_id: z + .string() + .optional() + .describe("Specific snapshot or comparison ID"), + level: z + .string() + .optional() + .describe("Filter by log level: error, warn, info, debug"), + }, + async (args) => { + try { + trackMCP( + "percy_get_build_logs", + server.server.getClientVersion()!, + config, + ); + return await percyGetBuildLogs(args, config); + } catch (error) { + return handleMCPError("percy_get_build_logs", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_get_usage_stats + // ------------------------------------------------------------------------- + tools.percy_get_usage_stats = server.tool( + "percy_get_usage_stats", + "Get organization usage: screenshot counts, quotas, AI comparisons.", + { + org_id: z.string().describe("Percy organization ID"), + product: z + .string() + .optional() + .describe("Filter by product type (e.g., 'percy', 'app_percy')"), + }, + async (args) => { + try { + trackMCP( + "percy_get_usage_stats", + server.server.getClientVersion()!, + config, + ); + return await percyGetUsageStats(args, config); + } catch (error) { + return handleMCPError("percy_get_usage_stats", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_auth_status + // ------------------------------------------------------------------------- + tools.percy_auth_status = server.tool( + "percy_auth_status", + "Check Percy auth: which tokens are set, validated, and their scope.", + {}, + async () => { + try { + trackMCP( + "percy_auth_status", + server.server.getClientVersion()!, + config, + ); + return await percyAuthStatus({}, config); + } catch (error) { + return handleMCPError("percy_auth_status", server, config, error); + } + }, + ); + + // ========================================================================= + // === UPDATE === + // ========================================================================= + + // ------------------------------------------------------------------------- + // percy_approve_build + // ------------------------------------------------------------------------- + tools.percy_approve_build = server.tool( + "percy_approve_build", + "Approve, reject, or request changes on a Percy build.", + { + build_id: z.string().describe("Percy build ID to review"), + action: z + .enum(["approve", "request_changes", "unapprove", "reject"]) + .describe("Review action"), + snapshot_ids: z + .string() + .optional() + .describe( + "Comma-separated snapshot IDs (required for request_changes)", + ), + reason: z + .string() + .optional() + .describe("Optional reason for the review action"), + }, + async (args) => { + try { + trackMCP( + "percy_approve_build", + server.server.getClientVersion()!, + config, + ); + return await percyApproveBuild(args, config); + } catch (error) { + return handleMCPError("percy_approve_build", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_manage_project_settings + // ------------------------------------------------------------------------- + tools.percy_manage_project_settings = server.tool( + "percy_manage_project_settings", + "Update Percy project settings: diff sensitivity, auto-approve, IntelliIgnore.", + { + project_id: z.string().describe("Percy project ID"), + settings: z + .string() + .optional() + .describe( + 'JSON string of attributes to update, e.g. \'{"diff-sensitivity":0.1,"auto-approve-branch-filter":"main"}\'', + ), + confirm_destructive: z + .boolean() + .optional() + .describe( + "Set to true to confirm high-risk changes (auto-approve/approval-required branch filters)", + ), + }, + async (args) => { + try { + trackMCP( + "percy_manage_project_settings", + server.server.getClientVersion()!, + config, + ); + return await percyManageProjectSettings(args, config); + } catch (error) { + return handleMCPError( + "percy_manage_project_settings", + server, + config, + error, + ); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_manage_browser_targets + // ------------------------------------------------------------------------- + tools.percy_manage_browser_targets = server.tool( + "percy_manage_browser_targets", + "Add or remove browser targets (Chrome, Firefox, Safari, Edge).", + { + project_id: z.string().describe("Percy project ID"), + action: z + .enum(["list", "add", "remove"]) + .optional() + .describe("Action to perform (default: list)"), + browser_family: z + .string() + .optional() + .describe( + "Browser family ID to add or project-browser-target ID to remove", + ), + }, + async (args) => { + try { + trackMCP( + "percy_manage_browser_targets", + server.server.getClientVersion()!, + config, + ); + return await percyManageBrowserTargets(args, config); + } catch (error) { + return handleMCPError( + "percy_manage_browser_targets", + server, + config, + error, + ); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_manage_tokens + // ------------------------------------------------------------------------- + tools.percy_manage_tokens = server.tool( + "percy_manage_tokens", + "View (masked) or rotate Percy project tokens.", + { + project_id: z.string().describe("Percy project ID"), + action: z + .enum(["list", "rotate"]) + .optional() + .describe("Action to perform (default: list)"), + role: z + .string() + .optional() + .describe("Token role for rotation (e.g., 'write', 'read')"), + }, + async (args) => { + try { + trackMCP( + "percy_manage_tokens", + server.server.getClientVersion()!, + config, + ); + return await percyManageTokens(args, config); + } catch (error) { + return handleMCPError("percy_manage_tokens", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_manage_webhooks + // ------------------------------------------------------------------------- + tools.percy_manage_webhooks = server.tool( + "percy_manage_webhooks", + "Create, update, or delete webhooks for build events.", + { + project_id: z.string().describe("Percy project ID"), + action: z + .enum(["list", "create", "update", "delete"]) + .optional() + .describe("Action to perform (default: list)"), + webhook_id: z + .string() + .optional() + .describe("Webhook ID (required for update/delete)"), + url: z.string().optional().describe("Webhook URL (required for create)"), + events: z + .string() + .optional() + .describe( + "Comma-separated event types, e.g. 'build:finished,build:failed'", + ), + description: z + .string() + .optional() + .describe("Human-readable webhook description"), + }, + async (args) => { + try { + trackMCP( + "percy_manage_webhooks", + server.server.getClientVersion()!, + config, + ); + return await percyManageWebhooks(args, config); + } catch (error) { + return handleMCPError("percy_manage_webhooks", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_manage_ignored_regions + // ------------------------------------------------------------------------- + tools.percy_manage_ignored_regions = server.tool( + "percy_manage_ignored_regions", + "Create, save, or delete ignored regions on comparisons.", + { + comparison_id: z + .string() + .optional() + .describe("Percy comparison ID (required for list/create)"), + action: z + .enum(["list", "create", "save", "delete"]) + .optional() + .describe("Action to perform (default: list)"), + region_id: z + .string() + .optional() + .describe("Region revision ID (required for delete)"), + type: z + .string() + .optional() + .describe("Region type: raw, xpath, css, full_page"), + coordinates: z + .string() + .optional() + .describe( + 'JSON bounding box for raw type: {"x":0,"y":0,"width":100,"height":100}', + ), + selector: z.string().optional().describe("XPath or CSS selector string"), + }, + async (args) => { + try { + trackMCP( + "percy_manage_ignored_regions", + server.server.getClientVersion()!, + config, + ); + return await percyManageIgnoredRegions(args, config); + } catch (error) { + return handleMCPError( + "percy_manage_ignored_regions", + server, + config, + error, + ); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_manage_comments + // ------------------------------------------------------------------------- + tools.percy_manage_comments = server.tool( + "percy_manage_comments", + "Create or close comment threads on snapshots.", + { + build_id: z + .string() + .optional() + .describe("Percy build ID (required for list)"), + snapshot_id: z + .string() + .optional() + .describe("Percy snapshot ID (required for create)"), + action: z + .enum(["list", "create", "close"]) + .optional() + .describe("Action to perform (default: list)"), + thread_id: z + .string() + .optional() + .describe("Comment thread ID (required for close)"), + body: z + .string() + .optional() + .describe("Comment body text (required for create)"), + }, + async (args) => { + try { + trackMCP( + "percy_manage_comments", + server.server.getClientVersion()!, + config, + ); + return await percyManageComments(args, config); + } catch (error) { + return handleMCPError("percy_manage_comments", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_manage_variants + // ------------------------------------------------------------------------- + tools.percy_manage_variants = server.tool( + "percy_manage_variants", + "Create or update A/B testing variants.", + { + comparison_id: z + .string() + .optional() + .describe("Percy comparison ID (required for list)"), + snapshot_id: z + .string() + .optional() + .describe("Percy snapshot ID (required for create)"), + action: z + .enum(["list", "create", "update"]) + .optional() + .describe("Action to perform (default: list)"), + variant_id: z + .string() + .optional() + .describe("Variant ID (required for update)"), + name: z + .string() + .optional() + .describe("Variant name (required for create)"), + state: z.string().optional().describe("Variant state (for update)"), + }, + async (args) => { + try { + trackMCP( + "percy_manage_variants", + server.server.getClientVersion()!, + config, + ); + return await percyManageVariants(args, config); + } catch (error) { + return handleMCPError("percy_manage_variants", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_manage_visual_monitoring + // ------------------------------------------------------------------------- + tools.percy_manage_visual_monitoring = server.tool( + "percy_manage_visual_monitoring", + "Create or update Visual Monitoring projects.", + { + org_id: z + .string() + .optional() + .describe("Percy organization ID (required for list/create)"), + project_id: z + .string() + .optional() + .describe("Visual Monitoring project ID (required for update)"), + action: z + .enum(["list", "create", "update"]) + .optional() + .describe("Action to perform (default: list)"), + urls: z + .string() + .optional() + .describe( + "Comma-separated URLs to monitor, e.g. 'https://example.com,https://example.com/about'", + ), + cron: z + .string() + .optional() + .describe( + "Cron expression for monitoring schedule, e.g. '0 */6 * * *'", + ), + schedule: z + .boolean() + .optional() + .describe("Enable or disable the monitoring schedule"), + }, + async (args) => { + try { + trackMCP( + "percy_manage_visual_monitoring", + server.server.getClientVersion()!, + config, + ); + return await percyManageVisualMonitoring(args, config); + } catch (error) { + return handleMCPError( + "percy_manage_visual_monitoring", + server, + config, + error, + ); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_trigger_ai_recompute + // ------------------------------------------------------------------------- + tools.percy_trigger_ai_recompute = server.tool( + "percy_trigger_ai_recompute", + "Re-run AI analysis with a custom prompt.", + { + build_id: z + .string() + .optional() + .describe("Percy build ID (for bulk recompute)"), + comparison_id: z + .string() + .optional() + .describe("Single comparison ID to recompute"), + prompt: z + .string() + .optional() + .describe( + "Custom prompt for AI (max 400 chars), e.g. 'Ignore font rendering differences'", + ), + mode: z + .enum(["ignore", "unignore"]) + .optional() + .describe( + "ignore = hide matching diffs, unignore = show matching diffs", + ), + }, + async (args) => { + try { + trackMCP( + "percy_trigger_ai_recompute", + server.server.getClientVersion()!, + config, + ); + return await percyTriggerAiRecompute(args, config); + } catch (error) { + return handleMCPError( + "percy_trigger_ai_recompute", + server, + config, + error, + ); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_suggest_prompt + // ------------------------------------------------------------------------- + tools.percy_suggest_prompt = server.tool( + "percy_suggest_prompt", + "Get AI-suggested prompt for specific diff regions.", + { + comparison_id: z.string().describe("Percy comparison ID"), + region_ids: z.string().describe("Comma-separated region IDs to analyze"), + ignore_change: z + .boolean() + .optional() + .describe( + "true = suggest ignore prompt, false = suggest show prompt (default true)", + ), + }, + async (args) => { + try { + trackMCP( + "percy_suggest_prompt", + server.server.getClientVersion()!, + config, + ); + return await percySuggestPrompt(args, config); + } catch (error) { + return handleMCPError("percy_suggest_prompt", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_branchline_operations + // ------------------------------------------------------------------------- + tools.percy_branchline_operations = server.tool( + "percy_branchline_operations", + "Sync, merge, or unmerge branch baselines.", + { + action: z + .enum(["sync", "merge", "unmerge"]) + .describe("Branchline operation to perform"), + project_id: z.string().optional().describe("Percy project ID"), + build_id: z.string().optional().describe("Percy build ID"), + target_branch_filter: z + .string() + .optional() + .describe("Target branch pattern for sync (e.g., 'main', 'release/*')"), + snapshot_ids: z + .string() + .optional() + .describe("Comma-separated snapshot IDs to include"), + }, + async (args) => { + try { + trackMCP( + "percy_branchline_operations", + server.server.getClientVersion()!, + config, + ); + return await percyBranchlineOperations(args, config); + } catch (error) { + return handleMCPError( + "percy_branchline_operations", + server, + config, + error, + ); + } + }, + ); + + // ========================================================================= + // === FINALIZE / UPLOAD === + // ========================================================================= + + // ------------------------------------------------------------------------- + // percy_finalize_build + // ------------------------------------------------------------------------- + tools.percy_finalize_build = server.tool( + "percy_finalize_build", + "Finalize a Percy build (triggers processing).", + { + build_id: z.string().describe("Percy build ID"), + }, + async (args) => { + try { + trackMCP( + "percy_finalize_build", + server.server.getClientVersion()!, + config, + ); + return await percyFinalizeBuild(args, config); + } catch (error) { + return handleMCPError("percy_finalize_build", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_finalize_snapshot + // ------------------------------------------------------------------------- + tools.percy_finalize_snapshot = server.tool( + "percy_finalize_snapshot", + "Finalize a snapshot (triggers rendering).", + { + snapshot_id: z.string().describe("Percy snapshot ID"), + }, + async (args) => { + try { + trackMCP( + "percy_finalize_snapshot", + server.server.getClientVersion()!, + config, + ); + return await percyFinalizeSnapshot(args, config); + } catch (error) { + return handleMCPError("percy_finalize_snapshot", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_finalize_comparison + // ------------------------------------------------------------------------- + tools.percy_finalize_comparison = server.tool( + "percy_finalize_comparison", + "Finalize a comparison (triggers diff processing).", + { + comparison_id: z.string().describe("Percy comparison ID"), + }, + async (args) => { + try { + trackMCP( + "percy_finalize_comparison", + server.server.getClientVersion()!, + config, + ); + return await percyFinalizeComparison(args, config); + } catch (error) { + return handleMCPError( + "percy_finalize_comparison", + server, + config, + error, + ); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_upload_resource + // ------------------------------------------------------------------------- + tools.percy_upload_resource = server.tool( + "percy_upload_resource", + "Upload a resource to a Percy build (CSS, JS, HTML, images).", + { + build_id: z.string().describe("Percy build ID"), + sha: z.string().describe("SHA-256 hash of the resource content"), + base64_content: z.string().describe("Base64-encoded resource content"), + }, + async (args) => { + try { + trackMCP( + "percy_upload_resource", + server.server.getClientVersion()!, + config, + ); + return await percyUploadResource(args, config); + } catch (error) { + return handleMCPError("percy_upload_resource", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_upload_tile + // ------------------------------------------------------------------------- + tools.percy_upload_tile = server.tool( + "percy_upload_tile", + "Upload a screenshot tile to a comparison (PNG/JPEG).", + { + comparison_id: z.string().describe("Percy comparison ID"), + base64_content: z + .string() + .describe("Base64-encoded PNG or JPEG screenshot"), + }, + async (args) => { + try { + trackMCP( + "percy_upload_tile", + server.server.getClientVersion()!, + config, + ); + return await percyUploadTile(args, config); + } catch (error) { + return handleMCPError("percy_upload_tile", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_analyze_logs_realtime + // ------------------------------------------------------------------------- + tools.percy_analyze_logs_realtime = server.tool( + "percy_analyze_logs_realtime", + "Analyze raw logs in real-time without a stored build.", + { + logs: z + .string() + .describe( + 'JSON array of log entries: [{"message":"...","level":"error","meta":{}}]', + ), + }, + async (args) => { + try { + trackMCP( + "percy_analyze_logs_realtime", + server.server.getClientVersion()!, + config, + ); + return await percyAnalyzeLogsRealtime(args, config); + } catch (error) { + return handleMCPError( + "percy_analyze_logs_realtime", + server, + config, + error, + ); + } + }, + ); + + // ========================================================================= + // === WORKFLOWS (Composite — highest value) === + // ========================================================================= + + // ------------------------------------------------------------------------- + // percy_pr_visual_report + // ------------------------------------------------------------------------- + tools.percy_pr_visual_report = server.tool( + "percy_pr_visual_report", + "Get a complete PR visual regression report: risk-ranked changes with AI analysis and recommendations. THE tool for checking PR status.", + { + project_id: z + .string() + .optional() + .describe( + "Percy project ID (optional if PERCY_TOKEN is project-scoped)", + ), + branch: z + .string() + .optional() + .describe("Git branch name to find the build"), + sha: z.string().optional().describe("Git commit SHA to find the build"), + build_id: z + .string() + .optional() + .describe("Direct Percy build ID (skips search)"), + }, + async (args) => { + try { + trackMCP( + "percy_pr_visual_report", + server.server.getClientVersion()!, + config, + ); + return await percyPrVisualReport(args, config); + } catch (error) { + return handleMCPError("percy_pr_visual_report", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_auto_triage + // ------------------------------------------------------------------------- + tools.percy_auto_triage = server.tool( + "percy_auto_triage", + "Auto-categorize all visual changes: Critical, Review Required, Auto-Approvable, Noise.", + { + build_id: z.string().describe("Percy build ID"), + noise_threshold: z + .number() + .optional() + .describe("Diff ratio below this is noise (default 0.005 = 0.5%)"), + review_threshold: z + .number() + .optional() + .describe("Diff ratio above this needs review (default 0.15 = 15%)"), + }, + async (args) => { + try { + trackMCP( + "percy_auto_triage", + server.server.getClientVersion()!, + config, + ); + return await percyAutoTriage(args, config); + } catch (error) { + return handleMCPError("percy_auto_triage", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_debug_failed_build + // ------------------------------------------------------------------------- + tools.percy_debug_failed_build = server.tool( + "percy_debug_failed_build", + "Diagnose a failed build: cross-references logs, suggestions, and network issues with fix commands.", + { + build_id: z.string().describe("Percy build ID"), + }, + async (args) => { + try { + trackMCP( + "percy_debug_failed_build", + server.server.getClientVersion()!, + config, + ); + return await percyDebugFailedBuild(args, config); + } catch (error) { + return handleMCPError( + "percy_debug_failed_build", + server, + config, + error, + ); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_diff_explain + // ------------------------------------------------------------------------- + tools.percy_diff_explain = server.tool( + "percy_diff_explain", + "Explain visual changes in plain English at 3 depth levels (summary/detailed/full_rca).", + { + comparison_id: z.string().describe("Percy comparison ID"), + depth: z + .enum(["summary", "detailed", "full_rca"]) + .optional() + .describe("Analysis depth (default: detailed)"), + }, + async (args) => { + try { + trackMCP( + "percy_diff_explain", + server.server.getClientVersion()!, + config, + ); + return await percyDiffExplain(args, config); + } catch (error) { + return handleMCPError("percy_diff_explain", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_snapshot_urls — Actually render URLs locally via Percy CLI + // ------------------------------------------------------------------------- + tools.percy_snapshot_urls = server.tool( + "percy_snapshot_urls", + "Snapshot URLs locally using Percy CLI. Launches a browser, captures screenshots at specified widths, and uploads to Percy. Runs in background — returns build URL immediately. Requires @percy/cli installed.", + { + project_name: z + .string() + .describe("Percy project name (auto-creates if doesn't exist)"), + urls: z + .string() + .describe( + "Comma-separated URLs to snapshot, e.g. 'http://localhost:3000,http://localhost:3000/about'", + ), + widths: z + .string() + .optional() + .describe("Comma-separated widths (default: 375,1280)"), + type: z.string().optional().describe("Project type: web or automate"), + }, + async (args) => { + try { + trackMCP( + "percy_snapshot_urls", + server.server.getClientVersion()!, + config, + ); + return await percySnapshotUrls(args, config); + } catch (error) { + return handleMCPError("percy_snapshot_urls", server, config, error); + } + }, + ); + + // ------------------------------------------------------------------------- + // percy_run_tests — Run tests with Percy visual testing + // ------------------------------------------------------------------------- + tools.percy_run_tests = server.tool( + "percy_run_tests", + "Run a test command with Percy visual testing. Wraps your test command with percy exec to capture snapshots during test execution. Runs in background — returns build URL immediately. Requires @percy/cli installed.", + { + project_name: z + .string() + .describe("Percy project name (auto-creates if doesn't exist)"), + test_command: z + .string() + .describe("Test command to run, e.g. 'npx cypress run' or 'npm test'"), + type: z.string().optional().describe("Project type: web or automate"), + }, + async (args) => { + try { + trackMCP("percy_run_tests", server.server.getClientVersion()!, config); + return await percyRunTests(args, config); + } catch (error) { + return handleMCPError("percy_run_tests", server, config, error); + } + }, + ); + + return tools; +} + +export default registerPercyMcpTools; diff --git a/src/tools/percy-mcp/intelligence/get-ai-analysis.ts b/src/tools/percy-mcp/intelligence/get-ai-analysis.ts new file mode 100644 index 0000000..9bafefb --- /dev/null +++ b/src/tools/percy-mcp/intelligence/get-ai-analysis.ts @@ -0,0 +1,225 @@ +/** + * percy_get_ai_analysis — Get AI-powered visual diff analysis. + * + * Two modes: + * 1. Single comparison (comparison_id) — regions, diff ratios, bug flags + * 2. Build aggregate (build_id) — overall AI metrics and job status + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface GetAiAnalysisArgs { + comparison_id?: string; + build_id?: string; +} + +function pct(value: number | null | undefined): string { + if (value == null) return "N/A"; + return `${(value * 100).toFixed(1)}%`; +} + +function na(value: unknown): string { + if (value == null || value === "") return "N/A"; + return String(value); +} + +// --------------------------------------------------------------------------- +// Single-comparison AI analysis +// --------------------------------------------------------------------------- + +async function analyzeComparison( + comparisonId: string, + client: PercyClient, +): Promise { + const includes = [ + "head-screenshot.image", + "base-screenshot.image", + "diff-image", + "ai-diff-image", + "browser.browser-family", + "comparison-tag", + ]; + + const response = await client.get<{ + data: Record | null; + }>(`/comparisons/${comparisonId}`, undefined, includes); + + const comparison = response.data as any; + + if (!comparison) { + return { + content: [ + { + type: "text", + text: `_Comparison ${comparisonId} not found._`, + }, + ], + }; + } + + const lines: string[] = []; + lines.push(`## AI Analysis — Comparison #${comparisonId}`); + lines.push(""); + + // Diff ratios + const aiDiff = comparison.aiDiffRatio; + const rawDiff = comparison.diffRatio; + if (aiDiff != null || rawDiff != null) { + lines.push(`**AI Diff Ratio:** ${pct(aiDiff)} (raw: ${pct(rawDiff)})`); + } + + // AI processing state + if ( + comparison.aiProcessingState && + comparison.aiProcessingState !== "completed" + ) { + lines.push( + `> ⚠ AI processing state: ${comparison.aiProcessingState}. Results may be incomplete.`, + ); + lines.push(""); + } + + // Bug count from regions + const regions: any[] = comparison.appliedRegions ?? []; + const bugCount = regions.filter( + (r: any) => + r.isBug === true || r.classification === "bug" || r.type === "bug", + ).length; + + if (bugCount > 0) { + lines.push(`**Potential Bugs:** ${bugCount}`); + } + + // Regions + if (regions.length > 0) { + lines.push(""); + lines.push(`### Regions (${regions.length}):`); + + for (let i = 0; i < regions.length; i++) { + const region = regions[i]; + const label = na(region.label ?? region.name); + const type = region.type ?? region.changeType ?? "unknown"; + const desc = region.description ?? ""; + const ignored = region.ignored === true || region.state === "ignored"; + + let line: string; + if (ignored) { + line = `${i + 1}. ~~${label}~~ (ignored by AI)`; + } else { + line = `${i + 1}. **${label}** (${type})`; + } + if (desc) line += `\n ${desc}`; + lines.push(line); + } + } else { + lines.push(""); + lines.push("_No AI regions detected for this comparison._"); + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + }; +} + +// --------------------------------------------------------------------------- +// Build-aggregate AI analysis +// --------------------------------------------------------------------------- + +async function analyzeBuild( + buildId: string, + client: PercyClient, +): Promise { + const response = await client.get<{ + data: Record | null; + }>(`/builds/${buildId}`, { "include-metadata": "true" }); + + const build = response.data as any; + + if (!build) { + return { + content: [{ type: "text", text: `_Build ${buildId} not found._` }], + }; + } + + const ai = build.aiDetails; + if (!ai) { + return { + content: [ + { + type: "text", + text: "AI analysis is not enabled for this project.", + }, + ], + }; + } + + const lines: string[] = []; + lines.push(`## AI Analysis — Build #${build.buildNumber ?? buildId}`); + lines.push(""); + + if (ai.comparisonsAnalyzed != null) { + lines.push(`- Comparisons analyzed: ${ai.comparisonsAnalyzed}`); + } + if (ai.potentialBugs != null) { + lines.push(`- Potential bugs: ${ai.potentialBugs}`); + } + if (ai.totalAiDiffs != null) { + lines.push(`- Total AI visual diffs: ${ai.totalAiDiffs}`); + } + if (ai.diffReduction != null) { + lines.push(`- Diff reduction: ${ai.diffReduction} diffs filtered`); + } else if (ai.originalDiffPercent != null && ai.aiDiffPercent != null) { + lines.push( + `- Diff reduction: ${pct(ai.originalDiffPercent)} → ${pct(ai.aiDiffPercent)}`, + ); + } + + const jobsCompleted = + ai.aiJobsCompleted != null ? (ai.aiJobsCompleted ? "yes" : "no") : "N/A"; + lines.push(`- AI jobs completed: ${jobsCompleted}`); + + const summaryStatus = na(ai.summaryStatus ?? ai.aiSummaryStatus); + lines.push(`- Summary status: ${summaryStatus}`); + + // Warning if AI is still processing + if (ai.aiJobsCompleted === false || ai.summaryStatus === "processing") { + lines.push(""); + lines.push( + "> ⚠ AI analysis is still in progress. Some metrics may be incomplete. Re-run for final results.", + ); + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + }; +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +export async function percyGetAiAnalysis( + args: GetAiAnalysisArgs, + config: BrowserStackConfig, +): Promise { + if (!args.comparison_id && !args.build_id) { + return { + content: [ + { + type: "text", + text: "_Error: Provide either `comparison_id` or `build_id` for AI analysis._", + }, + ], + }; + } + + const client = new PercyClient(config, { scope: "project" }); + + if (args.comparison_id) { + return analyzeComparison(args.comparison_id, client); + } + + return analyzeBuild(args.build_id!, client); +} diff --git a/src/tools/percy-mcp/intelligence/get-ai-quota.ts b/src/tools/percy-mcp/intelligence/get-ai-quota.ts new file mode 100644 index 0000000..95b32af --- /dev/null +++ b/src/tools/percy-mcp/intelligence/get-ai-quota.ts @@ -0,0 +1,94 @@ +/** + * percy_get_ai_quota — Check Percy AI quota status. + * + * Since there is no direct quota endpoint, derives AI quota info + * from the latest build's AI details. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyGetAiQuota( + _args: Record, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config, { scope: "project" }); + + // Fetch the latest build to extract AI details + const response = await client.get<{ + data: Record[] | null; + meta?: Record; + }>("/builds", { + "page[limit]": "1", + "include-metadata": "true", + }); + + const builds = Array.isArray(response.data) ? response.data : []; + + if (builds.length === 0) { + return { + content: [ + { + type: "text", + text: "AI quota information unavailable. No builds found for this project.", + }, + ], + }; + } + + const build = builds[0] as any; + const ai = build.aiDetails; + + if (!ai) { + return { + content: [ + { + type: "text", + text: "AI quota information unavailable. Ensure AI is enabled on your Percy project.", + }, + ], + }; + } + + const lines: string[] = []; + lines.push("## Percy AI Quota Status"); + lines.push(""); + + // Quota / regeneration info + const used = ai.regenerationsUsed ?? ai.quotaUsed; + const total = ai.regenerationsTotal ?? ai.quotaTotal ?? ai.dailyQuota; + const plan = ai.planType ?? ai.plan ?? ai.tier; + + if (used != null && total != null) { + lines.push(`**Daily Regenerations:** ${used} / ${total} used`); + } else if (total != null) { + lines.push(`**Daily Regeneration Limit:** ${total}`); + } else { + lines.push( + "**Daily Regenerations:** Quota details not available in build metadata.", + ); + } + + if (plan) { + lines.push(`**Plan:** ${plan}`); + } + + // Additional AI stats from the latest build + if (ai.comparisonsAnalyzed != null) { + lines.push(""); + lines.push("### Latest Build AI Stats"); + lines.push(`- Build #${build.buildNumber ?? build.id}`); + lines.push(`- Comparisons analyzed: ${ai.comparisonsAnalyzed}`); + if (ai.potentialBugs != null) { + lines.push(`- Potential bugs detected: ${ai.potentialBugs}`); + } + if (ai.aiJobsCompleted != null) { + lines.push(`- AI jobs completed: ${ai.aiJobsCompleted ? "yes" : "no"}`); + } + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + }; +} diff --git a/src/tools/percy-mcp/intelligence/get-build-summary.ts b/src/tools/percy-mcp/intelligence/get-build-summary.ts new file mode 100644 index 0000000..394b4e2 --- /dev/null +++ b/src/tools/percy-mcp/intelligence/get-build-summary.ts @@ -0,0 +1,105 @@ +/** + * percy_get_build_summary — Get AI-generated natural language summary + * of all visual changes in a Percy build. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface GetBuildSummaryArgs { + build_id: string; +} + +export async function percyGetBuildSummary( + args: GetBuildSummaryArgs, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config, { scope: "project" }); + + const response = await client.get<{ + data: Record | null; + }>(`/builds/${args.build_id}`, { "include-metadata": "true" }, [ + "build-summary", + ]); + + const build = response.data as any; + + if (!build) { + return { + content: [{ type: "text", text: `_Build ${args.build_id} not found._` }], + }; + } + + // Check for build summary in relationships or top-level + const summary = + build.buildSummary?.content ?? + build.buildSummary?.summary ?? + build.summary ?? + null; + + if (summary && typeof summary === "string") { + const lines: string[] = []; + lines.push( + `## Build Summary — Build #${build.buildNumber ?? args.build_id}`, + ); + lines.push(""); + + // The summary may be a JSON string or plain text + let parsedSummary: string; + try { + const parsed = JSON.parse(summary); + // If it parsed as an object, format its contents + if (typeof parsed === "object" && parsed !== null) { + parsedSummary = Object.entries(parsed) + .map(([key, value]) => `**${key}:** ${value}`) + .join("\n"); + } else { + parsedSummary = String(parsed); + } + } catch { + // Plain text — use as-is + parsedSummary = summary; + } + + lines.push(parsedSummary); + + return { + content: [{ type: "text", text: lines.join("\n") }], + }; + } + + // No summary — check AI details for reason + const ai = build.aiDetails; + if (ai) { + const status = ai.summaryStatus ?? ai.aiSummaryStatus; + + if (status === "processing") { + return { + content: [ + { + type: "text", + text: "Build summary is being generated. Try again in a minute.", + }, + ], + }; + } + + if (status === "skipped") { + const reason = + ai.summaryReason ?? ai.summarySkipReason ?? "unknown reason"; + return { + content: [ + { + type: "text", + text: `Build summary unavailable. Reason: ${reason}`, + }, + ], + }; + } + } + + return { + content: [{ type: "text", text: "No build summary available." }], + }; +} diff --git a/src/tools/percy-mcp/intelligence/get-rca.ts b/src/tools/percy-mcp/intelligence/get-rca.ts new file mode 100644 index 0000000..24a115b --- /dev/null +++ b/src/tools/percy-mcp/intelligence/get-rca.ts @@ -0,0 +1,168 @@ +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { pollUntil } from "../../../lib/percy-api/polling.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyGetRca( + args: { comparison_id: string; trigger_if_missing?: boolean }, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config); + const triggerIfMissing = args.trigger_if_missing !== false; // default true + + // Step 1: Check existing RCA status + // GET /rca?comparison_id={id} + // Response has: status (pending/finished/failed), diffNodes (when finished) + + let rcaData: any; + try { + rcaData = await client.get("/rca", { comparison_id: args.comparison_id }); + } catch (e: any) { + // 404 means RCA not started + if (e.statusCode === 404 && triggerIfMissing) { + // Step 2: Trigger RCA + try { + await client.post("/rca", { + data: { + type: "rca", + attributes: { "comparison-id": args.comparison_id }, + }, + }); + } catch (triggerError: any) { + if (triggerError.statusCode === 422) { + return { + content: [ + { + type: "text", + text: "RCA requires DOM metadata. This comparison type does not support RCA.", + }, + ], + isError: true, + }; + } + throw triggerError; + } + rcaData = { status: "pending" }; + } else if (e.statusCode === 404) { + return { + content: [ + { + type: "text", + text: "RCA not yet triggered for this comparison. Set trigger_if_missing=true to start it.", + }, + ], + }; + } else { + throw e; + } + } + + // Step 3: Poll if pending + if (rcaData?.status === "pending") { + const result = await pollUntil( + async () => { + const data = await client.get("/rca", { + comparison_id: args.comparison_id, + }); + if (data?.status === "finished") return { done: true, result: data }; + if (data?.status === "failed") return { done: true, result: data }; + return { done: false }; + }, + { initialDelayMs: 500, maxDelayMs: 5000, maxTimeoutMs: 120000 }, + ); + + if (!result) { + return { + content: [ + { + type: "text", + text: "RCA analysis timed out after 2 minutes. The analysis may still be processing — try again later.", + }, + ], + }; + } + rcaData = result; + } + + if (rcaData?.status === "failed") { + return { + content: [ + { + type: "text", + text: "RCA analysis failed. The comparison may not have sufficient DOM metadata.", + }, + ], + isError: true, + }; + } + + // Step 4: Format diff nodes + const diffNodes = rcaData?.diffNodes || rcaData?.diff_nodes || {}; + const commonDiffs = diffNodes.common_diffs || []; + const extraBase = diffNodes.extra_base || []; + const extraHead = diffNodes.extra_head || []; + + let output = `## Root Cause Analysis — Comparison #${args.comparison_id}\n\n`; + output += `**Status:** ${rcaData?.status || "unknown"}\n\n`; + + if (commonDiffs.length > 0) { + output += `### Changed Elements (${commonDiffs.length})\n\n`; + commonDiffs.forEach((diff: any, i: number) => { + const base = diff.base || {}; + const head = diff.head || {}; + const tag = head.tagName || base.tagName || "unknown"; + const xpath = head.xpath || base.xpath || ""; + const diffType = + head.diff_type === 1 + ? "DIFF" + : head.diff_type === 2 + ? "IGNORED" + : "unknown"; + output += `${i + 1}. **${tag}** (${diffType})\n`; + if (xpath) output += ` XPath: \`${xpath}\`\n`; + // Show attribute differences + const baseAttrs = base.attributes || {}; + const headAttrs = head.attributes || {}; + const allKeys = new Set([ + ...Object.keys(baseAttrs), + ...Object.keys(headAttrs), + ]); + for (const key of allKeys) { + if (JSON.stringify(baseAttrs[key]) !== JSON.stringify(headAttrs[key])) { + output += ` ${key}: \`${baseAttrs[key] ?? "N/A"}\` → \`${headAttrs[key] ?? "N/A"}\`\n`; + } + } + output += "\n"; + }); + } + + if (extraBase.length > 0) { + output += `### Removed Elements (${extraBase.length})\n\n`; + extraBase.forEach((node: any, i: number) => { + const detail = node.node_detail || node; + output += `${i + 1}. **${detail.tagName || "unknown"}** — removed from head\n`; + if (detail.xpath) output += ` XPath: \`${detail.xpath}\`\n`; + output += "\n"; + }); + } + + if (extraHead.length > 0) { + output += `### Added Elements (${extraHead.length})\n\n`; + extraHead.forEach((node: any, i: number) => { + const detail = node.node_detail || node; + output += `${i + 1}. **${detail.tagName || "unknown"}** — added in head\n`; + if (detail.xpath) output += ` XPath: \`${detail.xpath}\`\n`; + output += "\n"; + }); + } + + if ( + commonDiffs.length === 0 && + extraBase.length === 0 && + extraHead.length === 0 + ) { + output += "No DOM differences found.\n"; + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/intelligence/suggest-prompt.ts b/src/tools/percy-mcp/intelligence/suggest-prompt.ts new file mode 100644 index 0000000..89bc573 --- /dev/null +++ b/src/tools/percy-mcp/intelligence/suggest-prompt.ts @@ -0,0 +1,122 @@ +/** + * percy_suggest_prompt — Get an AI-generated prompt suggestion for diff regions. + * + * Sends region IDs to the Percy API, polls for the suggestion result, + * and returns the generated prompt text. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { pollUntil } from "../../../lib/percy-api/polling.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface SuggestPromptArgs { + comparison_id: string; + region_ids: string; + ignore_change?: boolean; +} + +export async function percySuggestPrompt( + args: SuggestPromptArgs, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config); + + const regionIds = args.region_ids + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + const regionTypes = regionIds.map(() => "ai_region"); + + const body = { + data: { + attributes: { + "comparison-id": parseInt(args.comparison_id, 10), + "region-id": regionIds.map(Number), + "region-type": regionTypes, + "ignore-change": args.ignore_change !== false, + }, + }, + }; + + const result = await client.post>( + "/suggest-prompt", + body, + ); + + const identifier = + (result as Record)?.identifier || + (result as Record)?.id; + + if (!identifier) { + return { + content: [ + { + type: "text", + text: "Prompt suggestion initiated but no tracking ID received. Check results manually.", + }, + ], + }; + } + + const suggestion = await pollUntil>( + async () => { + const status = await client.get>>( + "/job_status", + { + sync: "true", + type: "ai", + id: String(identifier), + }, + ); + + const entry = status?.[String(identifier)]; + if (entry?.status === true) { + return { done: true, result: entry }; + } + if (entry?.error) { + return { done: true, result: entry }; + } + return { done: false }; + }, + { initialDelayMs: 1000, maxDelayMs: 3000, maxTimeoutMs: 30000 }, + ); + + if (!suggestion) { + return { + content: [ + { + type: "text", + text: "Prompt suggestion timed out. The AI is still generating — try again in a moment.", + }, + ], + }; + } + + if (suggestion.error) { + return { + content: [ + { + type: "text", + text: `Prompt suggestion failed: ${suggestion.error}`, + }, + ], + isError: true, + }; + } + + const data = suggestion.data as Record | undefined; + const prompt = + data?.generated_prompt || + suggestion.generated_prompt || + "No prompt generated"; + + let output = "## AI Prompt Suggestion\n\n"; + output += `**Suggested prompt:** ${prompt}\n\n`; + output += `**Mode:** ${args.ignore_change !== false ? "ignore" : "show"}\n`; + output += `**Regions analyzed:** ${regionIds.length}\n\n`; + output += + "Use this prompt with `percy_trigger_ai_recompute` to apply it across all comparisons.\n"; + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/intelligence/trigger-ai-recompute.ts b/src/tools/percy-mcp/intelligence/trigger-ai-recompute.ts new file mode 100644 index 0000000..c6ed474 --- /dev/null +++ b/src/tools/percy-mcp/intelligence/trigger-ai-recompute.ts @@ -0,0 +1,81 @@ +/** + * percy_trigger_ai_recompute — Re-run Percy AI analysis with a custom prompt. + * + * Sends a recompute request for a build or single comparison, optionally + * with a user-supplied prompt and ignore/unignore mode. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface TriggerAiRecomputeArgs { + build_id?: string; + comparison_id?: string; + prompt?: string; + mode?: string; +} + +export async function percyTriggerAiRecompute( + args: TriggerAiRecomputeArgs, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config); + + if (!args.build_id && !args.comparison_id) { + return { + content: [ + { + type: "text", + text: "Either build_id or comparison_id is required.", + }, + ], + isError: true, + }; + } + + const body: Record = { + data: { + type: "ai-recompute", + attributes: { + ...(args.prompt && { "user-prompt": args.prompt }), + ...(args.mode && { mode: args.mode }), + ...(args.comparison_id && { + "comparison-id": parseInt(args.comparison_id, 10), + }), + ...(args.build_id && { + "build-id": parseInt(args.build_id, 10), + }), + }, + }, + }; + + try { + await client.post("/ai-recompute", body); + + let output = "## AI Recompute Triggered\n\n"; + output += `**Mode:** ${args.mode || "ignore"}\n`; + if (args.prompt) output += `**Prompt:** ${args.prompt}\n`; + output += `**Status:** Processing\n\n`; + output += + "The AI will re-analyze the visual diffs with your custom prompt. "; + output += + "Use `percy_get_ai_analysis` to check results after processing completes (typically 30-60 seconds).\n"; + + return { content: [{ type: "text", text: output }] }; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + if (message.includes("quota")) { + return { + content: [ + { + type: "text", + text: "AI recompute quota exceeded for today. Try again tomorrow or upgrade your plan.", + }, + ], + isError: true, + }; + } + throw e; + } +} diff --git a/src/tools/percy-mcp/management/create-project.ts b/src/tools/percy-mcp/management/create-project.ts new file mode 100644 index 0000000..e3e3c4f --- /dev/null +++ b/src/tools/percy-mcp/management/create-project.ts @@ -0,0 +1,75 @@ +import { getBrowserStackAuth } from "../../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Creates a Percy project using the BrowserStack API. + * + * Uses `api.browserstack.com/api/app_percy/get_project_token` which: + * - Creates the project if it doesn't exist + * - Returns a project token for the project + * - Requires BrowserStack Basic Auth (username + access key) + */ +export async function percyCreateProject( + args: { + name: string; + type?: string; + }, + config: BrowserStackConfig, +): Promise { + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); + + const params = new URLSearchParams({ name: args.name }); + if (args.type) { + params.append("type", args.type); + } + + const url = `https://api.browserstack.com/api/app_percy/get_project_token?${params.toString()}`; + + const response = await fetch(url, { + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + throw new Error( + `Failed to create Percy project (${response.status}): ${errorText || response.statusText}`, + ); + } + + const data = await response.json(); + + if (!data?.success) { + throw new Error( + data?.message || + "Project creation failed — check the project name and type.", + ); + } + + const token = data.token || "unknown"; + const tokenPrefix = token.split("_")[0] || "unknown"; + const maskedToken = + token.length > 8 ? `${token.slice(0, 8)}...${token.slice(-4)}` : "****"; + + let output = `## Percy Project Created\n\n`; + output += `| Field | Value |\n`; + output += `|-------|-------|\n`; + output += `| **Name** | ${args.name} |\n`; + output += `| **Type** | ${args.type || "auto (default)"} |\n`; + output += `| **Token** | \`${maskedToken}\` |\n`; + output += `| **Token type** | ${tokenPrefix} |\n`; + output += `| **Capture mode** | ${data.percy_capture_mode || "auto"} |\n`; + output += `\n### Project Token\n\n`; + output += `\`\`\`\n${token}\n\`\`\`\n\n`; + output += `> Save this token — set it as \`PERCY_TOKEN\` env var to use with other Percy tools.\n\n`; + output += `### Next Steps\n\n`; + output += `1. Set the token: \`export PERCY_TOKEN=${token}\`\n`; + output += `2. Create a build: \`percy_create_build\` with project_id from Percy dashboard\n`; + output += `3. Or run Percy CLI: \`percy exec -- your-test-command\`\n`; + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/management/get-usage-stats.ts b/src/tools/percy-mcp/management/get-usage-stats.ts new file mode 100644 index 0000000..4203d2e --- /dev/null +++ b/src/tools/percy-mcp/management/get-usage-stats.ts @@ -0,0 +1,92 @@ +/** + * percy_get_usage_stats — Get Percy screenshot usage, quota limits, and AI comparison + * counts for an organization. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface GetUsageStatsArgs { + org_id: string; + product?: string; +} + +export async function percyGetUsageStats( + args: GetUsageStatsArgs, + config: BrowserStackConfig, +): Promise { + const { org_id, product } = args; + const client = new PercyClient(config); + + const params: Record = { + "filter[organization-id]": org_id, + }; + if (product) { + params["filter[product]"] = product; + } + + const response = await client.get<{ + data: Record | Record[] | null; + meta?: Record; + }>("/usage-stats", params); + + const data = response?.data; + const entries = Array.isArray(data) ? data : data ? [data] : []; + + if (entries.length === 0) { + return { + content: [ + { type: "text", text: "_No usage data found for this organization._" }, + ], + }; + } + + const lines: string[] = []; + lines.push(`## Percy Usage Stats (Org: ${org_id})`); + lines.push(""); + + for (const entry of entries) { + const attrs = (entry as any).attributes ?? entry; + const entryProduct = attrs.product ?? attrs["product-type"] ?? "percy"; + + lines.push(`### ${entryProduct}`); + lines.push(""); + lines.push("| Metric | Value |"); + lines.push("|--------|-------|"); + + const currentUsage = + attrs.currentUsage ?? attrs["current-usage"] ?? attrs.usage ?? "?"; + const quota = attrs.quota ?? attrs["screenshot-quota"] ?? "?"; + const aiComparisons = + attrs.aiComparisons ?? attrs["ai-comparisons"] ?? "N/A"; + const planType = attrs.planType ?? attrs["plan-type"] ?? "N/A"; + + lines.push(`| Current Usage | ${currentUsage} |`); + lines.push(`| Quota | ${quota} |`); + lines.push(`| AI Comparisons | ${aiComparisons} |`); + lines.push(`| Plan Type | ${planType} |`); + + // Include any additional numeric attrs + for (const [key, value] of Object.entries(attrs)) { + if ( + typeof value === "number" && + ![ + "currentUsage", + "current-usage", + "usage", + "quota", + "screenshot-quota", + "aiComparisons", + "ai-comparisons", + ].includes(key) + ) { + lines.push(`| ${key} | ${value} |`); + } + } + + lines.push(""); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; +} diff --git a/src/tools/percy-mcp/management/manage-browser-targets.ts b/src/tools/percy-mcp/management/manage-browser-targets.ts new file mode 100644 index 0000000..81d3f1a --- /dev/null +++ b/src/tools/percy-mcp/management/manage-browser-targets.ts @@ -0,0 +1,163 @@ +/** + * percy_manage_browser_targets — List, add, or remove browser targets for a project. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface ManageBrowserTargetsArgs { + project_id: string; + action?: string; + browser_family?: string; +} + +export async function percyManageBrowserTargets( + args: ManageBrowserTargetsArgs, + config: BrowserStackConfig, +): Promise { + const { project_id, action = "list", browser_family } = args; + const client = new PercyClient(config); + + // ---- List ---- + if (action === "list") { + const [families, targets] = await Promise.all([ + client.get<{ data: Record[] | null }>( + "/browser-families", + ), + client.get<{ data: Record[] | null }>( + `/projects/${project_id}/project-browser-targets`, + ), + ]); + + const familyList = Array.isArray(families?.data) ? families.data : []; + const targetList = Array.isArray(targets?.data) ? targets.data : []; + + const lines: string[] = []; + lines.push(`## Browser Targets for Project ${project_id}`); + lines.push(""); + + if (targetList.length === 0) { + lines.push("_No browser targets configured. Using defaults._"); + } else { + lines.push("### Active Targets"); + lines.push(""); + lines.push("| Browser Family | ID |"); + lines.push("|---------------|-----|"); + for (const target of targetList) { + const attrs = (target as any).attributes ?? target; + const name = + attrs.browserFamilySlug ?? + attrs["browser-family-slug"] ?? + attrs.name ?? + "unknown"; + lines.push(`| ${name} | ${target.id ?? "?"} |`); + } + } + + if (familyList.length > 0) { + lines.push(""); + lines.push("### Available Browser Families"); + lines.push(""); + for (const family of familyList) { + const attrs = (family as any).attributes ?? family; + const name = attrs.name ?? attrs.slug ?? "unknown"; + lines.push(`- ${name} (ID: ${family.id ?? "?"})`); + } + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + // ---- Add ---- + if (action === "add") { + if (!browser_family) { + return { + content: [ + { + type: "text", + text: "browser_family is required for the 'add' action. Use action='list' to see available families.", + }, + ], + isError: true, + }; + } + + const body = { + data: { + type: "project-browser-targets", + relationships: { + project: { data: { type: "projects", id: project_id } }, + "browser-family": { + data: { type: "browser-families", id: browser_family }, + }, + }, + }, + }; + + try { + await client.post("/project-browser-targets", body); + return { + content: [ + { + type: "text", + text: `Browser family ${browser_family} added to project ${project_id}.`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { type: "text", text: `Failed to add browser target: ${message}` }, + ], + isError: true, + }; + } + } + + // ---- Remove ---- + if (action === "remove") { + if (!browser_family) { + return { + content: [ + { + type: "text", + text: "browser_family (target ID) is required for the 'remove' action.", + }, + ], + isError: true, + }; + } + + try { + await client.del(`/project-browser-targets/${browser_family}`); + return { + content: [ + { + type: "text", + text: `Browser target ${browser_family} removed from project ${project_id}.`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { type: "text", text: `Failed to remove browser target: ${message}` }, + ], + isError: true, + }; + } + } + + return { + content: [ + { + type: "text", + text: `Invalid action "${action}". Valid actions: list, add, remove`, + }, + ], + isError: true, + }; +} diff --git a/src/tools/percy-mcp/management/manage-comments.ts b/src/tools/percy-mcp/management/manage-comments.ts new file mode 100644 index 0000000..d47a1d0 --- /dev/null +++ b/src/tools/percy-mcp/management/manage-comments.ts @@ -0,0 +1,182 @@ +/** + * percy_manage_comments — List, create, or close comment threads on Percy snapshots. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface ManageCommentsArgs { + build_id?: string; + snapshot_id?: string; + action?: string; + thread_id?: string; + body?: string; +} + +export async function percyManageComments( + args: ManageCommentsArgs, + config: BrowserStackConfig, +): Promise { + const { build_id, snapshot_id, action = "list", thread_id, body } = args; + const client = new PercyClient(config); + + // ---- List ---- + if (action === "list") { + if (!build_id) { + return { + content: [ + { type: "text", text: "build_id is required for the 'list' action." }, + ], + isError: true, + }; + } + + const response = await client.get<{ + data: Record[] | null; + }>(`/builds/${build_id}/comment_threads`); + + const threads = Array.isArray(response?.data) ? response.data : []; + + if (threads.length === 0) { + return { + content: [ + { type: "text", text: "_No comment threads for this build._" }, + ], + }; + } + + const lines: string[] = []; + lines.push(`## Comment Threads (Build: ${build_id})`); + lines.push(""); + + for (const thread of threads) { + const attrs = (thread as any).attributes ?? thread; + const id = thread.id ?? "?"; + const closed = attrs.closedAt ?? attrs["closed-at"]; + const status = closed ? "Closed" : "Open"; + const commentCount = + attrs.commentsCount ?? attrs["comments-count"] ?? "?"; + lines.push(`### Thread #${id} (${status}, ${commentCount} comments)`); + lines.push(""); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + // ---- Create ---- + if (action === "create") { + if (!snapshot_id) { + return { + content: [ + { + type: "text", + text: "snapshot_id is required for the 'create' action.", + }, + ], + isError: true, + }; + } + if (!body) { + return { + content: [ + { type: "text", text: "body is required for the 'create' action." }, + ], + isError: true, + }; + } + + const requestBody = { + data: { + type: "comments", + attributes: { + body, + }, + relationships: { + snapshot: { + data: { type: "snapshots", id: snapshot_id }, + }, + }, + }, + }; + + try { + const result = (await client.post<{ + data: Record | null; + }>("/comments", requestBody)) as { + data: Record | null; + }; + + const id = (result?.data as any)?.id ?? "?"; + return { + content: [ + { + type: "text", + text: `Comment created (ID: ${id}) on snapshot ${snapshot_id}.`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { type: "text", text: `Failed to create comment: ${message}` }, + ], + isError: true, + }; + } + } + + // ---- Close ---- + if (action === "close") { + if (!thread_id) { + return { + content: [ + { + type: "text", + text: "thread_id is required for the 'close' action.", + }, + ], + isError: true, + }; + } + + const requestBody = { + data: { + type: "comment-threads", + id: thread_id, + attributes: { + "closed-at": new Date().toISOString(), + }, + }, + }; + + try { + await client.patch(`/comment-threads/${thread_id}`, requestBody); + return { + content: [ + { + type: "text", + text: `Comment thread ${thread_id} closed.`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text", text: `Failed to close thread: ${message}` }], + isError: true, + }; + } + } + + return { + content: [ + { + type: "text", + text: `Invalid action "${action}". Valid actions: list, create, close`, + }, + ], + isError: true, + }; +} diff --git a/src/tools/percy-mcp/management/manage-ignored-regions.ts b/src/tools/percy-mcp/management/manage-ignored-regions.ts new file mode 100644 index 0000000..c7799ab --- /dev/null +++ b/src/tools/percy-mcp/management/manage-ignored-regions.ts @@ -0,0 +1,221 @@ +/** + * percy_manage_ignored_regions — Create, list, save, or delete ignored regions + * on Percy comparisons. + * + * Supports bounding box (raw), XPath, CSS selector, and fullpage types. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface ManageIgnoredRegionsArgs { + comparison_id?: string; + action?: string; + region_id?: string; + type?: string; + coordinates?: string; + selector?: string; +} + +export async function percyManageIgnoredRegions( + args: ManageIgnoredRegionsArgs, + config: BrowserStackConfig, +): Promise { + const { + comparison_id, + action = "list", + region_id, + type, + coordinates, + selector, + } = args; + const client = new PercyClient(config); + + // ---- List ---- + if (action === "list") { + if (!comparison_id) { + return { + content: [ + { + type: "text", + text: "comparison_id is required for the 'list' action.", + }, + ], + isError: true, + }; + } + + const response = await client.get<{ + data: Record[] | null; + }>("/region-revisions", { + comparison_id, + }); + + const regions = Array.isArray(response?.data) ? response.data : []; + + if (regions.length === 0) { + return { + content: [ + { type: "text", text: "_No ignored regions for this comparison._" }, + ], + }; + } + + const lines: string[] = []; + lines.push(`## Ignored Regions (Comparison: ${comparison_id})`); + lines.push(""); + lines.push("| ID | Type | Selector / Coordinates |"); + lines.push("|----|------|------------------------|"); + + for (const region of regions) { + const attrs = (region as any).attributes ?? region; + const rType = attrs.type ?? attrs["region-type"] ?? "unknown"; + const rSelector = attrs.selector ?? ""; + const rCoords = attrs.coordinates + ? JSON.stringify(attrs.coordinates) + : ""; + const display = rSelector || rCoords || "—"; + lines.push(`| ${region.id ?? "?"} | ${rType} | ${display} |`); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + // ---- Create ---- + if (action === "create") { + if (!comparison_id) { + return { + content: [ + { + type: "text", + text: "comparison_id is required for the 'create' action.", + }, + ], + isError: true, + }; + } + + const attrs: Record = {}; + if (type) attrs["region-type"] = type; + if (selector) attrs.selector = selector; + if (coordinates) { + try { + attrs.coordinates = JSON.parse(coordinates); + } catch { + return { + content: [ + { + type: "text", + text: 'Invalid coordinates JSON. Expected format: {"x":0,"y":0,"width":100,"height":100}', + }, + ], + isError: true, + }; + } + } + + const body = { + data: { + type: "region-revisions", + attributes: attrs, + relationships: { + comparison: { + data: { type: "comparisons", id: comparison_id }, + }, + }, + }, + }; + + try { + const result = (await client.post<{ + data: Record | null; + }>("/region-revisions", body)) as { + data: Record | null; + }; + + const id = (result?.data as any)?.id ?? "?"; + return { + content: [ + { + type: "text", + text: `Ignored region created (ID: ${id}, type: ${type ?? "raw"}).`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { type: "text", text: `Failed to create ignored region: ${message}` }, + ], + isError: true, + }; + } + } + + // ---- Save (bulk) ---- + if (action === "save") { + try { + await client.patch("/region-revisions/bulk-save", {}); + return { + content: [ + { + type: "text", + text: "Ignored regions saved (bulk save completed).", + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { type: "text", text: `Failed to bulk-save regions: ${message}` }, + ], + isError: true, + }; + } + } + + // ---- Delete ---- + if (action === "delete") { + if (!region_id) { + return { + content: [ + { + type: "text", + text: "region_id is required for the 'delete' action.", + }, + ], + isError: true, + }; + } + + try { + await client.del(`/region-revisions/${region_id}`); + return { + content: [ + { type: "text", text: `Ignored region ${region_id} deleted.` }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { type: "text", text: `Failed to delete region: ${message}` }, + ], + isError: true, + }; + } + } + + return { + content: [ + { + type: "text", + text: `Invalid action "${action}". Valid actions: list, create, save, delete`, + }, + ], + isError: true, + }; +} diff --git a/src/tools/percy-mcp/management/manage-project-settings.ts b/src/tools/percy-mcp/management/manage-project-settings.ts new file mode 100644 index 0000000..2e6726f --- /dev/null +++ b/src/tools/percy-mcp/management/manage-project-settings.ts @@ -0,0 +1,139 @@ +/** + * percy_manage_project_settings — View or update Percy project settings. + * + * GET /projects/{project_id} to read current settings. + * PATCH /projects/{project_id} with JSON:API body to update. + * High-risk attributes require confirm_destructive=true. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const HIGH_RISK_ATTRIBUTES = [ + "auto-approve-branch-filter", + "approval-required-branch-filter", +]; + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +interface ManageProjectSettingsArgs { + project_id: string; + settings?: string; + confirm_destructive?: boolean; +} + +export async function percyManageProjectSettings( + args: ManageProjectSettingsArgs, + config: BrowserStackConfig, +): Promise { + const { project_id, settings, confirm_destructive } = args; + const client = new PercyClient(config); + + // ---- Read current settings ---- + const current = (await client.get<{ + data: Record | null; + }>(`/projects/${project_id}`)) as { data: Record | null }; + + if (!settings) { + // Read-only mode — return current settings + const attrs = (current?.data as any)?.attributes ?? current?.data ?? {}; + const lines: string[] = []; + lines.push(`## Project Settings (ID: ${project_id})`); + lines.push(""); + lines.push("| Setting | Value |"); + lines.push("|---------|-------|"); + + for (const [key, value] of Object.entries(attrs)) { + lines.push(`| ${key} | ${JSON.stringify(value)} |`); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + // ---- Update mode ---- + let parsed: Record; + try { + parsed = JSON.parse(settings); + } catch { + return { + content: [ + { + type: "text", + text: "Invalid settings JSON. Provide a valid JSON object of attributes to update.", + }, + ], + isError: true, + }; + } + + // Check for high-risk attributes + const highRiskKeys = Object.keys(parsed).filter((key) => + HIGH_RISK_ATTRIBUTES.includes(key), + ); + + if (highRiskKeys.length > 0 && !confirm_destructive) { + const lines: string[] = []; + lines.push("## Warning: High-Risk Settings Change"); + lines.push(""); + lines.push( + "The following settings can significantly affect your workflow:", + ); + lines.push(""); + for (const key of highRiskKeys) { + lines.push(`- **${key}**: \`${JSON.stringify(parsed[key])}\``); + } + lines.push(""); + lines.push("Set `confirm_destructive=true` to apply these changes."); + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + // Build JSON:API PATCH body + const body = { + data: { + type: "projects", + id: project_id, + attributes: parsed, + }, + }; + + try { + const result = (await client.patch<{ + data: Record | null; + }>(`/projects/${project_id}`, body)) as { + data: Record | null; + }; + + const updatedAttrs = + (result?.data as any)?.attributes ?? result?.data ?? {}; + const lines: string[] = []; + lines.push(`## Project Settings Updated (ID: ${project_id})`); + lines.push(""); + lines.push("**Updated attributes:**"); + for (const key of Object.keys(parsed)) { + lines.push( + `- **${key}**: ${JSON.stringify(updatedAttrs[key] ?? parsed[key])}`, + ); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Failed to update project settings: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/percy-mcp/management/manage-tokens.ts b/src/tools/percy-mcp/management/manage-tokens.ts new file mode 100644 index 0000000..fef31c3 --- /dev/null +++ b/src/tools/percy-mcp/management/manage-tokens.ts @@ -0,0 +1,124 @@ +/** + * percy_manage_tokens — List or rotate Percy project tokens. + * + * Token values are masked — only the last 4 characters are shown. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface ManageTokensArgs { + project_id: string; + action?: string; + role?: string; +} + +export async function percyManageTokens( + args: ManageTokensArgs, + config: BrowserStackConfig, +): Promise { + const { project_id, action = "list", role } = args; + const client = new PercyClient(config); + + // ---- List ---- + if (action === "list") { + const response = await client.get<{ + data: Record[] | null; + }>(`/projects/${project_id}/tokens`); + + const tokens = Array.isArray(response?.data) ? response.data : []; + + if (tokens.length === 0) { + return { + content: [ + { type: "text", text: "_No tokens found for this project._" }, + ], + }; + } + + const lines: string[] = []; + lines.push(`## Tokens for Project ${project_id}`); + lines.push(""); + lines.push("| Role | Token (masked) | ID |"); + lines.push("|------|---------------|----|"); + + for (const token of tokens) { + const attrs = (token as any).attributes ?? token; + const tokenRole = attrs.role ?? attrs["token-role"] ?? "unknown"; + const tokenValue = attrs.token ?? attrs["token-value"] ?? ""; + const masked = + tokenValue.length > 4 ? `****${tokenValue.slice(-4)}` : "****"; + lines.push(`| ${tokenRole} | ${masked} | ${token.id ?? "?"} |`); + } + + lines.push(""); + lines.push( + "_Token values are masked for security. Use action='rotate' to generate a new token._", + ); + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + // ---- Rotate ---- + if (action === "rotate") { + if (!role) { + return { + content: [ + { + type: "text", + text: "role is required for the 'rotate' action (e.g., 'write', 'read').", + }, + ], + isError: true, + }; + } + + const body = { + data: { + type: "tokens", + attributes: { + "project-id": parseInt(project_id, 10), + role, + }, + }, + }; + + try { + const result = (await client.patch<{ + data: Record | null; + }>("/tokens/rotate", body)) as { + data: Record | null; + }; + + const attrs = (result?.data as any)?.attributes ?? result?.data ?? {}; + const newToken = attrs.token ?? attrs["token-value"] ?? ""; + const masked = newToken.length > 4 ? `****${newToken.slice(-4)}` : "****"; + + return { + content: [ + { + type: "text", + text: `## Token Rotated\n\n**Role:** ${role}\n**New token (masked):** ${masked}\n\n_The full token was returned by the API. Store it securely — it cannot be retrieved again._`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text", text: `Failed to rotate token: ${message}` }], + isError: true, + }; + } + } + + return { + content: [ + { + type: "text", + text: `Invalid action "${action}". Valid actions: list, rotate`, + }, + ], + isError: true, + }; +} diff --git a/src/tools/percy-mcp/management/manage-webhooks.ts b/src/tools/percy-mcp/management/manage-webhooks.ts new file mode 100644 index 0000000..715da9a --- /dev/null +++ b/src/tools/percy-mcp/management/manage-webhooks.ts @@ -0,0 +1,220 @@ +/** + * percy_manage_webhooks — Create, update, list, or delete webhooks for Percy build events. + */ + +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +interface ManageWebhooksArgs { + project_id: string; + action?: string; + webhook_id?: string; + url?: string; + events?: string; + description?: string; +} + +export async function percyManageWebhooks( + args: ManageWebhooksArgs, + config: BrowserStackConfig, +): Promise { + const { + project_id, + action = "list", + webhook_id, + url, + events, + description, + } = args; + const client = new PercyClient(config); + + // ---- List ---- + if (action === "list") { + const response = await client.get<{ + data: Record[] | null; + }>(`/webhook-configs`, { + "filter[project-id]": project_id, + }); + + const webhooks = Array.isArray(response?.data) ? response.data : []; + + if (webhooks.length === 0) { + return { + content: [ + { type: "text", text: "_No webhooks configured for this project._" }, + ], + }; + } + + const lines: string[] = []; + lines.push(`## Webhooks for Project ${project_id}`); + lines.push(""); + lines.push("| ID | URL | Events | Description |"); + lines.push("|----|-----|--------|-------------|"); + + for (const webhook of webhooks) { + const attrs = (webhook as any).attributes ?? webhook; + const wUrl = attrs.url ?? "?"; + const wEvents = Array.isArray(attrs.events) + ? attrs.events.join(", ") + : (attrs.events ?? "?"); + const wDesc = attrs.description ?? ""; + lines.push(`| ${webhook.id ?? "?"} | ${wUrl} | ${wEvents} | ${wDesc} |`); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + // ---- Create ---- + if (action === "create") { + if (!url) { + return { + content: [ + { type: "text", text: "url is required for the 'create' action." }, + ], + isError: true, + }; + } + + const eventArray = events + ? events + .split(",") + .map((e) => e.trim()) + .filter(Boolean) + : []; + + const body = { + data: { + type: "webhook-configs", + attributes: { + url, + events: eventArray, + ...(description ? { description } : {}), + }, + relationships: { + project: { data: { type: "projects", id: project_id } }, + }, + }, + }; + + try { + const result = (await client.post<{ + data: Record | null; + }>("/webhook-configs", body)) as { data: Record | null }; + + const id = (result?.data as any)?.id ?? "?"; + return { + content: [ + { + type: "text", + text: `## Webhook Created\n\n**ID:** ${id}\n**URL:** ${url}\n**Events:** ${eventArray.join(", ") || "all"}\n${description ? `**Description:** ${description}` : ""}`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { type: "text", text: `Failed to create webhook: ${message}` }, + ], + isError: true, + }; + } + } + + // ---- Update ---- + if (action === "update") { + if (!webhook_id) { + return { + content: [ + { + type: "text", + text: "webhook_id is required for the 'update' action.", + }, + ], + isError: true, + }; + } + + const attrs: Record = {}; + if (url) attrs.url = url; + if (events) { + attrs.events = events + .split(",") + .map((e) => e.trim()) + .filter(Boolean); + } + if (description) attrs.description = description; + + const body = { + data: { + type: "webhook-configs", + id: webhook_id, + attributes: attrs, + }, + }; + + try { + await client.patch(`/webhook-configs/${webhook_id}`, body); + return { + content: [ + { + type: "text", + text: `Webhook ${webhook_id} updated successfully.`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { type: "text", text: `Failed to update webhook: ${message}` }, + ], + isError: true, + }; + } + } + + // ---- Delete ---- + if (action === "delete") { + if (!webhook_id) { + return { + content: [ + { + type: "text", + text: "webhook_id is required for the 'delete' action.", + }, + ], + isError: true, + }; + } + + try { + await client.del(`/webhook-configs/${webhook_id}`); + return { + content: [ + { type: "text", text: `Webhook ${webhook_id} deleted successfully.` }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { type: "text", text: `Failed to delete webhook: ${message}` }, + ], + isError: true, + }; + } + } + + return { + content: [ + { + type: "text", + text: `Invalid action "${action}". Valid actions: list, create, update, delete`, + }, + ], + isError: true, + }; +} diff --git a/src/tools/percy-mcp/v2/auth-status.ts b/src/tools/percy-mcp/v2/auth-status.ts new file mode 100644 index 0000000..85757d6 --- /dev/null +++ b/src/tools/percy-mcp/v2/auth-status.ts @@ -0,0 +1,101 @@ +import { percyGet } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + getSession, + formatActiveProject, + formatActiveBuild, +} from "../../../lib/percy-api/percy-session.js"; + +export async function percyAuthStatusV2( + _args: Record, + config: BrowserStackConfig, +): Promise { + let output = `## Percy Auth Status\n\n`; + + const hasCreds = !!( + config["browserstack-username"] && config["browserstack-access-key"] + ); + + output += `| Credential | Status |\n|---|---|\n`; + output += `| BrowserStack Username | ${hasCreds ? config["browserstack-username"] : "Not set"} |\n`; + output += `| BrowserStack Access Key | ${hasCreds ? "Set" : "Not set"} |\n`; + output += "\n"; + + // Note about PERCY_TOKEN — it's per-project, not global + output += `> **Note:** PERCY_TOKEN is set per-project, not globally. Use \`percy_create_project\` to get a project token — it will be activated automatically for subsequent calls.\n\n`; + + if (hasCreds) { + output += `### Validation\n\n`; + + // Test BrowserStack API by checking user info (lightweight, won't 500) + const bsAuth = Buffer.from( + `${config["browserstack-username"]}:${config["browserstack-access-key"]}`, + ).toString("base64"); + + try { + const response = await fetch( + "https://api.browserstack.com/api/app_percy/user", + { headers: { Authorization: `Basic ${bsAuth}` } }, + ); + if (response.ok) { + const userData = await response.json(); + const orgName = userData?.organizations?.[0]?.name; + const orgId = userData?.organizations?.[0]?.id; + output += `**BrowserStack API:** Connected\n`; + if (orgName) output += `**Organization:** ${orgName}`; + if (orgId) output += ` (ID: ${orgId})`; + output += `\n`; + } else { + output += `**BrowserStack API:** ${response.status} ${response.statusText}\n`; + } + } catch (e: any) { + output += `**BrowserStack API:** Failed — ${e.message}\n`; + } + + // Test Percy API read access (use a lightweight endpoint) + try { + await percyGet("/organizations", config, { "page[limit]": "1" }); + output += `**Percy API (Basic Auth):** Connected\n`; + } catch (e: any) { + // If /organizations fails, try a simpler endpoint + try { + await percyGet("/user", config); + output += `**Percy API (Basic Auth):** Connected\n`; + } catch { + output += `**Percy API (Basic Auth):** Limited — ${e.message}\n`; + output += `This is OK. All project-scoped tools work via BrowserStack API.\n`; + } + } + } + + output += "\n### Capabilities\n\n"; + if (hasCreds) { + output += `All Percy MCP tools are available:\n`; + output += `- Create/manage projects and tokens\n`; + output += `- Create builds (URL, screenshot, app BYOS)\n`; + output += `- Read builds, snapshots, comparisons\n`; + output += `- AI analysis, RCA, insights\n`; + output += `- Clone builds, Figma integration\n`; + } else { + output += `No BrowserStack credentials found.\n\n`; + output += `Set your credentials in MCP server config or environment:\n`; + output += `\`\`\`bash\nexport BROWSERSTACK_USERNAME="your_username"\nexport BROWSERSTACK_ACCESS_KEY="your_key"\n\`\`\`\n`; + } + + // Show active session context + const session = getSession(); + if (session.projectName || session.buildId) { + output += `\n### Active Session\n`; + output += formatActiveProject(); + output += formatActiveBuild(); + } + + output += `\n### Getting Started\n\n`; + output += `1. \`percy_create_project\` — Create/access a project (sets active token)\n`; + output += `2. \`percy_create_build\` — Create a web build with URLs or screenshots\n`; + output += `3. \`percy_create_app_build\` — Create an app BYOS build (works with sample data)\n`; + output += `4. \`percy_get_projects\` — List all projects in your org\n`; + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/clone-build.ts b/src/tools/percy-mcp/v2/clone-build.ts new file mode 100644 index 0000000..3be44b8 --- /dev/null +++ b/src/tools/percy-mcp/v2/clone-build.ts @@ -0,0 +1,682 @@ +/** + * percy_clone_build — Clone a build into a different project with 100% parity. + * + * Preserves: snapshot names, all widths, all browsers, all device info. + * + * Two modes (auto-selected): + * 1. URL Replay (`percy snapshot`): URL-named snapshots → full DOM re-render + * 2. Screenshot Clone (direct API): Named snapshots → downloads all screenshots, + * re-uploads via tile API. Each snapshot keeps its exact name with all + * width/browser/device comparisons intact. + * + * Screenshot clone uses tile-based API which requires app-type project. + * If target project is web-type, creates with same name as app-type. + */ + +import { + percyGet, + percyTokenPost, + getOrCreateProjectToken, + getPercyAuthHeaders, +} from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { createHash } from "crypto"; +import { execFile, spawn } from "child_process"; +import { promisify } from "util"; +import { writeFile, unlink, mkdtemp } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; + +const execFileAsync = promisify(execFile); + +async function getGitBranch(): Promise { + try { + return ( + ( + await execFileAsync("git", ["branch", "--show-current"]) + ).stdout.trim() || "main" + ); + } catch { + return "main"; + } +} + +/** Strip base64 padding — Percy requires strict base64 (RFC 4648 §4.1) */ +function toStrictBase64(buffer: Buffer): string { + return buffer.toString("base64").replace(/=+$/, ""); +} + +/** Small delay to avoid rate limiting */ +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +interface ComparisonInfo { + width: number; + height: number; + tagName: string; + osName: string; + osVersion: string; + browserName: string; + browserVersion: string; + orientation: string; + imageUrl: string | null; +} + +interface SnapshotInfo { + id: string; + name: string; + displayName: string; + widths: number[]; + enableJavascript: boolean; + testCase: string | null; + comparisons: ComparisonInfo[]; +} + +interface CloneBuildArgs { + source_build_id: string; + target_project_name: string; + target_token?: string; + branch?: string; +} + +export async function percyCloneBuildV2( + args: CloneBuildArgs, + config: BrowserStackConfig, +): Promise { + const branch = args.branch || (await getGitBranch()); + + let output = `## Percy Build Clone\n\n`; + output += `**Source:** Build #${args.source_build_id}\n`; + output += `**Target:** ${args.target_project_name}\n`; + output += `**Branch:** ${branch}\n\n`; + + // ── Step 1: Read source build ───────────────────────────────────────── + + let sourceBuild: any; + try { + sourceBuild = await percyGet(`/builds/${args.source_build_id}`, config); + } catch (e: any) { + return { + content: [ + { type: "text", text: `Failed to read source build: ${e.message}` }, + ], + isError: true, + }; + } + + const sourceAttrs = sourceBuild?.data?.attributes || {}; + const buildType = sourceAttrs.type || "web"; + + output += `Source: **${sourceAttrs.state}** — ${sourceAttrs["total-snapshots"]} snapshots, type: ${buildType}\n\n`; + + // ── Step 2: Get ALL snapshot details with ALL comparisons ───────────── + + const headers = getPercyAuthHeaders(config); + const baseUrl = "https://percy.io/api/v1"; + + let allSnapshotIds: string[] = []; + try { + const items = await percyGet("/build-items", config, { + "filter[build-id]": args.source_build_id, + "page[limit]": "50", + }); + const itemList = items?.data || []; + for (const item of itemList) { + const a = item.attributes || item; + const ids = a["snapshot-ids"] || a.snapshotIds || []; + if (ids.length > 0) { + allSnapshotIds.push(...ids.map(String)); + } else if (a["cover-snapshot-id"] || a.coverSnapshotId) { + allSnapshotIds.push( + String(a["cover-snapshot-id"] || a.coverSnapshotId), + ); + } + } + allSnapshotIds = [...new Set(allSnapshotIds)]; + } catch (e: any) { + return { + content: [ + { type: "text", text: `Failed to read snapshots: ${e.message}` }, + ], + isError: true, + }; + } + + output += `Found **${allSnapshotIds.length}** snapshots.\n\n`; + + if (allSnapshotIds.length === 0) { + output += `Nothing to clone.\n`; + return { content: [{ type: "text", text: output }] }; + } + + // Read snapshot metadata with ALL comparison details (all browsers, all widths) + const snapshots: SnapshotInfo[] = []; + let totalComps = 0; + + for (const snapId of allSnapshotIds) { + try { + const snapResponse = await fetch( + `${baseUrl}/snapshots/${snapId}?include=comparisons.head-screenshot.image,comparisons.comparison-tag`, + { headers }, + ); + if (!snapResponse.ok) continue; + + const snapJson = await snapResponse.json(); + const sa = snapJson.data?.attributes || {}; + const included = snapJson.included || []; + + const byTypeId = new Map(); + for (const item of included) { + byTypeId.set(`${item.type}:${item.id}`, item); + } + + const compRefs = snapJson.data?.relationships?.comparisons?.data || []; + const comparisons: ComparisonInfo[] = []; + const widthSet = new Set(); + + for (const ref of compRefs) { + const comp = byTypeId.get(`comparisons:${ref.id}`); + if (!comp) continue; + + const width = comp.attributes?.width || 1280; + widthSet.add(width); + + let imageUrl: string | null = null; + let height = 800; + + const hsRef = comp.relationships?.["head-screenshot"]?.data; + if (hsRef) { + const ss = byTypeId.get(`screenshots:${hsRef.id}`); + const imgRef = ss?.relationships?.image?.data; + if (imgRef) { + const img = byTypeId.get(`images:${imgRef.id}`); + if (img) { + imageUrl = img.attributes?.url || null; + height = img.attributes?.height || 800; + } + } + } + + // Extract REAL device/browser info from comparison tag + const tagRef = comp.relationships?.["comparison-tag"]?.data; + let tagName = "Chrome"; + let osName = ""; + let osVersion = ""; + let browserName = "Chrome"; + let browserVersion = ""; + let orientation = "portrait"; + + if (tagRef) { + const tag = byTypeId.get(`comparison-tags:${tagRef.id}`); + if (tag?.attributes) { + const ta = tag.attributes; + tagName = ta.name || tagName; + osName = ta["os-name"] || osName; + osVersion = ta["os-version"] || osVersion; + browserName = ta["browser-name"] || browserName; + browserVersion = ta["browser-version"] || browserVersion; + orientation = ta.orientation || orientation; + } + } + + comparisons.push({ + width, + height, + tagName, + osName, + osVersion, + browserName, + browserVersion, + orientation, + imageUrl, + }); + } + + totalComps += comparisons.length; + snapshots.push({ + id: snapId, + name: sa.name || `Snapshot ${snapId}`, + displayName: sa["display-name"] || sa.name || "", + widths: [...widthSet].sort(), + enableJavascript: sa["enable-javascript"] || false, + testCase: sa["test-case-name"] || null, + comparisons, + }); + } catch { + /* skip failed snapshots */ + } + } + + // Show what we found + const browsers = new Set(); + const widths = new Set(); + for (const snap of snapshots) { + for (const c of snap.comparisons) { + browsers.add(c.browserName || c.tagName); + widths.add(c.width); + } + } + + output += `Read **${snapshots.length}** snapshots, **${totalComps}** comparisons\n`; + output += `Browsers: ${[...browsers].join(", ")}\n`; + output += `Widths: ${[...widths].sort((a, b) => a - b).join(", ")}px\n\n`; + + // ── Step 3: Ensure Percy CLI is available (auto-install if missing) ─── + + let hasCli = false; + try { + await execFileAsync("npx", ["@percy/cli", "--version"]); + hasCli = true; + } catch { + output += `Installing Percy CLI...\n`; + try { + await execFileAsync("npm", ["install", "-g", "@percy/cli"], { + timeout: 60000, + }); + hasCli = true; + output += `Percy CLI installed.\n\n`; + } catch { + hasCli = false; + } + } + + // ── Step 4: Determine clone mode ────────────────────────────────────── + + const hasUrlNames = snapshots.some( + (s) => s.name.startsWith("http://") || s.name.startsWith("https://"), + ); + + if (hasCli && hasUrlNames) { + // URL Replay: Percy CLI re-snapshots with full DOM/CSS/JS + let targetToken: string; + if (args.target_token) { + targetToken = args.target_token; + } else { + try { + targetToken = await getOrCreateProjectToken( + args.target_project_name, + config, + ); + } catch (e: any) { + return { + content: [ + { type: "text", text: `Failed to get target token: ${e.message}` }, + ], + isError: true, + }; + } + } + return await replayWithPercyCli( + output, + snapshots, + targetToken, + branch, + args.target_project_name, + ); + } + + // ── Screenshot Clone via tile API ───────────────────────────────────── + // Tile-based API preserves: exact snapshot names, all widths, all browsers. + // MUST be app-type project — web projects require DOM resources for snapshots + // and reject tile-based uploads. This is an immutable Percy API constraint. + + let targetToken: string; + const actualProjectName = args.target_project_name; + + if (args.target_token) { + targetToken = args.target_token; + } else { + // Always request app type — tiles only work on app/automate/generic projects. + // If project exists as web-type, this will fail and we retry with a suffix. + try { + targetToken = await getOrCreateProjectToken( + args.target_project_name, + config, + "app", + ); + } catch { + // Project exists as web-type — can't use tiles on it. + // Create companion project with same name + "-screenshots" as app type. + const altName = `${args.target_project_name}-screenshots`; + output += `"${args.target_project_name}" is web-type (needs DOM resources).\n`; + output += `Creating **${altName}** (app-type) for screenshot clone.\n\n`; + try { + targetToken = await getOrCreateProjectToken(altName, config, "app"); + } catch (e: any) { + return { + content: [ + { type: "text", text: `Failed to create project: ${e.message}` }, + ], + isError: true, + }; + } + } + } + + return await cloneViaApi( + output, + snapshots, + targetToken, + branch, + actualProjectName, + totalComps, + ); +} + +// ── URL Replay (Percy CLI) ────────────────────────────────────────────────── + +async function replayWithPercyCli( + output: string, + snapshots: SnapshotInfo[], + token: string, + branch: string, + projectName: string, +): Promise { + output += `### Mode: URL Replay (Percy CLI)\n\n`; + output += `**Project:** ${projectName}\n`; + output += `Percy CLI will re-snapshot each page with full resource discovery.\n\n`; + + let yamlContent = ""; + const uniqueNames = new Set(); + + for (const snap of snapshots) { + if (uniqueNames.has(snap.name)) continue; + uniqueNames.add(snap.name); + + const name = snap.displayName || snap.name; + const widths = snap.widths.length > 0 ? snap.widths : [1280]; + + yamlContent += `- name: "${name}"\n`; + if (snap.name.startsWith("http://") || snap.name.startsWith("https://")) { + yamlContent += ` url: ${snap.name}\n`; + } else { + yamlContent += ` url: "UNKNOWN"\n`; + } + yamlContent += ` waitForTimeout: 3000\n`; + if (snap.enableJavascript) { + yamlContent += ` enableJavaScript: true\n`; + } + if (snap.testCase) { + yamlContent += ` testCase: "${snap.testCase}"\n`; + } + yamlContent += ` widths:\n`; + widths.forEach((w) => { + yamlContent += ` - ${w}\n`; + }); + } + + const hasUrls = snapshots.some( + (s) => s.name.startsWith("http://") || s.name.startsWith("https://"), + ); + + if (!hasUrls) { + output += `Snapshots don't have URL names. Use \`percy_create_build\` with URLs.\n`; + return { content: [{ type: "text", text: output }] }; + } + + const tmpDir = await mkdtemp(join(tmpdir(), "percy-clone-")); + const configPath = join(tmpDir, "snapshots.yml"); + await writeFile(configPath, yamlContent); + + const child = spawn("npx", ["@percy/cli", "snapshot", configPath], { + env: { ...process.env, PERCY_TOKEN: token, PERCY_BRANCH: branch }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + + let buildUrl = ""; + let stdoutData = ""; + + child.stdout?.on("data", (d: Buffer) => { + const text = d.toString(); + stdoutData += text; + const match = text.match(/https:\/\/percy\.io\/[^\s]+\/builds\/\d+/); + if (match) buildUrl = match[0]; + }); + child.stderr?.on("data", (d: Buffer) => { + stdoutData += d.toString(); + }); + + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 30000); + child.on("close", () => { + clearTimeout(timeout); + resolve(); + }); + const check = setInterval(() => { + if (buildUrl) { + clearTimeout(timeout); + clearInterval(check); + resolve(); + } + }, 500); + }); + + child.unref(); + + setTimeout(async () => { + try { + await unlink(configPath); + } catch { + /* ignore */ + } + }, 120000); + + output += `**Replaying ${uniqueNames.size} snapshots...**\n\n`; + + if (buildUrl) { + output += `**Build URL:** ${buildUrl}\n\n`; + output += `Percy CLI is re-snapshotting with full resource discovery.\n`; + output += `Results ready in 1-3 minutes.\n`; + } else { + const percyLines = stdoutData + .split("\n") + .filter((l) => l.includes("[percy")) + .slice(0, 10); + if (percyLines.length > 0) { + output += `**Percy output:**\n\`\`\`\n${percyLines.join("\n")}\n\`\`\`\n`; + } else { + output += `Percy is processing in background. Check dashboard.\n`; + } + } + + return { content: [{ type: "text", text: output }] }; +} + +// ── Screenshot Clone (direct API with tiles) ──────────────────────────────── +// +// Full parity clone: same snapshot names, all widths, all browsers, all devices. +// +// API flow per snapshot: +// 1. POST /builds/:id/snapshots → exact source name +// 2. For each comparison (width × browser): +// a. Download screenshot image +// b. POST /snapshots/:id/comparisons → tag (browser/device) + tile SHA +// c. POST /comparisons/:id/tiles → upload image (strict base64) +// d. POST /comparisons/:id/finalize → finalize comparison +// 3. POST /builds/:id/finalize → finalize build +// + +async function cloneViaApi( + output: string, + snapshots: SnapshotInfo[], + token: string, + branch: string, + projectName: string, + totalComps: number, +): Promise { + output += `### Mode: Screenshot Clone (full parity)\n\n`; + output += `**Project:** ${projectName}\n`; + output += `Cloning ${snapshots.length} snapshots, ${totalComps} comparisons.\n\n`; + + const commitSha = createHash("sha1") + .update(Date.now().toString()) + .digest("hex"); + + // Step 1: Create build + let buildResult: any; + try { + buildResult = await percyTokenPost("/builds", token, { + data: { + type: "builds", + attributes: { branch, "commit-sha": commitSha }, + }, + }); + } catch (e: any) { + output += `Failed to create build: ${e.message}\n`; + return { content: [{ type: "text", text: output }], isError: true }; + } + + const buildId = buildResult?.data?.id; + const buildUrl = buildResult?.data?.attributes?.["web-url"] || ""; + + output += `Build: **#${buildId}**`; + if (buildUrl) output += ` — ${buildUrl}`; + output += "\n\n"; + + let clonedSnaps = 0; + let clonedComps = 0; + let failedComps = 0; + + for (const snap of snapshots) { + const compsWithImages = snap.comparisons.filter((c) => c.imageUrl); + if (compsWithImages.length === 0) { + output += `- ${snap.name} — no screenshots, skipped\n`; + continue; + } + + try { + // Step 2: Create snapshot with EXACT source name + const snapResult = await percyTokenPost( + `/builds/${buildId}/snapshots`, + token, + { + data: { + type: "snapshots", + attributes: { name: snap.name }, + }, + }, + ); + const newSnapId = snapResult?.data?.id; + if (!newSnapId) { + output += `- ${snap.name} — snapshot creation failed\n`; + continue; + } + + let snapCompCount = 0; + + // Step 3: Create comparison for EACH width × browser combo + for (const comp of compsWithImages) { + try { + // Download screenshot + const imgResponse = await fetch(comp.imageUrl!); + if (!imgResponse.ok) { + failedComps++; + continue; + } + + const imgBuffer = Buffer.from(await imgResponse.arrayBuffer()); + const sha = createHash("sha256").update(imgBuffer).digest("hex"); + const base64 = toStrictBase64(imgBuffer); + + // Create comparison: attributes (required) + relationships (tag + tiles) + const compResult = await percyTokenPost( + `/snapshots/${newSnapId}/comparisons`, + token, + { + data: { + type: "comparisons", + attributes: { + "external-debug-url": null, + "dom-info-sha": null, + }, + relationships: { + tag: { + data: { + type: "tag", + attributes: { + name: comp.tagName, + width: comp.width, + height: comp.height, + "os-name": comp.osName || "", + "os-version": comp.osVersion || "", + "browser-name": comp.browserName || "", + "browser-version": comp.browserVersion || "", + orientation: comp.orientation || "portrait", + }, + }, + }, + tiles: { + data: [ + { + type: "tiles", + attributes: { + sha, + "status-bar-height": 0, + "nav-bar-height": 0, + "header-height": 0, + "footer-height": 0, + fullscreen: false, + }, + }, + ], + }, + }, + }, + }, + ); + + const compId = compResult?.data?.id; + if (compId) { + // Upload tile image + await percyTokenPost(`/comparisons/${compId}/tiles`, token, { + data: { + type: "tiles", + attributes: { "base64-content": base64 }, + }, + }); + // Finalize comparison + await percyTokenPost(`/comparisons/${compId}/finalize`, token, {}); + snapCompCount++; + clonedComps++; + } else { + failedComps++; + } + } catch (compErr: any) { + failedComps++; + const msg = compErr.message?.slice(0, 100) || "unknown error"; + output += ` ! ${comp.browserName} ${comp.width}px: ${msg}\n`; + } + + // Rate limit protection — 500ms between comparisons (3 API calls each) + await delay(500); + } + + clonedSnaps++; + output += `- **${snap.name}** — ${snapCompCount}/${compsWithImages.length} comparisons\n`; + } catch (e: any) { + output += `- FAILED ${snap.name}: ${e.message}\n`; + } + + // Delay between snapshots to avoid Cloudflare rate limits + await delay(1000); + } + + // Finalize build + try { + await percyTokenPost(`/builds/${buildId}/finalize`, token, {}); + } catch (e: any) { + output += `\nFinalize failed: ${e.message}\n`; + } + + // Summary + output += `\n---\n`; + output += `**Result:** ${clonedSnaps}/${snapshots.length} snapshots, ${clonedComps}/${totalComps} comparisons cloned`; + if (failedComps > 0) output += ` (${failedComps} failed)`; + output += `\n`; + if (buildUrl) output += `**View:** ${buildUrl}\n`; + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/create-app-build.ts b/src/tools/percy-mcp/v2/create-app-build.ts new file mode 100644 index 0000000..a25a6cb --- /dev/null +++ b/src/tools/percy-mcp/v2/create-app-build.ts @@ -0,0 +1,557 @@ +/** + * percy_create_app_build — Create an App Percy BYOS (Bring Your Own Screenshots) build. + * + * Two modes: + * 1. Sample mode (use_sample_data=true): auto-generates 3 devices × 2 screenshots + * using sharp. Zero setup — just provide a project name. + * 2. Custom mode (resources_dir): reads your own device folders with device.json + PNGs. + * + * Expected directory structure for custom mode: + * resources/ + * iPhone_14_Pro/ + * device.json ← { deviceName, osName, osVersion, orientation, deviceScreenSize } + * Home.png + * Settings.png + * Pixel_7/ + * device.json + * Home.png + */ + +import { + percyTokenPost, + getOrCreateProjectToken, +} from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + setActiveProject, + setActiveBuild, +} from "../../../lib/percy-api/percy-session.js"; +import { execFile } from "child_process"; +import { promisify } from "util"; +import { readdir, readFile, stat, writeFile, mkdir } from "fs/promises"; +import { join, basename, extname } from "path"; +import { tmpdir } from "os"; +import { createHash } from "crypto"; +import sharp from "sharp"; + +const execFileAsync = promisify(execFile); + +// ── Types ─────────────────────────────────────────────────────────────────── + +interface DeviceConfig { + deviceName: string; + osName: string; + osVersion?: string; + orientation?: string; + deviceScreenSize: string; // "WIDTHxHEIGHT" + statusBarHeight?: number; + navBarHeight?: number; +} + +interface DeviceEntry { + folder: string; + config: DeviceConfig; + screenshots: string[]; + width: number; + height: number; +} + +export interface CreateAppBuildArgs { + project_name: string; + resources_dir?: string; + use_sample_data?: boolean; + branch?: string; + test_case?: string; +} + +// ── Built-in sample devices ───────────────────────────────────────────────── + +const SAMPLE_DEVICES: { + folder: string; + config: DeviceConfig; + screenshots: string[]; + background: { r: number; g: number; b: number }; +}[] = [ + { + folder: "iPhone_14_Pro", + config: { + deviceName: "iPhone 14 Pro", + osName: "iOS", + osVersion: "16", + orientation: "portrait", + deviceScreenSize: "1179x2556", + statusBarHeight: 132, + navBarHeight: 0, + }, + screenshots: ["Home Screen", "Login Screen"], + background: { r: 230, g: 230, b: 250 }, // light lavender + }, + { + folder: "Pixel_7", + config: { + deviceName: "Pixel 7", + osName: "Android", + osVersion: "13", + orientation: "portrait", + deviceScreenSize: "1080x2400", + statusBarHeight: 118, + navBarHeight: 63, + }, + screenshots: ["Home Screen", "Login Screen"], + background: { r: 230, g: 250, b: 230 }, // light green + }, + { + folder: "Samsung_Galaxy_S23", + config: { + deviceName: "Samsung Galaxy S23", + osName: "Android", + osVersion: "13", + orientation: "portrait", + deviceScreenSize: "1080x2340", + statusBarHeight: 110, + navBarHeight: 63, + }, + screenshots: ["Home Screen", "Login Screen"], + background: { r: 250, g: 240, b: 230 }, // light peach + }, +]; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +async function getGitBranch(): Promise { + try { + return ( + ( + await execFileAsync("git", ["branch", "--show-current"]) + ).stdout.trim() || "main" + ); + } catch { + return "main"; + } +} + +async function getGitSha(): Promise { + try { + return (await execFileAsync("git", ["rev-parse", "HEAD"])).stdout.trim(); + } catch { + return createHash("sha1").update(Date.now().toString()).digest("hex"); + } +} + +function parseDimensions(sizeStr: string): [number, number] | null { + const match = sizeStr.match(/^(\d+)\s*[xX×]\s*(\d+)$/); + if (!match) return null; + return [parseInt(match[1], 10), parseInt(match[2], 10)]; +} + +function readPngDimensions( + buffer: Buffer, +): { width: number; height: number } | null { + if ( + buffer.length >= 24 && + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 + ) { + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20), + }; + } + return null; +} + +// ── Sample data generation ────────────────────────────────────────────────── + +async function generateSampleResources(): Promise { + const ts = Date.now(); + const tmpDir = join(tmpdir(), `percy-app-samples-${ts}`); + await mkdir(tmpDir, { recursive: true }); + + for (const device of SAMPLE_DEVICES) { + const deviceDir = join(tmpDir, device.folder); + await mkdir(deviceDir, { recursive: true }); + + // Write device.json + await writeFile( + join(deviceDir, "device.json"), + JSON.stringify(device.config, null, 2), + ); + + // Generate PNGs at correct dimensions + const dims = parseDimensions(device.config.deviceScreenSize)!; + const [width, height] = dims; + + for (const name of device.screenshots) { + await sharp({ + create: { + width, + height, + channels: 3, + background: device.background, + }, + }) + .png({ compressionLevel: 9 }) + .toFile(join(deviceDir, `${name}.png`)); + } + } + + return tmpDir; +} + +// ── Discovery: find device folders ────────────────────────────────────────── + +async function discoverDevices( + resourcesDir: string, +): Promise<{ devices: DeviceEntry[]; errors: string[] }> { + const devices: DeviceEntry[] = []; + const errors: string[] = []; + + let entries: string[]; + try { + entries = await readdir(resourcesDir); + } catch (e: any) { + return { + devices: [], + errors: [`Cannot read "${resourcesDir}": ${e.message}`], + }; + } + + for (const entry of entries) { + const folderPath = join(resourcesDir, entry); + const folderStat = await stat(folderPath).catch(() => null); + if (!folderStat?.isDirectory()) continue; + + // Must have device.json + const configPath = join(folderPath, "device.json"); + const configExists = await stat(configPath).catch(() => null); + if (!configExists) continue; + + // Parse device.json + let deviceConfig: DeviceConfig; + try { + const raw = await readFile(configPath, "utf-8"); + deviceConfig = JSON.parse(raw); + } catch (e: any) { + errors.push(`${entry}: invalid device.json — ${e.message}`); + continue; + } + + // Validate required fields + if (!deviceConfig.deviceName) { + errors.push(`${entry}: device.json missing "deviceName"`); + continue; + } + if (!deviceConfig.osName) { + errors.push(`${entry}: device.json missing "osName"`); + continue; + } + if (!deviceConfig.deviceScreenSize) { + errors.push(`${entry}: device.json missing "deviceScreenSize"`); + continue; + } + + const dims = parseDimensions(deviceConfig.deviceScreenSize); + if (!dims) { + errors.push( + `${entry}: invalid deviceScreenSize "${deviceConfig.deviceScreenSize}" — expected "WIDTHxHEIGHT"`, + ); + continue; + } + + // Find .png screenshots + const allFiles = await readdir(folderPath); + const screenshots = allFiles + .filter((f) => /\.png$/i.test(f)) + .map((f) => join(folderPath, f)); + + if (screenshots.length === 0) { + errors.push(`${entry}: no .png files found`); + continue; + } + + devices.push({ + folder: entry, + config: deviceConfig, + screenshots, + width: dims[0], + height: dims[1], + }); + } + + return { devices, errors }; +} + +// ── Main handler ──────────────────────────────────────────────────────────── + +export async function percyCreateAppBuildV2( + args: CreateAppBuildArgs, + config: BrowserStackConfig, +): Promise { + const branch = args.branch || (await getGitBranch()); + const commitSha = await getGitSha(); + const usingSamples = args.use_sample_data === true || !args.resources_dir; + + // ── 1. Resolve resources directory ──────────────────────────────────────── + let resourcesDir: string; + if (usingSamples) { + try { + resourcesDir = await generateSampleResources(); + } catch (e: any) { + return { + content: [ + { + type: "text", + text: `Failed to generate sample data: ${e.message}\n\nMake sure \`sharp\` is installed: \`npm install sharp\``, + }, + ], + isError: true, + }; + } + } else { + resourcesDir = args.resources_dir!; + } + + // ── 2. Get app project token ────────────────────────────────────────────── + let token: string; + try { + token = await getOrCreateProjectToken(args.project_name, config, "app"); + setActiveProject({ name: args.project_name, token, type: "app" }); + } catch (e: any) { + return { + content: [ + { + type: "text", + text: `Failed to access app project "${args.project_name}": ${e.message}\n\nMake sure the project exists or your BrowserStack credentials have permission to create app Percy projects.`, + }, + ], + isError: true, + }; + } + + // ── 3. Discover devices & screenshots ───────────────────────────────────── + const { devices, errors: discoveryErrors } = + await discoverDevices(resourcesDir); + + if (devices.length === 0) { + let output = `## App Percy Build — No Valid Devices\n\n`; + output += `No device folders with valid device.json found in \`${resourcesDir}\`.\n\n`; + if (discoveryErrors.length > 0) { + output += `**Errors:**\n`; + for (const err of discoveryErrors) { + output += `- ${err}\n`; + } + } + output += `\n**Expected structure:**\n`; + output += `\`\`\`\nresources/\n iPhone_14_Pro/\n device.json\n Home.png\n Pixel_7/\n device.json\n Home.png\n\`\`\`\n`; + output += `\n**device.json format:**\n`; + output += `\`\`\`json\n{\n "deviceName": "iPhone 14 Pro",\n "osName": "iOS",\n "osVersion": "16",\n "orientation": "portrait",\n "deviceScreenSize": "1290x2796"\n}\n\`\`\`\n`; + return { content: [{ type: "text", text: output }], isError: true }; + } + + const totalScreenshots = devices.reduce( + (sum, d) => sum + d.screenshots.length, + 0, + ); + + // ── 4. Create build ─────────────────────────────────────────────────────── + let buildId: string; + let buildUrl: string; + try { + const buildResponse = await percyTokenPost("/builds", token, { + data: { + type: "builds", + attributes: { branch, "commit-sha": commitSha }, + relationships: { resources: { data: [] } }, + }, + }); + buildId = buildResponse?.data?.id; + buildUrl = buildResponse?.data?.attributes?.["web-url"] || ""; + + // Store in session + if (buildId) { + setActiveBuild({ id: buildId, url: buildUrl, branch }); + } + + if (!buildId) { + return { + content: [ + { + type: "text", + text: "Failed to create app build — no build ID returned.", + }, + ], + isError: true, + }; + } + } catch (e: any) { + return { + content: [ + { type: "text", text: `Failed to create app build: ${e.message}` }, + ], + isError: true, + }; + } + + // ── 5. Upload screenshots per device ────────────────────────────────────── + let output = `## App Percy Build — ${args.project_name}\n\n`; + if (usingSamples) { + output += `> Using built-in sample data (3 devices × 2 screenshots). Pass \`resources_dir\` for custom screenshots.\n\n`; + } + output += `| Field | Value |\n|---|---|\n`; + output += `| **Build ID** | ${buildId} |\n`; + output += `| **Project** | ${args.project_name} |\n`; + output += `| **Branch** | ${branch} |\n`; + output += `| **Devices** | ${devices.length} |\n`; + output += `| **Screenshots** | ${totalScreenshots} |\n`; + output += `| **Token** | \`${token.slice(0, 8)}...${token.slice(-4)}\` |\n`; + if (buildUrl) output += `| **Build URL** | ${buildUrl} |\n`; + output += "\n"; + + if (discoveryErrors.length > 0) { + output += `**Skipped (validation errors):**\n`; + for (const err of discoveryErrors) { + output += `- ${err}\n`; + } + output += `\n`; + } + + let uploaded = 0; + let failed = 0; + + for (const device of devices) { + const dc = device.config; + output += `### ${dc.deviceName}`; + if (dc.osName) + output += ` (${dc.osName}${dc.osVersion ? ` ${dc.osVersion}` : ""})`; + if (dc.orientation) output += ` — ${dc.orientation}`; + output += `\n`; + + for (const screenshotPath of device.screenshots) { + const screenshotName = basename( + screenshotPath, + extname(screenshotPath), + ).replace(/[-_]/g, " "); + + try { + const content = await readFile(screenshotPath); + const sha = createHash("sha256").update(content).digest("hex"); + + // Validate PNG dimensions match device config + const pngDims = readPngDimensions(content); + if (pngDims) { + if ( + pngDims.width !== device.width || + pngDims.height !== device.height + ) { + output += `- ✗ **${screenshotName}** — dimension mismatch: image is ${pngDims.width}x${pngDims.height}, device.json expects ${device.width}x${device.height}\n`; + failed++; + continue; + } + } + + const base64 = content.toString("base64"); + + // Create snapshot + const snapAttrs: Record = { name: screenshotName }; + if (args.test_case) snapAttrs["test-case"] = args.test_case; + + const snapRes = await percyTokenPost( + `/builds/${buildId}/snapshots`, + token, + { data: { type: "snapshots", attributes: snapAttrs } }, + ); + const snapId = snapRes?.data?.id; + if (!snapId) { + output += `- ✗ **${screenshotName}** — snapshot creation failed\n`; + failed++; + continue; + } + + // Create comparison with device tag + const compRes = await percyTokenPost( + `/snapshots/${snapId}/comparisons`, + token, + { + data: { + attributes: { + "external-debug-url": null, + "dom-info-sha": null, + }, + relationships: { + tag: { + data: { + attributes: { + name: dc.deviceName, + width: device.width, + height: device.height, + "os-name": dc.osName, + ...(dc.osVersion ? { "os-version": dc.osVersion } : {}), + orientation: dc.orientation || "portrait", + }, + }, + }, + tiles: { + data: [ + { + attributes: { + sha, + "status-bar-height": dc.statusBarHeight || 0, + "nav-bar-height": dc.navBarHeight || 0, + }, + }, + ], + }, + }, + }, + }, + ); + const compId = compRes?.data?.id; + if (!compId) { + output += `- ✗ **${screenshotName}** — comparison creation failed\n`; + failed++; + continue; + } + + // Upload tile + await percyTokenPost(`/comparisons/${compId}/tiles`, token, { + data: { attributes: { "base64-content": base64 } }, + }); + + // Finalize comparison + await percyTokenPost(`/comparisons/${compId}/finalize`, token, {}); + + uploaded++; + output += `- ✓ **${screenshotName}** (${device.width}×${device.height})\n`; + } catch (e: any) { + output += `- ✗ **${screenshotName}** — ${e.message}\n`; + failed++; + } + } + output += `\n`; + } + + // ── 6. Finalize build ───────────────────────────────────────────────────── + try { + await percyTokenPost(`/builds/${buildId}/finalize`, token, {}); + output += `---\n\n**Build finalized.** ${uploaded}/${totalScreenshots} snapshots uploaded`; + if (failed > 0) output += `, ${failed} failed`; + output += `.\n`; + } catch (e: any) { + output += `---\n\n**Finalize failed:** ${e.message}\n`; + } + + if (buildUrl) { + output += `\n**View build:** ${buildUrl}\n`; + } + + output += `\n### Next Steps\n\n`; + output += `- \`percy_get_build\` with build_id "${buildId}" — View build details\n`; + output += `- \`percy_get_build\` with build_id "${buildId}" and detail "snapshots" — List snapshots\n`; + output += `- \`percy_get_build\` with build_id "${buildId}" and detail "ai_summary" — AI analysis\n`; + output += `- \`percy_get_builds\` — List all builds for this project\n`; + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/create-build.ts b/src/tools/percy-mcp/v2/create-build.ts new file mode 100644 index 0000000..bbc9b12 --- /dev/null +++ b/src/tools/percy-mcp/v2/create-build.ts @@ -0,0 +1,749 @@ +import { + percyTokenPost, + getOrCreateProjectToken, +} from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + setActiveProject, + setActiveBuild, +} from "../../../lib/percy-api/percy-session.js"; +import { execFile, spawn } from "child_process"; +import { promisify } from "util"; +import { + writeFile, + readdir, + readFile, + stat, + unlink, + mkdtemp, +} from "fs/promises"; +import { join, basename, extname } from "path"; +import { tmpdir } from "os"; +import { createHash } from "crypto"; + +const execFileAsync = promisify(execFile); + +async function getGitBranch(): Promise { + try { + return ( + ( + await execFileAsync("git", ["branch", "--show-current"]) + ).stdout.trim() || "main" + ); + } catch { + return "main"; + } +} + +async function getGitSha(): Promise { + try { + return (await execFileAsync("git", ["rev-parse", "HEAD"])).stdout.trim(); + } catch { + return createHash("sha1").update(Date.now().toString()).digest("hex"); + } +} + +async function isPercyCliInstalled(): Promise { + try { + await execFileAsync("npx", ["@percy/cli", "--version"]); + return true; + } catch { + return false; + } +} + +// ── Types ─────────────────────────────────────────────────────────────────── + +interface CreateBuildArgs { + project_name: string; + // Mode: provide ONE + urls?: string; + screenshots_dir?: string; + screenshot_files?: string; + test_command?: string; + // Options + branch?: string; + widths?: string; + type?: string; + snapshot_names?: string; + test_case?: string; +} + +// ── Main handler ──────────────────────────────────────────────────────────── + +export async function percyCreateBuildV2( + args: CreateBuildArgs, + config: BrowserStackConfig, +): Promise { + const branch = args.branch || (await getGitBranch()); + const commitSha = await getGitSha(); + const widths = args.widths + ? args.widths.split(",").map((w) => w.trim()) + : ["375", "1280"]; + + // Get project token and activate in session + let token: string; + try { + token = await getOrCreateProjectToken(args.project_name, config, args.type); + setActiveProject({ name: args.project_name, token, type: args.type }); + } catch (e: any) { + return { + content: [ + { + type: "text", + text: `Failed to access project "${args.project_name}": ${e.message}`, + }, + ], + isError: true, + }; + } + + // Parse custom snapshot names and test cases + const customNames = args.snapshot_names + ? args.snapshot_names.split(",").map((n) => n.trim()) + : []; + // test_case can be single (applies to all) or comma-separated (maps 1:1) + const testCases = args.test_case + ? args.test_case.split(",").map((t) => t.trim()) + : []; + + // Detect mode + if (args.urls) { + return handleUrlSnapshot( + args.project_name, + token, + args.urls, + widths, + branch, + customNames, + testCases, + ); + } else if (args.test_command) { + return handleTestCommand( + args.project_name, + token, + args.test_command, + branch, + ); + } else if (args.screenshots_dir || args.screenshot_files) { + return handleScreenshotUpload( + token, + args, + branch, + commitSha, + customNames, + testCases, + ); + } else { + let output = `## Percy Build — ${args.project_name}\n\n`; + output += `**Token:** ready (${token.slice(0, 8)}...)\n`; + output += `**Branch:** ${branch}\n\n`; + output += `Provide one of:\n`; + output += `- \`urls\` — URLs to snapshot\n`; + output += `- \`test_command\` — test command to wrap\n`; + output += `- \`screenshots_dir\` — folder with PNG/JPG files\n`; + output += `- \`screenshot_files\` — comma-separated file paths\n`; + return { content: [{ type: "text", text: output }] }; + } +} + +// ── URL Snapshot ──────────────────────────────────────────────────────────── + +async function handleUrlSnapshot( + projectName: string, + token: string, + urls: string, + widths: string[], + branch: string, + customNames: string[], + testCases: string[], +): Promise { + const urlList = urls + .split(",") + .map((u) => u.trim()) + .filter(Boolean); + + const cliInstalled = await isPercyCliInstalled(); + + if (!cliInstalled) { + let output = `## Percy CLI Not Installed\n\n`; + output += `Install it first:\n\`\`\`bash\nnpm install -g @percy/cli\n\`\`\`\n\n`; + output += `Then re-run this command.\n`; + return { content: [{ type: "text", text: output }] }; + } + + // Build snapshots.yml with names, test cases, and widths + // Percy CLI YAML supports: name, url, testCase, widths, waitForTimeout + let yamlContent = ""; + urlList.forEach((url, i) => { + const name = + customNames[i] || + (urlList.length === 1 + ? "Homepage" + : url + .replace(/^https?:\/\/[^/]+/, "") + .replace(/^\//, "") + .replace(/[/:?&=]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || `Page ${i + 1}`); + const tc = testCases.length === 1 ? testCases[0] : testCases[i]; + + yamlContent += `- name: "${name}"\n`; + yamlContent += ` url: ${url}\n`; + yamlContent += ` waitForTimeout: 3000\n`; + if (tc) { + yamlContent += ` testCase: "${tc}"\n`; + } + if (widths.length > 0) { + yamlContent += ` widths:\n`; + widths.forEach((w) => { + yamlContent += ` - ${w}\n`; + }); + } + }); + + // Write config to temp file + const tmpDir = await mkdtemp(join(tmpdir(), "percy-mcp-")); + const configPath = join(tmpDir, "snapshots.yml"); + await writeFile(configPath, yamlContent); + + // Launch Percy CLI — EXECUTE AUTOMATICALLY + const child = spawn("npx", ["@percy/cli", "snapshot", configPath], { + env: { ...process.env, PERCY_TOKEN: token }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + + let buildUrl = ""; + let stdoutData = ""; + let stderrData = ""; + + child.stdout?.on("data", (d: Buffer) => { + const text = d.toString(); + stdoutData += text; + const match = text.match(/https:\/\/percy\.io\/[^\s]+\/builds\/\d+/); + if (match) buildUrl = match[0]; + }); + + child.stderr?.on("data", (d: Buffer) => { + stderrData += d.toString(); + }); + + // Wait for build URL or timeout + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 15000); + child.on("close", () => { + clearTimeout(timeout); + resolve(); + }); + const check = setInterval(() => { + if (buildUrl) { + clearTimeout(timeout); + clearInterval(check); + resolve(); + } + }, 500); + }); + + child.unref(); + + // Cleanup temp file later + setTimeout(async () => { + try { + await unlink(configPath); + } catch { + /* ignore */ + } + }, 120000); + + // Extract build ID from URL (format: .../builds/12345) + const buildIdMatch = buildUrl.match(/\/builds\/(\d+)/); + const buildId = buildIdMatch ? buildIdMatch[1] : ""; + + // Store in session + if (buildId || buildUrl) { + setActiveBuild({ id: buildId, url: buildUrl, branch }); + } + + // Build response + let output = `## Percy Build — ${projectName}\n\n`; + + // Always show build info table + output += `| Field | Value |\n|---|---|\n`; + output += `| **Project** | ${projectName} |\n`; + if (buildId) output += `| **Build ID** | ${buildId} |\n`; + output += `| **Branch** | ${branch} |\n`; + output += `| **URLs** | ${urlList.length} |\n`; + output += `| **Widths** | ${widths.join(", ")}px |\n`; + output += `| **Expected Snapshots** | ${urlList.length * widths.length} |\n`; + if (buildUrl) output += `| **Build URL** | ${buildUrl} |\n`; + output += `| **Token** | \`${token.slice(0, 8)}...${token.slice(-4)}\` |\n`; + output += "\n"; + + if (testCases.length > 0) { + output += `**Test cases:** ${testCases.join(", ")}\n\n`; + } + + // Show snapshot details + output += `**Snapshots:**\n`; + urlList.forEach((url, i) => { + const name = + customNames[i] || (urlList.length === 1 ? "Homepage" : `Page ${i + 1}`); + const tc = testCases.length === 1 ? testCases[0] : testCases[i]; + output += `- **${name}**`; + if (tc) output += ` (test: ${tc})`; + output += ` → ${url}\n`; + }); + output += "\n"; + + if (buildUrl) { + output += `**Build started!** Percy is rendering in the background.\n`; + output += `Results ready in 1-3 minutes.\n`; + } else { + const allOutput = (stdoutData + stderrData).trim(); + if (allOutput.includes("ECONNREFUSED") || allOutput.includes("not found")) { + output += `**Error:** URL not reachable. Make sure your app is running.\n\n`; + urlList.forEach((u) => { + output += `- ${u}\n`; + }); + } else if (allOutput) { + output += `**Percy output:**\n\`\`\`\n${allOutput.slice(0, 500)}\n\`\`\`\n`; + } else { + output += `Percy launched in background. Check your Percy dashboard for results.\n`; + } + } + + // Next steps + output += `\n### Next Steps\n\n`; + if (buildId) { + output += `- \`percy_get_build\` with build_id "${buildId}" — View build details\n`; + output += `- \`percy_get_build\` with build_id "${buildId}" and detail "snapshots" — List snapshots\n`; + output += `- \`percy_get_build\` with build_id "${buildId}" and detail "ai_summary" — AI analysis\n`; + } else { + output += `- \`percy_get_builds\` — Find the build ID once processing completes\n`; + } + + return { content: [{ type: "text", text: output }] }; +} + +// ── REMOVED: handleUrlWithTestCases — test cases now handled in YAML directly + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function _handleUrlWithTestCases_UNUSED( + projectName: string, + token: string, + urlList: string[], + widths: string[], + branch: string, + customNames: string[], + testCases: string[], +): Promise { + // Use @percy/core directly via a generated Node.js script + // This is the only way to set testCase on URL-based snapshots + const snapshots = urlList.map((url, i) => { + const name = + customNames[i] || + url + .replace(/^https?:\/\/[^/]+/, "") + .replace(/^\//, "") + .replace(/[/:?&=]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || + `Page ${i + 1}`; + const tc = testCases.length === 1 ? testCases[0] : testCases[i]; + return { url, name, testCase: tc || undefined }; + }); + + const scriptContent = ` +import Percy from '@percy/core'; + +const percy = new Percy({ + token: process.env.PERCY_TOKEN, + snapshot: { widths: [${widths.join(",")}] } +}); + +await percy.start(); +console.log('[percy-mcp] Percy started'); + +const snapshots = ${JSON.stringify(snapshots)}; + +for (const snap of snapshots) { + try { + await percy.snapshot({ + url: snap.url, + name: snap.name, + testCase: snap.testCase, + widths: [${widths.join(",")}], + waitForTimeout: 3000, + }); + console.log('[percy-mcp] ok ' + snap.name + (snap.testCase ? ' (test: ' + snap.testCase + ')' : '')); + } catch (e) { + console.error('[percy-mcp] fail ' + snap.name + ': ' + e.message); + } +} + +await percy.stop(); +console.log('[percy-mcp] Done'); +`; + + const tmpDir = await mkdtemp(join(tmpdir(), "percy-mcp-")); + const scriptPath = join(tmpDir, "snapshot.mjs"); + await writeFile(scriptPath, scriptContent); + + // Run the script in background + const child = spawn("node", [scriptPath], { + env: { ...process.env, PERCY_TOKEN: token }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + + let buildUrl = ""; + const stdoutLines: string[] = []; + + child.stdout?.on("data", (d: Buffer) => { + const text = d.toString(); + stdoutLines.push(text.trim()); + const match = text.match(/https:\/\/percy\.io\/[^\s]+\/builds\/\d+/); + if (match) buildUrl = match[0]; + }); + child.stderr?.on("data", (d: Buffer) => { + stdoutLines.push(d.toString().trim()); + }); + + // Wait for completion (up to 60s — Percy needs to start browser, render, upload) + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 60000); + child.on("close", () => { + clearTimeout(timeout); + resolve(); + }); + }); + + child.unref(); + + setTimeout(async () => { + try { + await unlink(scriptPath); + } catch { + /* ignore */ + } + }, 120000); + + // Build output + let output = `## Percy Build — ${projectName}\n\n`; + output += `**Branch:** ${branch}\n`; + output += `**URLs:** ${urlList.length}\n`; + output += `**Widths:** ${widths.join(", ")}px\n\n`; + + output += `**Snapshots:**\n`; + for (const snap of snapshots) { + const logLine = stdoutLines.find((l) => l.includes(snap.name)); + const ok = logLine?.includes("[percy-mcp] ok"); + output += `- ${ok ? "✓" : "?"} **${snap.name}**`; + if (snap.testCase) output += ` (test: ${snap.testCase})`; + output += ` → ${snap.url}\n`; + } + output += "\n"; + + if (buildUrl) { + output += `**Build URL:** ${buildUrl}\n\n`; + output += `${snapshots.length} snapshot(s) with test cases. Results ready in 1-3 minutes.\n`; + } else { + const percyOutput = stdoutLines + .filter((l) => l.includes("[percy")) + .join("\n"); + if (percyOutput) { + output += `**Percy output:**\n\`\`\`\n${percyOutput.slice(0, 500)}\n\`\`\`\n`; + } else { + output += `Percy is processing. Check dashboard for results.\n`; + } + } + + return { content: [{ type: "text", text: output }] }; +} + +// ── Test Command ──────────────────────────────────────────────────────────── + +async function handleTestCommand( + projectName: string, + token: string, + testCommand: string, + branch: string, +): Promise { + const cliInstalled = await isPercyCliInstalled(); + + if (!cliInstalled) { + let output = `## Percy CLI Not Installed\n\n`; + output += `Install it first:\n\`\`\`bash\nnpm install -g @percy/cli\n\`\`\`\n\n`; + output += `Then re-run this command.\n`; + return { content: [{ type: "text", text: output }] }; + } + + const cmdParts = testCommand.split(" ").filter(Boolean); + + // EXECUTE AUTOMATICALLY + const child = spawn("npx", ["@percy/cli", "exec", "--", ...cmdParts], { + env: { ...process.env, PERCY_TOKEN: token }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + + let buildUrl = ""; + let stdoutData = ""; + + child.stdout?.on("data", (d: Buffer) => { + const text = d.toString(); + stdoutData += text; + const match = text.match(/https:\/\/percy\.io\/[^\s]+\/builds\/\d+/); + if (match) buildUrl = match[0]; + }); + child.stderr?.on("data", (d: Buffer) => { + stdoutData += d.toString(); + }); + + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 15000); + child.on("close", () => { + clearTimeout(timeout); + resolve(); + }); + const check = setInterval(() => { + if (buildUrl) { + clearTimeout(timeout); + clearInterval(check); + resolve(); + } + }, 500); + }); + child.unref(); + + // Extract build ID from URL + const buildIdMatch = buildUrl.match(/\/builds\/(\d+)/); + const buildId = buildIdMatch ? buildIdMatch[1] : ""; + + if (buildId || buildUrl) { + setActiveBuild({ id: buildId, url: buildUrl, branch }); + } + + let output = `## Percy Build — Tests\n\n`; + output += `| Field | Value |\n|---|---|\n`; + output += `| **Project** | ${projectName} |\n`; + if (buildId) output += `| **Build ID** | ${buildId} |\n`; + output += `| **Command** | \`${testCommand}\` |\n`; + output += `| **Branch** | ${branch} |\n`; + output += `| **Token** | \`${token.slice(0, 8)}...${token.slice(-4)}\` |\n`; + if (buildUrl) output += `| **Build URL** | ${buildUrl} |\n`; + output += "\n"; + + if (buildUrl) { + output += `Tests running in background.\n`; + } else if (stdoutData.trim()) { + output += `**Output:**\n\`\`\`\n${stdoutData.trim().slice(0, 500)}\n\`\`\`\n`; + } else { + output += `Tests launched in background. Check Percy dashboard.\n`; + } + + // Next steps + output += `\n### Next Steps\n\n`; + if (buildId) { + output += `- \`percy_get_build\` with build_id "${buildId}" — View build details\n`; + } else { + output += `- \`percy_get_builds\` — Find build once processing completes\n`; + } + + return { content: [{ type: "text", text: output }] }; +} + +// ── Screenshot Upload ─────────────────────────────────────────────────────── + +async function handleScreenshotUpload( + token: string, + args: CreateBuildArgs, + branch: string, + commitSha: string, + customNames: string[], + testCases: string[], +): Promise { + let files: string[] = []; + + if (args.screenshot_files) { + files = args.screenshot_files + .split(",") + .map((f) => f.trim()) + .filter(Boolean); + } + if (args.screenshots_dir) { + try { + const dirStat = await stat(args.screenshots_dir); + if (dirStat.isDirectory()) { + const entries = await readdir(args.screenshots_dir); + files.push( + ...entries + .filter((f) => /\.(png|jpg|jpeg|webp)$/i.test(f)) + .map((f) => join(args.screenshots_dir!, f)), + ); + } + } catch (e: any) { + return { + content: [ + { + type: "text", + text: `Directory not accessible: ${e.message}`, + }, + ], + isError: true, + }; + } + } + + if (files.length === 0) { + return { + content: [{ type: "text", text: "No image files found." }], + isError: true, + }; + } + + // Create build + const buildResponse = await percyTokenPost("/builds", token, { + data: { + type: "builds", + attributes: { branch, "commit-sha": commitSha }, + relationships: { resources: { data: [] } }, + }, + }); + const buildId = buildResponse?.data?.id; + const buildUrl = buildResponse?.data?.attributes?.["web-url"] || ""; + + if (!buildId) { + return { + content: [{ type: "text", text: "Failed to create build." }], + isError: true, + }; + } + + // Store in session + setActiveBuild({ id: buildId, url: buildUrl, branch }); + + let output = `## Percy Build — Screenshot Upload\n\n`; + output += `| Field | Value |\n|---|---|\n`; + output += `| **Build ID** | ${buildId} |\n`; + output += `| **Project** | ${args.project_name} |\n`; + output += `| **Branch** | ${branch} |\n`; + output += `| **Files** | ${files.length} |\n`; + output += `| **Token** | \`${token.slice(0, 8)}...${token.slice(-4)}\` |\n`; + if (buildUrl) output += `| **Build URL** | ${buildUrl} |\n`; + output += "\n"; + + let uploaded = 0; + for (let i = 0; i < files.length; i++) { + const filePath = files[i]; + // Use custom name, or clean filename + const name = + customNames[i] || + basename(filePath, extname(filePath)).replace(/[-_]/g, " "); + + try { + const content = await readFile(filePath); + const sha = createHash("sha256").update(content).digest("hex"); + const base64 = content.toString("base64"); + + let width = 1280; + let height = 800; + if (content[0] === 0x89 && content[1] === 0x50) { + width = content.readUInt32BE(16); + height = content.readUInt32BE(20); + } + + // Create snapshot with optional test case + // If 1 test case provided → applies to all snapshots + // If multiple → maps 1:1 with files + const snapAttrs: Record = { name }; + const tc = testCases.length === 1 ? testCases[0] : testCases[i]; + if (tc) snapAttrs["test-case"] = tc; + + const snapRes = await percyTokenPost( + `/builds/${buildId}/snapshots`, + token, + { data: { type: "snapshots", attributes: snapAttrs } }, + ); + const snapId = snapRes?.data?.id; + if (!snapId) { + output += `- ✗ ${name}: snapshot failed\n`; + continue; + } + + // Create comparison + const compRes = await percyTokenPost( + `/snapshots/${snapId}/comparisons`, + token, + { + data: { + attributes: { + "external-debug-url": null, + "dom-info-sha": null, + }, + relationships: { + tag: { + data: { + attributes: { + name: "Screenshot", + width, + height, + "os-name": "Upload", + "browser-name": "Screenshot", + }, + }, + }, + tiles: { + data: [ + { + attributes: { + sha, + "status-bar-height": 0, + "nav-bar-height": 0, + }, + }, + ], + }, + }, + }, + }, + ); + const compId = compRes?.data?.id; + if (!compId) { + output += `- ✗ ${name}: comparison failed\n`; + continue; + } + + // Upload tile + await percyTokenPost(`/comparisons/${compId}/tiles`, token, { + data: { attributes: { "base64-content": base64 } }, + }); + + // Finalize comparison + await percyTokenPost(`/comparisons/${compId}/finalize`, token, {}); + + uploaded++; + output += `- ✓ **${name}** (${width}×${height})\n`; + } catch (e: any) { + output += `- ✗ ${name}: ${e.message}\n`; + } + } + + // Finalize build + try { + await percyTokenPost(`/builds/${buildId}/finalize`, token, {}); + output += `\n**Build finalized.** ${uploaded}/${files.length} uploaded.\n`; + } catch (e: any) { + output += `\n**Finalize failed:** ${e.message}\n`; + } + if (buildUrl) output += `\n**View:** ${buildUrl}\n`; + + output += `\n### Next Steps\n\n`; + output += `- \`percy_get_build\` with build_id "${buildId}" — View build details\n`; + output += `- \`percy_get_build\` with build_id "${buildId}" and detail "snapshots" — List snapshots\n`; + output += `- \`percy_get_build\` with build_id "${buildId}" and detail "ai_summary" — AI analysis\n`; + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/create-project.ts b/src/tools/percy-mcp/v2/create-project.ts new file mode 100644 index 0000000..d51b01c --- /dev/null +++ b/src/tools/percy-mcp/v2/create-project.ts @@ -0,0 +1,50 @@ +import { getOrCreateProjectToken } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { setActiveProject } from "../../../lib/percy-api/percy-session.js"; + +export async function percyCreateProjectV2( + args: { + name: string; + type?: string; + default_branch?: string; + workflow?: string; + }, + config: BrowserStackConfig, +): Promise { + const token = await getOrCreateProjectToken(args.name, config, args.type); + + const tokenPrefix = token.includes("_") ? token.split("_")[0] : "ci"; + const masked = + token.length > 8 ? `${token.slice(0, 8)}...${token.slice(-4)}` : "****"; + const projectType = args.type || (tokenPrefix === "app" ? "app" : "web"); + + // Store in session — all subsequent calls will use this token + setActiveProject({ + name: args.name, + token, + type: projectType, + }); + + let output = `## Percy Project — ${args.name}\n\n`; + output += `| Field | Value |\n|---|---|\n`; + output += `| **Name** | ${args.name} |\n`; + output += `| **Type** | ${projectType} |\n`; + output += `| **Token** | \`${masked}\` (${tokenPrefix}) |\n`; + output += `| **Status** | Active — token set for this session |\n`; + + output += `\n**Full token:**\n\`\`\`\n${token}\n\`\`\`\n`; + output += `\n> Token is now **active** for all subsequent Percy commands in this session. No need to set PERCY_TOKEN manually.\n`; + + output += `\n### Next Steps\n\n`; + if (projectType === "app") { + output += `- \`percy_create_app_build\` with project_name "${args.name}" — Create app BYOS build\n`; + output += `- \`percy_create_app_build\` with project_name "${args.name}" (no resources_dir) — Quick test with sample data\n`; + } else { + output += `- \`percy_create_build\` with project_name "${args.name}" and urls "http://localhost:3000" — Snapshot URLs\n`; + output += `- \`percy_create_build\` with project_name "${args.name}" and screenshots_dir "./screenshots" — Upload screenshots\n`; + } + output += `- \`percy_get_builds\` — List builds for this project\n`; + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/discover-urls.ts b/src/tools/percy-mcp/v2/discover-urls.ts new file mode 100644 index 0000000..75917b4 --- /dev/null +++ b/src/tools/percy-mcp/v2/discover-urls.ts @@ -0,0 +1,46 @@ +import { percyPost, percyGet } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyDiscoverUrls( + args: { project_id: string; sitemap_url?: string; action?: string }, + config: BrowserStackConfig, +): Promise { + const action = args.action || (args.sitemap_url ? "create" : "list"); + + if (action === "create" && args.sitemap_url) { + const result = await percyPost("/sitemaps", config, { + data: { + type: "sitemaps", + attributes: { url: args.sitemap_url, "project-id": args.project_id }, + }, + }); + const urls = + result?.included?.filter((i: any) => i.type === "sitemap-urls") || []; + let output = `## URLs Discovered from Sitemap\n\n`; + output += `**Sitemap:** ${args.sitemap_url}\n`; + output += `**URLs found:** ${urls.length}\n\n`; + urls.forEach((u: any, i: number) => { + output += `${i + 1}. ${u.attributes?.url || u.url || "?"}\n`; + }); + if (urls.length === 0) + output += `No URLs found in sitemap. Check the URL.\n`; + output += `\nUse these URLs with \`percy_create_build\` to snapshot them.\n`; + return { content: [{ type: "text", text: output }] }; + } + + // List existing sitemaps + const response = await percyGet("/sitemaps", config, { + project_id: args.project_id, + }); + const sitemaps = response?.data || []; + let output = `## Sitemaps for Project\n\n`; + if (!sitemaps.length) { + output += `No sitemaps found. Create one with \`sitemap_url\` parameter.\n`; + } else { + sitemaps.forEach((s: any, i: number) => { + output += `${i + 1}. ${s.attributes?.url || "?"} (${s.attributes?.state || "?"})\n`; + }); + } + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/figma-baseline.ts b/src/tools/percy-mcp/v2/figma-baseline.ts new file mode 100644 index 0000000..df33c6f --- /dev/null +++ b/src/tools/percy-mcp/v2/figma-baseline.ts @@ -0,0 +1,26 @@ +import { percyPost } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyFigmaBaseline( + args: { project_slug: string; branch: string; build_id: string }, + config: BrowserStackConfig, +): Promise { + await percyPost("/design/figma/update-baseline", config, { + data: { + attributes: { + "project-slug": args.project_slug, + branch: args.branch, + "build-id": args.build_id, + }, + }, + }); + + let output = `## Figma Baseline Updated\n\n`; + output += `**Project:** ${args.project_slug}\n`; + output += `**Branch:** ${args.branch}\n`; + output += `**Build:** ${args.build_id}\n`; + output += `Baseline has been updated from the latest Figma designs.\n`; + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/figma-build.ts b/src/tools/percy-mcp/v2/figma-build.ts new file mode 100644 index 0000000..58a0e20 --- /dev/null +++ b/src/tools/percy-mcp/v2/figma-build.ts @@ -0,0 +1,50 @@ +import { percyPost } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyFigmaBuild( + args: { project_slug: string; branch: string; figma_url: string }, + config: BrowserStackConfig, +): Promise { + // Step 1: Fetch design from Figma URL + const fetchResult = await percyPost("/design/figma/fetch-design", config, { + data: { attributes: { "figma-url": args.figma_url } }, + }); + + const nodes = + fetchResult?.data?.attributes?.nodes || fetchResult?.nodes || []; + if (!nodes.length && !fetchResult?.data) { + return { + content: [ + { + type: "text", + text: `No design nodes found at ${args.figma_url}. Check the Figma URL and ensure it points to a frame or component.`, + }, + ], + isError: true, + }; + } + + // Step 2: Create build from design data + const figmaData = Array.isArray(nodes) ? nodes : [nodes]; + const buildResult = await percyPost("/design/figma/create-build", config, { + data: { + attributes: { + branch: args.branch, + "project-slug": args.project_slug, + "figma-url": args.figma_url, + "figma-data": figmaData, + }, + }, + }); + + const buildId = buildResult?.data?.id || "unknown"; + let output = `## Figma Build Created\n\n`; + output += `**Build ID:** ${buildId}\n`; + output += `**Project:** ${args.project_slug}\n`; + output += `**Branch:** ${args.branch}\n`; + output += `**Figma URL:** ${args.figma_url}\n`; + output += `**Design nodes:** ${figmaData.length}\n`; + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/figma-link.ts b/src/tools/percy-mcp/v2/figma-link.ts new file mode 100644 index 0000000..1fa8e21 --- /dev/null +++ b/src/tools/percy-mcp/v2/figma-link.ts @@ -0,0 +1,34 @@ +import { percyGet } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyFigmaLink( + args: { snapshot_id?: string; comparison_id?: string }, + config: BrowserStackConfig, +): Promise { + const params: Record = {}; + if (args.snapshot_id) params.snapshot_id = args.snapshot_id; + if (args.comparison_id) params.comparison_id = args.comparison_id; + + const result = await percyGet("/design/figma/figma-link", config, params); + const link = + result?.data?.attributes?.["figma-url"] || result?.figma_url || null; + + if (!link) { + return { + content: [ + { + type: "text", + text: "No Figma link found for this snapshot/comparison.", + }, + ], + }; + } + + let output = `## Figma Link\n\n`; + output += `**Link:** ${link}\n`; + if (args.snapshot_id) output += `**Snapshot:** ${args.snapshot_id}\n`; + if (args.comparison_id) output += `**Comparison:** ${args.comparison_id}\n`; + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/get-ai-summary.ts b/src/tools/percy-mcp/v2/get-ai-summary.ts new file mode 100644 index 0000000..c2aaee2 --- /dev/null +++ b/src/tools/percy-mcp/v2/get-ai-summary.ts @@ -0,0 +1,130 @@ +import { percyGet } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyGetAiSummary( + args: { build_id: string }, + config: BrowserStackConfig, +): Promise { + // Get build with build-summary include + const response = await percyGet(`/builds/${args.build_id}`, config, { + include: "build-summary", + }); + + const build = response?.data || {}; + const attrs = build.attributes || {}; + const buildNum = attrs["build-number"] || args.build_id; + const state = attrs.state || "unknown"; + + // Get AI details from build attributes + const ai = attrs["ai-details"] || {}; + const potentialBugs = ai["total-potential-bugs"] ?? 0; + const aiVisualDiffs = ai["total-ai-visual-diffs"] ?? 0; + const diffsReduced = ai["total-diffs-reduced-capped"] ?? 0; + const comparisonsWithAi = ai["total-comparisons-with-ai"] ?? 0; + const allCompleted = ai["all-ai-jobs-completed"] ?? false; + const summaryStatus = ai["summary-status"]; + + // Get build summary from included data + const included = response?.included || []; + const summaryObj = included.find((i: any) => i.type === "build-summaries"); + const summaryJson = summaryObj?.attributes?.summary; + + let output = `## Percy Build #${buildNum} — AI Build Summary\n\n`; + + // Check for actual AI data, not just the toggle flag + const hasAiData = + (comparisonsWithAi ?? 0) > 0 || + (potentialBugs ?? 0) > 0 || + (aiVisualDiffs ?? 0) > 0 || + summaryStatus === "ok"; + + if (!hasAiData) { + output += `No AI analysis data found for this build.\n`; + output += `AI may not be enabled, or the build has no visual diffs.\n`; + return { content: [{ type: "text", text: output }] }; + } + + if (state !== "finished") { + output += `Build is **${state}**. AI summary is available after the build finishes.\n`; + return { content: [{ type: "text", text: output }] }; + } + + // AI stats header + output += `**${potentialBugs} potential bug${potentialBugs !== 1 ? "s" : ""}** · **${aiVisualDiffs} AI visual diff${aiVisualDiffs !== 1 ? "s" : ""}**\n\n`; + + if (diffsReduced > 0) { + output += `AI reduced noise by **${diffsReduced}** diff${diffsReduced !== 1 ? "s" : ""}.\n`; + } + if (comparisonsWithAi > 0) { + output += `**${comparisonsWithAi}** comparison${comparisonsWithAi !== 1 ? "s" : ""} analyzed by AI.\n`; + } + output += `AI jobs: ${allCompleted ? "completed" : "in progress"}\n\n`; + + // Parse and display the build summary + if (summaryJson) { + try { + const summary = + typeof summaryJson === "string" ? JSON.parse(summaryJson) : summaryJson; + + if (summary.title) { + output += `### Summary\n\n`; + output += `> ${summary.title}\n\n`; + } + + // Display items (change descriptions with occurrences) + const items = summary.items || summary.changes || []; + if (items.length > 0) { + output += `### Changes\n\n`; + items.forEach((item: any) => { + const title = + item.title || item.description || item.name || String(item); + const occurrences = + item.occurrences || item.count || item.occurrence_count; + output += `- **${title}**`; + if (occurrences) + output += ` (${occurrences} occurrence${occurrences !== 1 ? "s" : ""})`; + output += "\n"; + }); + output += "\n"; + } + + // Display snapshots if available + const snapshots = summary.snapshots || []; + if (snapshots.length > 0) { + output += `### Affected Snapshots\n\n`; + snapshots.forEach((snap: any) => { + const name = snap.name || snap.snapshot_name || "Unknown"; + const changes = snap.changes || snap.items || []; + output += `**${name}**\n`; + changes.forEach((change: any) => { + output += ` - ${change.title || change.description || change}\n`; + }); + }); + output += "\n"; + } + } catch { + // Summary is not valid JSON — show raw + output += `### Raw Summary\n\n`; + output += `${String(summaryJson).slice(0, 1000)}\n\n`; + } + } else if (summaryStatus === "processing") { + output += `### Summary\n\nAI summary is being generated. Try again in a minute.\n`; + } else if (summaryStatus === "skipped") { + const reason = ai["summary-reason"] || "unknown"; + output += `### Summary\n\nAI summary was skipped: ${reason}\n`; + if (reason === "too_many_comparisons") { + output += `(Build has more than 50 comparisons — summaries are only generated for smaller builds)\n`; + } + } else { + output += `### Summary\n\nNo AI summary available for this build.\n`; + } + + // Build URL + const webUrl = attrs["web-url"]; + if (webUrl) { + output += `**View in Percy:** ${webUrl}\n`; + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/get-build-detail.ts b/src/tools/percy-mcp/v2/get-build-detail.ts new file mode 100644 index 0000000..2fc183d --- /dev/null +++ b/src/tools/percy-mcp/v2/get-build-detail.ts @@ -0,0 +1,692 @@ +/** + * percy_get_build — Unified build details tool. + * + * detail param routes to different views: + * - overview: status, stats, AI metrics, browsers, summary preview + * - ai_summary: full AI change descriptions with occurrences + * - changes: changed snapshots with diff ratios and bugs + * - rca: root cause analysis for a comparison + * - logs: failure diagnostics and suggestions + * - network: network request logs for a comparison + * - snapshots: all snapshots with review states + */ + +import { percyGet, percyPost } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { setActiveBuild } from "../../../lib/percy-api/percy-session.js"; + +interface GetBuildArgs { + build_id: string; + detail?: string; + comparison_id?: string; + snapshot_id?: string; +} + +export async function percyGetBuildDetail( + args: GetBuildArgs, + config: BrowserStackConfig, +): Promise { + const detail = args.detail || "overview"; + + switch (detail) { + case "overview": + return getOverview(args.build_id, config); + case "ai_summary": + return getAiSummary(args.build_id, config); + case "changes": + return getChanges(args.build_id, config); + case "rca": + return getRca(args, config); + case "logs": + return getLogs(args.build_id, config); + case "network": + return getNetwork(args, config); + case "snapshots": + return getSnapshots(args.build_id, config); + default: + return { + content: [ + { + type: "text", + text: `Unknown detail: ${detail}. Use: overview, ai_summary, changes, rca, logs, network, snapshots.`, + }, + ], + isError: true, + }; + } +} + +// ── Helper: parse build response ──────────────────────────────────────────── + +function parseBuild(response: any) { + const attrs = response?.data?.attributes || {}; + const ai = attrs["ai-details"] || {}; + const included = response?.included || []; + const rels = response?.data?.relationships || {}; + + // Parse browsers from unique-browsers-across-snapshots (more detailed) + const uniqueBrowsers = (attrs["unique-browsers-across-snapshots"] || []).map( + (b: any) => { + const bf = b.browser_family || {}; + const os = b.operating_system || {}; + const dp = b.device_pool || {}; + return `${bf.name || "?"} ${b.version || ""} on ${os.name || "?"} ${os.version || ""} ${dp.name || ""}`.trim(); + }, + ); + + // Parse build summary + let summaryItems: any[] = []; + const summaryObj = included.find((i: any) => i.type === "build-summaries"); + if (summaryObj?.attributes?.summary) { + const raw = summaryObj.attributes.summary; + try { + summaryItems = typeof raw === "string" ? JSON.parse(raw) : raw; + if (!Array.isArray(summaryItems)) summaryItems = []; + } catch { + summaryItems = []; + } + } + + // Parse commit + const commitObj = included.find((i: any) => i.type === "commits"); + const commit = commitObj?.attributes || {}; + + // Base build + const baseBuildId = rels["base-build"]?.data?.id; + + const hasAiData = + (ai["total-comparisons-with-ai"] ?? 0) > 0 || + (ai["total-potential-bugs"] ?? 0) > 0 || + (ai["total-ai-visual-diffs"] ?? 0) > 0 || + ai["summary-status"] === "ok"; + + return { + attrs, + ai, + included, + uniqueBrowsers, + summaryItems, + commit, + baseBuildId, + hasAiData, + }; +} + +// ── Overview ──────────────────────────────────────────────────────────────── + +async function getOverview( + buildId: string, + config: BrowserStackConfig, +): Promise { + const response = await percyGet(`/builds/${buildId}`, config, { + include: "build-summary,browsers,commit", + }); + + const { + attrs, + ai, + uniqueBrowsers, + summaryItems, + commit, + baseBuildId, + hasAiData, + } = parseBuild(response); + + const buildNum = attrs["build-number"] || buildId; + const webUrl = attrs["web-url"] || ""; + + // Store in session + setActiveBuild({ + id: buildId, + number: buildNum?.toString(), + url: webUrl, + branch: attrs.branch, + }); + + let output = `## Percy Build #${buildNum} (ID: ${buildId})\n\n`; + + // Status table + output += `| Field | Value |\n|---|---|\n`; + output += `| **Build ID** | ${buildId} |\n`; + output += `| **Build #** | ${buildNum} |\n`; + output += `| **State** | ${attrs.state || "?"} |\n`; + output += `| **Branch** | ${attrs.branch || "?"} |\n`; + output += `| **Review** | ${attrs["review-state"] || "—"} (${attrs["review-state-reason"] || ""}) |\n`; + output += `| **Type** | ${attrs.type || "?"} |\n`; + if (webUrl) output += `| **Build URL** | ${webUrl} |\n`; + if (commit.sha) + output += `| **Commit** | ${commit.sha?.slice(0, 8)} — ${commit.message || "no message"} |\n`; + if (commit["author-name"]) + output += `| **Author** | ${commit["author-name"]} |\n`; + if (baseBuildId) output += `| **Base build** | #${baseBuildId} |\n`; + + // Stats + output += `\n### Stats\n\n`; + output += `| Metric | Value |\n|---|---|\n`; + output += `| Snapshots | ${attrs["total-snapshots"] ?? "?"} |\n`; + output += `| Comparisons | ${attrs["total-comparisons"] ?? "?"} |\n`; + output += `| With diffs | ${attrs["total-comparisons-diff"] ?? "—"} |\n`; + output += `| Unreviewed | ${attrs["total-snapshots-unreviewed"] ?? "—"} |\n`; + output += `| Failed | ${attrs["failed-snapshots-count"] ?? 0} |\n`; + output += `| Comments | ${attrs["total-open-comments"] ?? 0} |\n`; + output += `| Issues | ${attrs["total-open-issues"] ?? 0} |\n`; + + // AI metrics + if (hasAiData) { + output += `\n### AI Analysis\n\n`; + output += `| Metric | Value |\n|---|---|\n`; + output += `| Potential bugs | **${ai["total-potential-bugs"] ?? 0}** |\n`; + output += `| AI visual diffs | ${ai["total-ai-visual-diffs"] ?? 0} |\n`; + output += `| Diffs reduced | ${ai["total-diffs-reduced-capped"] ?? 0} filtered |\n`; + output += `| Comparisons analyzed | ${ai["total-comparisons-with-ai"] ?? 0} |\n`; + output += `| Jobs | ${ai["all-ai-jobs-completed"] ? "completed" : "in progress"} |\n`; + } + + // Browsers + if (uniqueBrowsers.length > 0) { + output += `\n### Browsers (${uniqueBrowsers.length})\n\n`; + uniqueBrowsers.forEach((b: string) => { + output += `- ${b}\n`; + }); + } + + // AI Summary preview + if (summaryItems.length > 0) { + output += `\n### AI Summary (${summaryItems.length} changes)\n\n`; + summaryItems.slice(0, 3).forEach((item: any) => { + output += `- **${item.title}** (${item.occurrences} occurrences)\n`; + }); + if (summaryItems.length > 3) { + output += `- ... and ${summaryItems.length - 3} more\n`; + } + output += `\nUse \`detail "ai_summary"\` for full details.\n`; + } + + // Failure info + if (attrs["failure-reason"]) { + output += `\n### Failure\n\n`; + output += `**Reason:** ${attrs["failure-reason"]}\n`; + if (attrs["failure-details"]) + output += `**Details:** ${attrs["failure-details"]}\n`; + const buckets = attrs["error-buckets"]; + if (Array.isArray(buckets) && buckets.length > 0) { + output += `\n**Error categories:**\n`; + buckets.forEach((b: any) => { + output += `- ${b.bucket || b.name || "?"}: ${b.count ?? "?"} snapshot(s)\n`; + }); + } + } + + // Timing + if (attrs["created-at"]) { + output += `\n### Timing\n\n`; + output += `| | |\n|---|---|\n`; + output += `| Created | ${attrs["created-at"]} |\n`; + if (attrs["finished-at"]) + output += `| Finished | ${attrs["finished-at"]} |\n`; + if (attrs["percy-processing-duration"]) + output += `| Processing | ${attrs["percy-processing-duration"]}s |\n`; + if (attrs["build-processing-duration"]) + output += `| Total | ${attrs["build-processing-duration"]}s |\n`; + } + + // URL + if (attrs["web-url"]) output += `\n**View:** ${attrs["web-url"]}\n`; + + // Available details + output += `\n### More Details\n\n`; + output += `| Command | Shows |\n|---|---|\n`; + output += `| \`detail "ai_summary"\` | Full AI change descriptions with occurrences |\n`; + output += `| \`detail "changes"\` | Changed snapshots with diff ratios |\n`; + output += `| \`detail "snapshots"\` | All snapshots with review states |\n`; + output += `| \`detail "logs"\` | Failure diagnostics and suggestions |\n`; + output += `| \`detail "rca"\` | Root cause analysis (needs comparison_id) |\n`; + output += `| \`detail "network"\` | Network logs (needs comparison_id) |\n`; + + return { content: [{ type: "text", text: output }] }; +} + +// ── AI Summary ────────────────────────────────────────────────────────────── + +async function getAiSummary( + buildId: string, + config: BrowserStackConfig, +): Promise { + const response = await percyGet(`/builds/${buildId}`, config, { + include: "build-summary", + }); + + const { attrs, ai, summaryItems, hasAiData } = parseBuild(response); + const buildNum = attrs["build-number"] || buildId; + + let output = `## Build #${buildNum} — AI Summary\n\n`; + + if (!hasAiData) { + output += `No AI analysis data found for this build.\n`; + return { content: [{ type: "text", text: output }] }; + } + + // AI stats + output += `**${ai["total-potential-bugs"] ?? 0} potential bugs** · **${ai["total-ai-visual-diffs"] ?? 0} AI visual diffs** · **${ai["total-diffs-reduced-capped"] ?? 0} diffs filtered**\n\n`; + output += `${ai["total-comparisons-with-ai"] ?? 0} of ${attrs["total-comparisons"] ?? "?"} comparisons analyzed by AI.\n\n`; + + // Summary items with full detail + if (summaryItems.length > 0) { + output += `### Changes (${summaryItems.length})\n\n`; + summaryItems.forEach((item: any, i: number) => { + output += `#### ${i + 1}. ${item.title}\n\n`; + output += `**Occurrences:** ${item.occurrences}\n`; + + const snaps = item.snapshots || []; + if (snaps.length > 0) { + output += `**Affected snapshots:** ${snaps.length}\n`; + const totalComps = snaps.reduce( + (sum: number, s: any) => sum + (s.comparisons?.length || 0), + 0, + ); + output += `**Affected comparisons:** ${totalComps}\n`; + + // Show snapshot IDs and comparison details + output += `\n| Snapshot | Comparisons | Dimensions |\n|---|---|---|\n`; + snaps.slice(0, 5).forEach((s: any) => { + const comps = s.comparisons || []; + const dims = comps + .map((c: any) => `${c.width || "?"}×${c.height || "?"}`) + .join(", "); + output += `| ${s.snapshot_id} | ${comps.length} | ${dims} |\n`; + }); + if (snaps.length > 5) { + output += `| ... | +${snaps.length - 5} more | |\n`; + } + } + output += "\n"; + }); + } else { + output += `AI analysis complete but no summary items generated.\n`; + if (ai["summary-status"] && ai["summary-status"] !== "ok") { + output += `Summary status: ${ai["summary-status"]}`; + if (ai["summary-reason"]) output += ` — ${ai["summary-reason"]}`; + output += "\n"; + } + } + + return { content: [{ type: "text", text: output }] }; +} + +// ── Changes ───────────────────────────────────────────────────────────────── + +async function getChanges( + buildId: string, + config: BrowserStackConfig, +): Promise { + const response = await percyGet("/build-items", config, { + "filter[build-id]": buildId, + "filter[category]": "changed", + "page[limit]": "30", + }); + + const items = response?.data || []; + + if (!items.length) { + return { + content: [ + { + type: "text", + text: `## Build #${buildId} — No Changes\n\nAll snapshots match the baseline.`, + }, + ], + }; + } + + let output = `## Build #${buildId} — Changed Snapshots (${items.length})\n\n`; + output += `| # | Snapshot | Display Name | Diff | Bugs | Review | Comparisons |\n|---|---|---|---|---|---|---|\n`; + + items.forEach((item: any, i: number) => { + const a = item.attributes || item; + const name = a["cover-snapshot-name"] || a.coverSnapshotName || "?"; + const displayName = + a["cover-snapshot-display-name"] || a.coverSnapshotDisplayName || ""; + const diff = + (a["max-diff-ratio"] ?? a.maxDiffRatio) != null + ? ((a["max-diff-ratio"] ?? a.maxDiffRatio) * 100).toFixed(1) + "%" + : "—"; + const bugs = + a["max-bug-total-potential-bugs"] ?? a.maxBugTotalPotentialBugs ?? 0; + const review = a["review-state"] || a.reviewState || "?"; + const count = a["item-count"] || a.itemCount || 1; + output += `| ${i + 1} | ${name} | ${displayName || "—"} | ${diff} | ${bugs} | ${review} | ${count} |\n`; + }); + + output += `\nUse \`percy_get_snapshot\` with a snapshot ID from above for full details.\n`; + + return { content: [{ type: "text", text: output }] }; +} + +// ── RCA ───────────────────────────────────────────────────────────────────── + +async function getRca( + args: GetBuildArgs, + config: BrowserStackConfig, +): Promise { + if (!args.comparison_id) { + return { + content: [ + { + type: "text", + text: `RCA requires a comparison_id.\n\nFind one with:\n\`Use percy_get_build with build_id "${args.build_id}" and detail "changes"\`\nThen: \`Use percy_get_snapshot with snapshot_id "..."\``, + }, + ], + isError: true, + }; + } + + let rcaData: any; + try { + rcaData = await percyGet("/rca", config, { + comparison_id: args.comparison_id, + }); + } catch { + try { + await percyPost("/rca", config, { + data: { + attributes: { + "comparison-id": args.comparison_id, + }, + }, + }); + return { + content: [ + { + type: "text", + text: `## RCA Triggered\n\nStarted for comparison ${args.comparison_id}. Re-run in 30-60 seconds.`, + }, + ], + }; + } catch (e: any) { + return { + content: [ + { + type: "text", + text: `RCA not available: ${e.message}\nThis comparison may not have DOM metadata.`, + }, + ], + isError: true, + }; + } + } + + const status = rcaData?.data?.attributes?.status || "unknown"; + + if (status === "pending") { + return { + content: [ + { + type: "text", + text: `## RCA — Processing\n\nStill analyzing. Try again in 30 seconds.`, + }, + ], + }; + } + + if (status === "failed") { + return { + content: [ + { + type: "text", + text: `## RCA — Failed\n\nAnalysis failed. Missing DOM metadata.`, + }, + ], + }; + } + + let output = `## Root Cause Analysis — Comparison ${args.comparison_id}\n\n`; + + const diffNodes = rcaData?.data?.attributes?.["diff-nodes"] || {}; + const common = diffNodes.common_diffs || []; + const removed = diffNodes.extra_base || []; + const added = diffNodes.extra_head || []; + + if (common.length > 0) { + output += `### Changed (${common.length})\n\n`; + output += `| # | Element | XPath | Diff Type |\n|---|---|---|---|\n`; + common.slice(0, 20).forEach((diff: any, i: number) => { + const head = diff.head || {}; + const tag = head.tagName || "?"; + const xpath = (head.xpath || "").slice(0, 60); + const dt = + head.diff_type === 1 + ? "change" + : head.diff_type === 2 + ? "ignored" + : "?"; + output += `| ${i + 1} | ${tag} | \`${xpath}\` | ${dt} |\n`; + }); + output += "\n"; + } + + if (removed.length > 0) { + output += `### Removed (${removed.length})\n\n`; + removed.slice(0, 10).forEach((n: any) => { + const d = n.node_detail || n; + output += `- ${d.tagName || "element"}`; + if (d.xpath) output += ` — \`${d.xpath.slice(0, 60)}\``; + output += "\n"; + }); + output += "\n"; + } + + if (added.length > 0) { + output += `### Added (${added.length})\n\n`; + added.slice(0, 10).forEach((n: any) => { + const d = n.node_detail || n; + output += `- ${d.tagName || "element"}`; + if (d.xpath) output += ` — \`${d.xpath.slice(0, 60)}\``; + output += "\n"; + }); + output += "\n"; + } + + if (!common.length && !removed.length && !added.length) { + output += `No DOM differences found.\n`; + } + + return { content: [{ type: "text", text: output }] }; +} + +// ── Logs ──────────────────────────────────────────────────────────────────── + +async function getLogs( + buildId: string, + config: BrowserStackConfig, +): Promise { + let output = `## Build #${buildId} — Diagnostics\n\n`; + + // Build info + try { + const buildResponse = await percyGet(`/builds/${buildId}`, config); + const attrs = buildResponse?.data?.attributes || {}; + + if (attrs["failure-reason"]) { + output += `### Failure\n\n`; + output += `**Reason:** ${attrs["failure-reason"]}\n`; + if (attrs["failure-details"]) + output += `**Details:** ${attrs["failure-details"]}\n`; + + const buckets = attrs["error-buckets"]; + if (Array.isArray(buckets) && buckets.length > 0) { + output += `\n**Error categories:**\n`; + output += `| Category | Snapshots |\n|---|---|\n`; + buckets.forEach((b: any) => { + output += `| ${b.bucket || b.name || "?"} | ${b.count ?? "?"} |\n`; + }); + } + output += "\n"; + } else { + output += `Build state: **${attrs.state || "?"}** — no failure recorded.\n\n`; + } + + // Failed snapshots + if ((attrs["failed-snapshots-count"] ?? 0) > 0) { + output += `### Failed Snapshots (${attrs["failed-snapshots-count"]})\n\n`; + try { + const failedResponse = await percyGet( + `/builds/${buildId}/failed-snapshots`, + config, + ); + const failed = failedResponse?.data || []; + if (failed.length > 0) { + output += `| # | Name |\n|---|---|\n`; + failed.slice(0, 10).forEach((s: any, i: number) => { + output += `| ${i + 1} | ${s.attributes?.name || s.name || "?"} |\n`; + }); + output += "\n"; + } + } catch { + output += `Could not fetch failed snapshot details.\n\n`; + } + } + } catch { + output += `Could not fetch build info.\n\n`; + } + + // Suggestions + try { + const sugResponse = await percyGet("/suggestions", config, { + build_id: buildId, + }); + const suggestions = sugResponse?.data || []; + if (Array.isArray(suggestions) && suggestions.length > 0) { + output += `### Suggestions (${suggestions.length})\n\n`; + suggestions.forEach((s: any, i: number) => { + const a = s.attributes || s; + output += `${i + 1}. **${a["bucket-display-name"] || a.bucket || "Issue"}**\n`; + if (a["reason-message"]) output += ` ${a["reason-message"]}\n`; + const steps = a.suggestion || []; + if (Array.isArray(steps)) { + steps.forEach((step: string) => { + output += ` - ${step}\n`; + }); + } + if (a["reference-doc-link"]) + output += ` [Docs](${a["reference-doc-link"]})\n`; + output += "\n"; + }); + } + } catch { + /* suggestions endpoint may not exist */ + } + + return { content: [{ type: "text", text: output }] }; +} + +// ── Network ───────────────────────────────────────────────────────────────── + +async function getNetwork( + args: GetBuildArgs, + config: BrowserStackConfig, +): Promise { + if (!args.comparison_id) { + return { + content: [ + { + type: "text", + text: `Network logs require comparison_id.\n\nFind one with:\n\`Use percy_get_snapshot with snapshot_id "..."\``, + }, + ], + isError: true, + }; + } + + const response = await percyGet("/network-logs", config, { + comparison_id: args.comparison_id, + }); + + const logs = response?.data || response || {}; + const entries = Array.isArray(logs) ? logs : Object.values(logs); + + if (!entries.length) { + return { + content: [ + { + type: "text", + text: `No network logs for comparison ${args.comparison_id}.`, + }, + ], + }; + } + + let output = `## Network Logs — Comparison ${args.comparison_id}\n\n`; + output += `| # | URL | Base | Head | Type | Issue |\n|---|---|---|---|---|---|\n`; + + entries.slice(0, 30).forEach((entry: any, i: number) => { + const url = entry.file || entry.domain || entry.url || "?"; + const base = entry["base-status"] || entry.baseStatus || "—"; + const head = entry["head-status"] || entry.headStatus || "—"; + const type = entry.mimetype || entry.type || "—"; + const summary = entry["status-summary"] || entry.statusSummary || ""; + output += `| ${i + 1} | ${url} | ${base} | ${head} | ${type} | ${summary} |\n`; + }); + + return { content: [{ type: "text", text: output }] }; +} + +// ── Snapshots ─────────────────────────────────────────────────────────────── + +async function getSnapshots( + buildId: string, + config: BrowserStackConfig, +): Promise { + const response = await percyGet("/build-items", config, { + "filter[build-id]": buildId, + "page[limit]": "30", + }); + + const items = response?.data || []; + + if (!items.length) { + return { + content: [ + { + type: "text", + text: `No snapshots found for build ${buildId}.`, + }, + ], + }; + } + + // Count totals + let totalItems = 0; + items.forEach((item: any) => { + totalItems += item.attributes?.["item-count"] || item.itemCount || 1; + }); + + let output = `## Build #${buildId} — Snapshots\n\n`; + output += `**Groups:** ${items.length} | **Total snapshots:** ${totalItems}\n\n`; + output += `| # | Name | Display | Diff | Bugs | Review | Items | Snapshot IDs |\n|---|---|---|---|---|---|---|---|\n`; + + items.forEach((item: any, i: number) => { + const a = item.attributes || item; + const name = a["cover-snapshot-name"] || a.coverSnapshotName || "?"; + const display = + a["cover-snapshot-display-name"] || a.coverSnapshotDisplayName || "—"; + const diff = + (a["max-diff-ratio"] ?? a.maxDiffRatio) != null + ? ((a["max-diff-ratio"] ?? a.maxDiffRatio) * 100).toFixed(1) + "%" + : "—"; + const bugs = + a["max-bug-total-potential-bugs"] ?? a.maxBugTotalPotentialBugs ?? "—"; + const review = a["review-state"] || a.reviewState || "?"; + const count = a["item-count"] || a.itemCount || 1; + const snapIds = (a["snapshot-ids"] || a.snapshotIds || []) + .slice(0, 3) + .join(", "); + const more = + (a["snapshot-ids"] || a.snapshotIds || []).length > 3 ? "..." : ""; + output += `| ${i + 1} | ${name} | ${display} | ${diff} | ${bugs} | ${review} | ${count} | ${snapIds}${more} |\n`; + }); + + output += `\nUse \`percy_get_snapshot\` with a snapshot ID for full comparison details.\n`; + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/get-builds.ts b/src/tools/percy-mcp/v2/get-builds.ts new file mode 100644 index 0000000..d2a8a60 --- /dev/null +++ b/src/tools/percy-mcp/v2/get-builds.ts @@ -0,0 +1,81 @@ +import { percyGet } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { setActiveBuild } from "../../../lib/percy-api/percy-session.js"; + +export async function percyGetBuildsV2( + args: { + project_slug?: string; + branch?: string; + state?: string; + limit?: number; + }, + config: BrowserStackConfig, +): Promise { + let path = "/builds"; + const params: Record = {}; + + if (args.project_slug) { + path = `/projects/${args.project_slug}/builds`; + } + if (args.branch) params["filter[branch]"] = args.branch; + if (args.state) params["filter[state]"] = args.state; + params["page[limit]"] = String(args.limit || 10); + + const response = await percyGet(path, config, params); + const builds = response?.data || []; + + if (builds.length === 0) { + return { + content: [ + { + type: "text", + text: "No builds found. Use `percy_get_projects` to find project slugs, then filter with `project_slug`.", + }, + ], + }; + } + + let output = `## Percy Builds (${builds.length})\n\n`; + output += `| # | Build ID | Build # | Branch | State | Review | Snapshots | Diffs | URL |\n`; + output += `|---|---|---|---|---|---|---|---|---|\n`; + + builds.forEach((b: any, i: number) => { + const attrs = b.attributes || {}; + const num = attrs["build-number"] || "—"; + const branch = attrs.branch || "?"; + const state = attrs.state || "?"; + const review = attrs["review-state"] || "—"; + const snaps = attrs["total-snapshots"] ?? "?"; + const diffs = attrs["total-comparisons-diff"] ?? "—"; + const webUrl = attrs["web-url"] || ""; + const urlShort = webUrl ? `[View](${webUrl})` : "—"; + output += `| ${i + 1} | ${b.id} | #${num} | ${branch} | ${state} | ${review} | ${snaps} | ${diffs} | ${urlShort} |\n`; + }); + + // Set the most recent build as active + const latest = builds[0]; + if (latest) { + const latestAttrs = latest.attributes || {}; + setActiveBuild({ + id: latest.id, + number: latestAttrs["build-number"]?.toString(), + url: latestAttrs["web-url"], + branch: latestAttrs.branch, + }); + } + + // Quick access to latest build + output += `\n### Latest Build: #${latest.attributes?.["build-number"] || latest.id} (ID: ${latest.id})\n\n`; + if (latest.attributes?.["web-url"]) { + output += `**URL:** ${latest.attributes["web-url"]}\n\n`; + } + + output += `### Drill Down\n\n`; + output += `- \`percy_get_build\` with build_id "${latest.id}" — Full overview\n`; + output += `- \`percy_get_build\` with build_id "${latest.id}" and detail "snapshots" — All snapshots\n`; + output += `- \`percy_get_build\` with build_id "${latest.id}" and detail "ai_summary" — AI analysis\n`; + output += `- \`percy_search_builds\` with build_id "${latest.id}" and category "changed" — Only diffs\n`; + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/get-comparison.ts b/src/tools/percy-mcp/v2/get-comparison.ts new file mode 100644 index 0000000..05c65ad --- /dev/null +++ b/src/tools/percy-mcp/v2/get-comparison.ts @@ -0,0 +1,117 @@ +import { percyGet } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyGetComparison( + args: { comparison_id: string }, + config: BrowserStackConfig, +): Promise { + const response = await percyGet( + `/comparisons/${args.comparison_id}`, + config, + { + include: [ + "head-screenshot.image", + "base-screenshot.image", + "diff-image", + "ai-diff-image", + "browser.browser-family", + "comparison-tag", + ].join(","), + }, + ); + + const comp = response?.data || {}; + const attrs = comp.attributes || {}; + const included = response?.included || []; + const ai = attrs["ai-details"] || {}; + + // Resolve browser name + const browserId = comp.relationships?.browser?.data?.id; + const browser = included.find( + (i: any) => i.type === "browsers" && i.id === browserId, + ); + const familyId = browser?.relationships?.["browser-family"]?.data?.id; + const family = included.find( + (i: any) => i.type === "browser-families" && i.id === familyId, + ); + const browserName = `${family?.attributes?.name || "?"} ${browser?.attributes?.version || ""}`; + + let output = `## Comparison #${args.comparison_id}\n\n`; + + output += `| Field | Value |\n|---|---|\n`; + output += `| **Browser** | ${browserName} |\n`; + output += `| **Width** | ${attrs.width || "?"}px |\n`; + output += `| **State** | ${attrs.state || "?"} |\n`; + output += `| **Diff ratio** | ${attrs["diff-ratio"] != null ? (attrs["diff-ratio"] * 100).toFixed(2) + "%" : "—"} |\n`; + output += `| **AI diff ratio** | ${attrs["ai-diff-ratio"] != null ? (attrs["ai-diff-ratio"] * 100).toFixed(2) + "%" : "—"} |\n`; + output += `| **AI state** | ${attrs["ai-processing-state"] || "—"} |\n`; + output += `| **Potential bugs** | ${ai["total-potential-bugs"] ?? "—"} |\n`; + output += `| **AI visual diffs** | ${ai["total-ai-visual-diffs"] ?? "—"} |\n`; + output += `| **Diffs reduced** | ${ai["total-diffs-reduced-capped"] ?? "—"} |\n`; + + // AI regions (the detailed change descriptions) + const regions = attrs["applied-regions"]; + if (Array.isArray(regions) && regions.length > 0) { + output += `\n### AI Detected Changes (${regions.length})\n\n`; + regions.forEach((r: any, i: number) => { + const ignored = r.ignored ? " ~~ignored~~" : ""; + output += `${i + 1}. **${r.change_title || r.change_type || "Change"}** (${r.change_type || "?"})${ignored}\n`; + if (r.change_description) output += ` ${r.change_description}\n`; + if (r.change_reason) output += ` *Reason: ${r.change_reason}*\n`; + if (r.coordinates) { + const c = r.coordinates; + output += ` Region: (${c.x || c.left || 0}, ${c.y || c.top || 0}) → (${c.x2 || c.right || c.x + c.width || 0}, ${c.y2 || c.bottom || c.y + c.height || 0})\n`; + } + output += "\n"; + }); + } + + // Image URLs + const resolveImageUrl = (relName: string): string | null => { + const screenshotId = comp.relationships?.[relName]?.data?.id; + if (!screenshotId) return null; + + // Direct image relationship + const directImage = included.find( + (i: any) => i.type === "images" && i.id === screenshotId, + ); + if (directImage?.attributes?.url) return directImage.attributes.url; + + // Screenshot → image relationship + const screenshot = included.find( + (i: any) => i.type === "screenshots" && i.id === screenshotId, + ); + const imageId = screenshot?.relationships?.image?.data?.id; + if (imageId) { + const image = included.find( + (i: any) => i.type === "images" && i.id === imageId, + ); + return image?.attributes?.url || null; + } + return null; + }; + + output += `### Images\n\n`; + const headUrl = resolveImageUrl("head-screenshot"); + const baseUrl = resolveImageUrl("base-screenshot"); + const diffUrl = resolveImageUrl("diff-image"); + const aiDiffUrl = resolveImageUrl("ai-diff-image"); + + if (headUrl) output += `**Head:** ${headUrl}\n`; + if (baseUrl) output += `**Base:** ${baseUrl}\n`; + if (diffUrl) output += `**Diff:** ${diffUrl}\n`; + if (aiDiffUrl) output += `**AI Diff:** ${aiDiffUrl}\n`; + if (!headUrl && !baseUrl && !diffUrl) output += `No images available.\n`; + + // Error info + if (attrs["error-buckets-exists"]) { + output += `\n### Errors\n\n`; + const assetFailures = attrs["asset-failure-category-counts"]; + if (assetFailures) { + output += `**Asset failures:** ${JSON.stringify(assetFailures)}\n`; + } + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/get-devices.ts b/src/tools/percy-mcp/v2/get-devices.ts new file mode 100644 index 0000000..5b5b0de --- /dev/null +++ b/src/tools/percy-mcp/v2/get-devices.ts @@ -0,0 +1,41 @@ +import { percyGet } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyGetDevices( + args: { build_id?: string }, + config: BrowserStackConfig, +): Promise { + // Get browser families + const families = await percyGet("/browser-families", config); + const familyList = families?.data || []; + + let output = `## Percy Browsers & Devices\n\n`; + output += `### Browser Families\n\n`; + output += `| Name | Slug | ID |\n|---|---|---|\n`; + familyList.forEach((f: any) => { + output += `| ${f.attributes?.name || "?"} | ${f.attributes?.slug || "?"} | ${f.id} |\n`; + }); + + // Get device details if build_id provided + if (args.build_id) { + try { + const devices = await percyGet("/discovery/device-details", config, { + build_id: args.build_id, + }); + const deviceList = devices?.data || devices || []; + if (Array.isArray(deviceList) && deviceList.length) { + output += `\n### Devices for Build ${args.build_id}\n\n`; + output += `| Device | Width | Height |\n|---|---|---|\n`; + deviceList.forEach((d: any) => { + const attrs = d.attributes || d; + output += `| ${attrs.name || "?"} | ${attrs.width || "?"} | ${attrs.height || "?"} |\n`; + }); + } + } catch { + /* device details may not be available */ + } + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/get-insights.ts b/src/tools/percy-mcp/v2/get-insights.ts new file mode 100644 index 0000000..5fd39de --- /dev/null +++ b/src/tools/percy-mcp/v2/get-insights.ts @@ -0,0 +1,63 @@ +import { percyGet } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyGetInsights( + args: { org_slug: string; period?: string; product?: string }, + config: BrowserStackConfig, +): Promise { + const params: Record = { + organization_id: args.org_slug, + period: args.period || "last_30_days", + product: args.product || "web", + }; + + const response = await percyGet("/insights/metrics", config, params); + const data = response?.data?.attributes || response?.data || {}; + + let output = `## Percy Testing Insights — ${args.org_slug}\n\n`; + output += `**Period:** ${params.period}\n**Product:** ${params.product}\n\n`; + + // Review efficiency + const review = data.reviewEfficiency || data["review-efficiency"] || {}; + if (review) { + output += `### Review Efficiency\n`; + output += `| Metric | Value |\n|---|---|\n`; + if (review.meaningfulReviewTimeRatio != null) + output += `| Meaningful review ratio | ${(review.meaningfulReviewTimeRatio * 100).toFixed(0)}% |\n`; + if (review.totalReviews != null) + output += `| Total reviews | ${review.totalReviews} |\n`; + if (review.noisyReviews != null) + output += `| Noisy reviews | ${review.noisyReviews} |\n`; + if (review.medianReviewTimeSeconds != null) + output += `| Median review time | ${review.medianReviewTimeSeconds}s |\n`; + output += "\n"; + } + + // ROI + const roi = data.roiTimeSavings || data["roi-time-savings"] || {}; + if (roi) { + output += `### ROI & Time Savings\n`; + output += `| Metric | Value |\n|---|---|\n`; + if (roi.totalTimeSaved != null) + output += `| Total time saved | ${roi.totalTimeSaved} min |\n`; + if (roi.noDiffPercentage != null) + output += `| No-diff percentage | ${(roi.noDiffPercentage * 100).toFixed(0)}% |\n`; + if (roi.buildsCount != null) output += `| Builds | ${roi.buildsCount} |\n`; + output += "\n"; + } + + // Coverage + const coverage = data.coverage || {}; + if (coverage) { + output += `### Coverage\n`; + output += `| Metric | Value |\n|---|---|\n`; + if (coverage.coveragePercentage != null) + output += `| Coverage | ${coverage.coveragePercentage.toFixed(0)}% |\n`; + if (coverage.activeSnapshotsCount != null) + output += `| Active snapshots | ${coverage.activeSnapshotsCount} |\n`; + output += "\n"; + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/get-projects.ts b/src/tools/percy-mcp/v2/get-projects.ts new file mode 100644 index 0000000..b58b85a --- /dev/null +++ b/src/tools/percy-mcp/v2/get-projects.ts @@ -0,0 +1,53 @@ +import { percyGet } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { setOrg } from "../../../lib/percy-api/percy-session.js"; + +export async function percyGetProjectsV2( + args: { search?: string; limit?: number }, + config: BrowserStackConfig, +): Promise { + const params: Record = {}; + if (args.search) params["filter[search]"] = args.search; + params["page[limit]"] = String(args.limit || 20); + + const response = await percyGet("/projects", config, params); + const projects = response?.data || []; + + if (projects.length === 0) { + return { + content: [ + { + type: "text", + text: "No projects found. Use `percy_create_project` to create one.", + }, + ], + }; + } + + // Extract org slug from the first project's full-slug + const firstSlug = projects[0]?.attributes?.["full-slug"] || ""; + const orgSlug = firstSlug.split("/")[0] || ""; + if (orgSlug) setOrg({ slug: orgSlug }); + + let output = `## Percy Projects (${projects.length})\n\n`; + output += `| # | Name | ID | Type | Slug (for builds) |\n|---|---|---|---|---|\n`; + + projects.forEach((p: any, i: number) => { + const name = p.attributes?.name || "?"; + const type = p.attributes?.type || "?"; + const fullSlug = p.attributes?.["full-slug"] || p.attributes?.slug || "?"; + output += `| ${i + 1} | ${name} | ${p.id} | ${type} | \`${fullSlug}\` |\n`; + }); + + if (orgSlug) { + output += `\n**Organization:** ${orgSlug}\n`; + } + + output += `\n### Usage\n\n`; + output += `- \`percy_get_builds\` with project_slug "${firstSlug}" — List builds for a project\n`; + output += `- \`percy_create_project\` with name "my-project" — Create new project & activate token\n`; + output += `- \`percy_create_build\` with project_name "my-project" — Create a build\n`; + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/get-snapshot.ts b/src/tools/percy-mcp/v2/get-snapshot.ts new file mode 100644 index 0000000..9424fce --- /dev/null +++ b/src/tools/percy-mcp/v2/get-snapshot.ts @@ -0,0 +1,128 @@ +import { percyGet } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyGetSnapshot( + args: { snapshot_id: string }, + config: BrowserStackConfig, +): Promise { + const response = await percyGet(`/snapshots/${args.snapshot_id}`, config, { + include: [ + "comparisons.head-screenshot.image", + "comparisons.base-screenshot.image", + "comparisons.diff-image", + "comparisons.ai-diff-image", + "comparisons.browser.browser-family", + "comparisons.comparison-tag", + ].join(","), + }); + + const snap = response?.data || {}; + const attrs = snap.attributes || {}; + const included = response?.included || []; + + let output = `## Snapshot: ${attrs.name || args.snapshot_id}\n\n`; + + if (attrs["display-name"] && attrs["display-name"] !== attrs.name) { + output += `**Display name:** ${attrs["display-name"]}\n`; + } + + output += `| Field | Value |\n|---|---|\n`; + output += `| **Review** | ${attrs["review-state"] || "—"} (${attrs["review-state-reason"] || "—"}) |\n`; + output += `| **Diff ratio** | ${attrs["diff-ratio"] != null ? (attrs["diff-ratio"] * 100).toFixed(2) + "%" : "—"} |\n`; + output += `| **Test case** | ${attrs["test-case-name"] || "none"} |\n`; + output += `| **Comments** | ${attrs["total-open-comments"] ?? 0} |\n`; + output += `| **Layout** | ${attrs["enable-layout"] ? "enabled" : "disabled"} |\n`; + + // Comparisons table + const comps = included.filter((i: any) => i.type === "comparisons"); + const browsers = new Map( + included + .filter((i: any) => i.type === "browsers") + .map((b: any) => { + const family = included.find( + (f: any) => + f.type === "browser-families" && + f.id === b.relationships?.["browser-family"]?.data?.id, + ); + return [ + b.id, + `${family?.attributes?.name || "?"} ${b.attributes?.version || ""}`, + ]; + }), + ); + const images = new Map( + included + .filter((i: any) => i.type === "images") + .map((img: any) => [img.id, img.attributes]), + ); + + if (comps.length > 0) { + output += `\n### Comparisons (${comps.length})\n\n`; + output += `| Browser | Width | Diff | AI Diff | AI State | Bugs |\n|---|---|---|---|---|---|\n`; + + comps.forEach((c: any) => { + const ca = c.attributes || {}; + const browserId = c.relationships?.browser?.data?.id; + const browserName = browsers.get(browserId) || "?"; + const diff = + ca["diff-ratio"] != null + ? (ca["diff-ratio"] * 100).toFixed(1) + "%" + : "—"; + const aiDiff = + ca["ai-diff-ratio"] != null + ? (ca["ai-diff-ratio"] * 100).toFixed(1) + "%" + : "—"; + const aiState = ca["ai-processing-state"] || "—"; + const bugs = ca["ai-details"]?.["total-potential-bugs"] ?? "—"; + output += `| ${browserName} | ${ca.width || "?"}px | ${diff} | ${aiDiff} | ${aiState} | ${bugs} |\n`; + }); + + // Show AI regions for comparisons that have them + const compsWithRegions = comps.filter( + (c: any) => c.attributes?.["applied-regions"]?.length > 0, + ); + if (compsWithRegions.length > 0) { + output += `\n### AI Detected Changes\n\n`; + for (const c of compsWithRegions) { + const regions = c.attributes["applied-regions"]; + regions.forEach((r: any) => { + const ignored = r.ignored ? " *(ignored)*" : ""; + output += `- **${r.change_title || r.change_type || "Change"}** (${r.change_type || "?"})${ignored}\n`; + if (r.change_description) output += ` ${r.change_description}\n`; + if (r.change_reason) output += ` *Reason: ${r.change_reason}*\n`; + }); + } + } + + // Image URLs for first comparison + const firstComp = comps[0]; + const headScreenshotId = + firstComp?.relationships?.["head-screenshot"]?.data?.id; + const headScreenshot = included.find( + (i: any) => i.type === "screenshots" && i.id === headScreenshotId, + ); + const headImageId = headScreenshot?.relationships?.image?.data?.id; + const headImage = headImageId ? images.get(headImageId) : null; + + if (headImage?.url) { + output += `\n### Images (first comparison)\n\n`; + output += `**Head:** ${headImage.url}\n`; + + const baseScreenshotId = + firstComp?.relationships?.["base-screenshot"]?.data?.id; + const baseScreenshot = included.find( + (i: any) => i.type === "screenshots" && i.id === baseScreenshotId, + ); + const baseImageId = baseScreenshot?.relationships?.image?.data?.id; + const baseImage = baseImageId ? images.get(baseImageId) : null; + if (baseImage?.url) output += `**Base:** ${baseImage.url}\n`; + + const diffImageId = firstComp?.relationships?.["diff-image"]?.data?.id; + const diffImage = diffImageId ? images.get(diffImageId) : null; + if (diffImage?.url) output += `**Diff:** ${diffImage.url}\n`; + } + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/get-test-case-history.ts b/src/tools/percy-mcp/v2/get-test-case-history.ts new file mode 100644 index 0000000..bfdfcde --- /dev/null +++ b/src/tools/percy-mcp/v2/get-test-case-history.ts @@ -0,0 +1,29 @@ +import { percyGet } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyGetTestCaseHistory( + args: { test_case_id: string }, + config: BrowserStackConfig, +): Promise { + const response = await percyGet("/test-case-histories", config, { + test_case_id: args.test_case_id, + }); + const history = response?.data || []; + + if (!history.length) { + return { + content: [{ type: "text", text: "No history found for this test case." }], + }; + } + + let output = `## Test Case History\n\n`; + output += `| # | Build | State | Total | Failed | Unreviewed |\n|---|---|---|---|---|---|\n`; + history.forEach((entry: any, i: number) => { + const attrs = entry.attributes || {}; + const buildId = entry.relationships?.build?.data?.id || "?"; + output += `| ${i + 1} | #${buildId} | ${attrs["review-state"] ?? "?"} | ${attrs["total-snapshots"] ?? "?"} | ${attrs["failed-snapshots"] ?? "?"} | ${attrs["unreviewed-snapshots"] ?? "?"} |\n`; + }); + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/get-test-cases.ts b/src/tools/percy-mcp/v2/get-test-cases.ts new file mode 100644 index 0000000..aa88dd3 --- /dev/null +++ b/src/tools/percy-mcp/v2/get-test-cases.ts @@ -0,0 +1,46 @@ +import { percyGet } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyGetTestCases( + args: { project_id: string; build_id?: string }, + config: BrowserStackConfig, +): Promise { + // Get test cases + const params: Record = { project_id: args.project_id }; + const response = await percyGet("/test-cases", config, params); + const testCases = response?.data || []; + + if (!testCases.length) { + return { + content: [ + { type: "text", text: "No test cases found for this project." }, + ], + }; + } + + let output = `## Test Cases (${testCases.length})\n\n`; + output += `| # | Name | ID |\n|---|---|---|\n`; + testCases.forEach((tc: any, i: number) => { + const name = tc.attributes?.name || tc.name || "?"; + output += `| ${i + 1} | ${name} | ${tc.id} |\n`; + }); + + // If build_id provided, get executions + if (args.build_id) { + const execResponse = await percyGet("/test-case-executions", config, { + build_id: args.build_id, + }); + const executions = execResponse?.data || []; + if (executions.length) { + output += `\n### Executions for Build ${args.build_id}\n\n`; + output += `| Test Case | Total | Failed | Unreviewed | State |\n|---|---|---|---|---|\n`; + executions.forEach((exec: any) => { + const attrs = exec.attributes || {}; + output += `| ${attrs["test-case-name"] || exec.id} | ${attrs["total-snapshots"] ?? "?"} | ${attrs["failed-snapshots"] ?? "?"} | ${attrs["unreviewed-snapshots"] ?? "?"} | ${attrs["review-state"] ?? "?"} |\n`; + }); + } + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/index.ts b/src/tools/percy-mcp/v2/index.ts new file mode 100644 index 0000000..e8ca1bd --- /dev/null +++ b/src/tools/percy-mcp/v2/index.ts @@ -0,0 +1,831 @@ +/** + * Percy MCP Tools v2 — Simplified, production-ready tools. + * + * Key changes from v1: + * - ALL read operations use BrowserStack Basic Auth (not Percy Token) + * - Fewer, more powerful tools (quality > quantity) + * - Every tool tested against real Percy API + * + * Tools (21 total): + * percy_create_project — Create/get a Percy project + * percy_create_build — Create build (URL snapshot / screenshot upload / test wrap) + * percy_get_projects — List projects + * percy_get_builds — List builds with filters + * percy_auth_status — Check auth + * percy_figma_build — Create build from Figma designs + * percy_figma_baseline — Update Figma design baseline + * percy_figma_link — Get Figma link for snapshot/comparison + * percy_get_insights — Testing health metrics + * percy_manage_insights_email — Configure insights email recipients + * percy_get_test_cases — List test cases for a project + * percy_get_test_case_history — Test case execution history + * percy_discover_urls — Discover URLs from sitemaps + * percy_get_devices — List browsers/devices/viewports + * percy_manage_domains — Get/update allowed/error domains + * percy_manage_usage_alerts — Configure usage alert thresholds + * percy_preview_comparison — Trigger on-demand diff recomputation + * percy_search_builds — Advanced build item search + * percy_list_integrations — List org integrations + * percy_migrate_integrations — Migrate integrations between orgs + * percy_create_app_build — Create App Percy BYOS build from device screenshots + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { handleMCPError } from "../../../lib/utils.js"; +import { trackMCP } from "../../../index.js"; +import { z } from "zod"; +import { + handlePercyToolError, + TOOL_HELP, +} from "../../../lib/percy-api/percy-error-handler.js"; + +import { percyCreateProjectV2 } from "./create-project.js"; +import { percyGetProjectsV2 } from "./get-projects.js"; +import { percyGetBuildsV2 } from "./get-builds.js"; +import { percyCreateBuildV2 } from "./create-build.js"; +import { percyAuthStatusV2 } from "./auth-status.js"; +import { percyGetBuildDetail } from "./get-build-detail.js"; +import { percyCloneBuildV2 } from "./clone-build.js"; +import { percyGetSnapshot } from "./get-snapshot.js"; +import { percyGetComparison } from "./get-comparison.js"; +import { percyFigmaBuild } from "./figma-build.js"; +import { percyFigmaBaseline } from "./figma-baseline.js"; +import { percyFigmaLink } from "./figma-link.js"; +import { percyGetInsights } from "./get-insights.js"; +import { percyManageInsightsEmail } from "./manage-insights-email.js"; +import { percyGetTestCases } from "./get-test-cases.js"; +import { percyGetTestCaseHistory } from "./get-test-case-history.js"; +import { percyDiscoverUrls } from "./discover-urls.js"; +import { percyGetDevices } from "./get-devices.js"; +import { percyManageDomains } from "./manage-domains.js"; +import { percyManageUsageAlerts } from "./manage-usage-alerts.js"; +import { percyPreviewComparison } from "./preview-comparison.js"; +import { percySearchBuildItems } from "./search-build-items.js"; +import { percyListIntegrations } from "./list-integrations.js"; +import { percyMigrateIntegrations } from "./migrate-integrations.js"; +import { percyGetAiSummary } from "./get-ai-summary.js"; +import { percyCreateAppBuildV2 } from "./create-app-build.js"; + +export function registerPercyMcpToolsV2( + server: McpServer, + config: BrowserStackConfig, +) { + const tools: Record = {}; + + // ── percy_create_project ──────────────────────────────────────────────── + tools.percy_create_project = server.tool( + "percy_create_project", + "Create a new Percy project (or get token for existing one). Returns project token for CLI use.", + { + name: z.string().describe("Project name"), + type: z + .enum(["web", "automate"]) + .optional() + .describe("Project type (default: auto-detect)"), + }, + async (args) => { + try { + trackMCP( + "percy_create_project", + server.server.getClientVersion()!, + config, + ); + return await percyCreateProjectV2(args, config); + } catch (error) { + return TOOL_HELP.percy_create_project + ? handlePercyToolError(error, TOOL_HELP.percy_create_project, args) + : handleMCPError("percy_create_project", server, config, error); + } + }, + ); + + // ── percy_create_build ────────────────────────────────────────────────── + tools.percy_create_build = server.tool( + "percy_create_build", + "Create a Percy build with snapshots. Handles URL snapshotting (launches real browser), screenshot upload, and test command wrapping — all in one tool. Auto-creates project if needed, auto-detects git branch.", + { + project_name: z + .string() + .describe("Percy project name (auto-creates if doesn't exist)"), + urls: z + .string() + .optional() + .describe( + "Comma-separated URLs to snapshot (e.g., 'http://localhost:3000,http://localhost:3000/about')", + ), + screenshots_dir: z + .string() + .optional() + .describe("Directory path with PNG/JPG screenshots to upload"), + screenshot_files: z + .string() + .optional() + .describe("Comma-separated screenshot file paths"), + test_command: z + .string() + .optional() + .describe("Test command to wrap with Percy (e.g., 'npx cypress run')"), + branch: z.string().optional().describe("Git branch (auto-detected)"), + widths: z + .string() + .optional() + .describe("Viewport widths (default: '375,1280')"), + snapshot_names: z + .string() + .optional() + .describe( + "Custom snapshot names, comma-separated (e.g., 'Homepage,Login Page,Dashboard'). Maps 1:1 with urls or screenshot files.", + ), + test_case: z + .string() + .optional() + .describe("Test case name to attach to all snapshots"), + type: z.enum(["web", "automate"]).optional().describe("Project type"), + }, + async (args) => { + try { + trackMCP( + "percy_create_build", + server.server.getClientVersion()!, + config, + ); + return await percyCreateBuildV2(args, config); + } catch (error) { + return TOOL_HELP.percy_create_build + ? handlePercyToolError(error, TOOL_HELP.percy_create_build, args) + : handleMCPError("percy_create_build", server, config, error); + } + }, + ); + + // ── percy_get_projects ────────────────────────────────────────────────── + tools.percy_get_projects = server.tool( + "percy_get_projects", + "List all Percy projects in your organization.", + { + search: z.string().optional().describe("Search by project name"), + limit: z.number().optional().describe("Max results (default: 20)"), + }, + async (args) => { + try { + trackMCP( + "percy_get_projects", + server.server.getClientVersion()!, + config, + ); + return await percyGetProjectsV2(args, config); + } catch (error) { + return TOOL_HELP.percy_get_projects + ? handlePercyToolError(error, TOOL_HELP.percy_get_projects, args) + : handleMCPError("percy_get_projects", server, config, error); + } + }, + ); + + // ── percy_get_builds ──────────────────────────────────────────────────── + tools.percy_get_builds = server.tool( + "percy_get_builds", + "List Percy builds. Provide project_slug (from percy_get_projects) to filter by project.", + { + project_slug: z + .string() + .optional() + .describe( + "Project slug from percy_get_projects (e.g., 'org-id/project-slug')", + ), + branch: z.string().optional().describe("Filter by branch"), + state: z + .string() + .optional() + .describe("Filter: pending, processing, finished, failed"), + limit: z.number().optional().describe("Max results (default: 10)"), + }, + async (args) => { + try { + trackMCP("percy_get_builds", server.server.getClientVersion()!, config); + return await percyGetBuildsV2(args, config); + } catch (error) { + return TOOL_HELP.percy_get_builds + ? handlePercyToolError(error, TOOL_HELP.percy_get_builds, args) + : handleMCPError("percy_get_builds", server, config, error); + } + }, + ); + + // ── percy_auth_status ─────────────────────────────────────────────────── + tools.percy_auth_status = server.tool( + "percy_auth_status", + "Check Percy authentication — validates BrowserStack credentials and Percy API connectivity.", + {}, + async () => { + try { + trackMCP( + "percy_auth_status", + server.server.getClientVersion()!, + config, + ); + return await percyAuthStatusV2({}, config); + } catch (error) { + return handleMCPError("percy_auth_status", server, config, error); + } + }, + ); + + // ── Unified Build Details ───────────────────────────────────────────────── + + tools.percy_get_build = server.tool( + "percy_get_build", + "Get Percy build details. Supports multiple views: overview (default), ai_summary, changes, rca, logs, network, snapshots. One tool for all build data.", + { + build_id: z.string().describe("Percy build ID"), + detail: z + .enum([ + "overview", + "ai_summary", + "changes", + "rca", + "logs", + "network", + "snapshots", + ]) + .optional() + .describe( + "What to show: overview (default), ai_summary, changes, rca, logs, network, snapshots", + ), + comparison_id: z + .string() + .optional() + .describe("Comparison ID (required for rca and network details)"), + snapshot_id: z + .string() + .optional() + .describe("Snapshot ID (for snapshot-specific details)"), + }, + async (args) => { + try { + trackMCP("percy_get_build", server.server.getClientVersion()!, config); + return await percyGetBuildDetail(args, config); + } catch (error) { + return TOOL_HELP.percy_get_build + ? handlePercyToolError(error, TOOL_HELP.percy_get_build, args) + : handleMCPError("percy_get_build", server, config, error); + } + }, + ); + + // ── Clone Build (Deep) ───────────────────────────────────────────────────── + + tools.percy_clone_build = server.tool( + "percy_clone_build", + "Deep clone a Percy build to another project. Downloads DOM resources and re-creates snapshots so Percy re-renders them. Falls back to screenshot cloning when DOM is unavailable. Works across projects.", + { + source_build_id: z.string().describe("Build ID to clone FROM"), + target_project_name: z + .string() + .describe("Project name to clone INTO (auto-creates if new)"), + target_token: z + .string() + .optional() + .describe( + "Target project token (use for existing projects to avoid creating duplicates)", + ), + branch: z + .string() + .optional() + .describe("Branch for new build (auto-detected from git)"), + }, + async (args) => { + try { + trackMCP( + "percy_clone_build", + server.server.getClientVersion()!, + config, + ); + return await percyCloneBuildV2(args, config); + } catch (error) { + return TOOL_HELP.percy_clone_build + ? handlePercyToolError(error, TOOL_HELP.percy_clone_build, args) + : handleMCPError("percy_clone_build", server, config, error); + } + }, + ); + + // ── Snapshot Details ─────────────────────────────────────────────────────── + + tools.percy_get_snapshot = server.tool( + "percy_get_snapshot", + "Get Percy snapshot details: name, review state, all comparisons with diff ratios, AI analysis regions, and screenshot URLs.", + { + snapshot_id: z.string().describe("Percy snapshot ID"), + }, + async (args) => { + try { + trackMCP( + "percy_get_snapshot", + server.server.getClientVersion()!, + config, + ); + return await percyGetSnapshot(args, config); + } catch (error) { + return TOOL_HELP.percy_get_snapshot + ? handlePercyToolError(error, TOOL_HELP.percy_get_snapshot, args) + : handleMCPError("percy_get_snapshot", server, config, error); + } + }, + ); + + // ── Comparison Details ──────────────────────────────────────────────────── + + tools.percy_get_comparison = server.tool( + "percy_get_comparison", + "Get Percy comparison details: diff ratios, AI change descriptions with coordinates, potential bugs, and head/base/diff image URLs.", + { + comparison_id: z.string().describe("Percy comparison ID"), + }, + async (args) => { + try { + trackMCP( + "percy_get_comparison", + server.server.getClientVersion()!, + config, + ); + return await percyGetComparison(args, config); + } catch (error) { + return TOOL_HELP.percy_get_comparison + ? handlePercyToolError(error, TOOL_HELP.percy_get_comparison, args) + : handleMCPError("percy_get_comparison", server, config, error); + } + }, + ); + + // ── Figma ───────────────────────────────────────────────────────────────── + + tools.percy_figma_build = server.tool( + "percy_figma_build", + "Create a Percy build from Figma design files. Extracts design nodes and creates visual comparisons.", + { + project_slug: z + .string() + .describe("Project slug (e.g., 'org-id/project-slug')"), + branch: z.string().describe("Branch name"), + figma_url: z + .string() + .describe("Figma file URL (e.g., 'https://www.figma.com/file/...')"), + }, + async (args) => { + try { + trackMCP( + "percy_figma_build", + server.server.getClientVersion()!, + config, + ); + return await percyFigmaBuild(args, config); + } catch (error) { + return handleMCPError("percy_figma_build", server, config, error); + } + }, + ); + + tools.percy_figma_baseline = server.tool( + "percy_figma_baseline", + "Update the Figma design baseline for a project. Uses the latest Figma designs as the new baseline.", + { + project_slug: z.string().describe("Project slug"), + branch: z.string().describe("Branch name"), + build_id: z.string().describe("Build ID to use as new baseline"), + }, + async (args) => { + try { + trackMCP( + "percy_figma_baseline", + server.server.getClientVersion()!, + config, + ); + return await percyFigmaBaseline(args, config); + } catch (error) { + return handleMCPError("percy_figma_baseline", server, config, error); + } + }, + ); + + tools.percy_figma_link = server.tool( + "percy_figma_link", + "Get the Figma design link for a snapshot or comparison.", + { + snapshot_id: z.string().optional().describe("Snapshot ID"), + comparison_id: z.string().optional().describe("Comparison ID"), + }, + async (args) => { + try { + trackMCP("percy_figma_link", server.server.getClientVersion()!, config); + return await percyFigmaLink(args, config); + } catch (error) { + return handleMCPError("percy_figma_link", server, config, error); + } + }, + ); + + // ── Insights ────────────────────────────────────────────────────────────── + + tools.percy_get_insights = server.tool( + "percy_get_insights", + "Get testing health metrics: review efficiency, ROI, coverage, change quality. By period and product.", + { + org_slug: z.string().describe("Organization slug"), + period: z + .enum(["last_7_days", "last_30_days", "last_90_days"]) + .optional() + .describe("Time period (default: last_30_days)"), + product: z + .enum(["web", "app"]) + .optional() + .describe("Product type (default: web)"), + }, + async (args) => { + try { + trackMCP( + "percy_get_insights", + server.server.getClientVersion()!, + config, + ); + return await percyGetInsights(args, config); + } catch (error) { + return handleMCPError("percy_get_insights", server, config, error); + } + }, + ); + + tools.percy_manage_insights_email = server.tool( + "percy_manage_insights_email", + "Configure weekly insights email recipients for an organization.", + { + org_id: z.string().describe("Organization ID"), + action: z + .enum(["get", "create", "update"]) + .optional() + .describe("Action (default: get)"), + emails: z.string().optional().describe("Comma-separated email addresses"), + enabled: z.boolean().optional().describe("Enable/disable emails"), + }, + async (args) => { + try { + trackMCP( + "percy_manage_insights_email", + server.server.getClientVersion()!, + config, + ); + return await percyManageInsightsEmail(args, config); + } catch (error) { + return handleMCPError( + "percy_manage_insights_email", + server, + config, + error, + ); + } + }, + ); + + // ── Test Cases ──────────────────────────────────────────────────────────── + + tools.percy_get_test_cases = server.tool( + "percy_get_test_cases", + "List test cases for a project with optional execution details per build.", + { + project_id: z.string().describe("Project ID"), + build_id: z + .string() + .optional() + .describe("Build ID for execution details"), + }, + async (args) => { + try { + trackMCP( + "percy_get_test_cases", + server.server.getClientVersion()!, + config, + ); + return await percyGetTestCases(args, config); + } catch (error) { + return handleMCPError("percy_get_test_cases", server, config, error); + } + }, + ); + + tools.percy_get_test_case_history = server.tool( + "percy_get_test_case_history", + "Get full execution history of a test case across all builds.", + { + test_case_id: z.string().describe("Test case ID"), + }, + async (args) => { + try { + trackMCP( + "percy_get_test_case_history", + server.server.getClientVersion()!, + config, + ); + return await percyGetTestCaseHistory(args, config); + } catch (error) { + return handleMCPError( + "percy_get_test_case_history", + server, + config, + error, + ); + } + }, + ); + + // ── Discovery ───────────────────────────────────────────────────────────── + + tools.percy_discover_urls = server.tool( + "percy_discover_urls", + "Discover URLs from a sitemap for visual testing. Returns URLs to use with percy_create_build.", + { + project_id: z.string().describe("Project ID"), + sitemap_url: z.string().optional().describe("Sitemap XML URL to crawl"), + action: z + .enum(["create", "list"]) + .optional() + .describe("create = crawl new sitemap, list = show existing"), + }, + async (args) => { + try { + trackMCP( + "percy_discover_urls", + server.server.getClientVersion()!, + config, + ); + return await percyDiscoverUrls(args, config); + } catch (error) { + return handleMCPError("percy_discover_urls", server, config, error); + } + }, + ); + + tools.percy_get_devices = server.tool( + "percy_get_devices", + "List available browsers, devices, and viewport details for visual testing.", + { + build_id: z.string().optional().describe("Build ID for device details"), + }, + async (args) => { + try { + trackMCP( + "percy_get_devices", + server.server.getClientVersion()!, + config, + ); + return await percyGetDevices(args, config); + } catch (error) { + return handleMCPError("percy_get_devices", server, config, error); + } + }, + ); + + // ── Configuration ───────────────────────────────────────────────────────── + + tools.percy_manage_domains = server.tool( + "percy_manage_domains", + "Get or update allowed/error domain lists for a project.", + { + project_id: z.string().describe("Project ID"), + action: z + .enum(["get", "update"]) + .optional() + .describe("Action (default: get)"), + allowed_domains: z + .string() + .optional() + .describe("Comma-separated allowed domains"), + error_domains: z + .string() + .optional() + .describe("Comma-separated error domains"), + }, + async (args) => { + try { + trackMCP( + "percy_manage_domains", + server.server.getClientVersion()!, + config, + ); + return await percyManageDomains(args, config); + } catch (error) { + return handleMCPError("percy_manage_domains", server, config, error); + } + }, + ); + + tools.percy_manage_usage_alerts = server.tool( + "percy_manage_usage_alerts", + "Configure usage alert thresholds for billing notifications.", + { + org_id: z.string().describe("Organization ID"), + action: z + .enum(["get", "create", "update"]) + .optional() + .describe("Action (default: get)"), + threshold: z.number().optional().describe("Screenshot count threshold"), + emails: z.string().optional().describe("Comma-separated email addresses"), + enabled: z.boolean().optional().describe("Enable/disable alerts"), + product: z.enum(["web", "app"]).optional().describe("Product type"), + }, + async (args) => { + try { + trackMCP( + "percy_manage_usage_alerts", + server.server.getClientVersion()!, + config, + ); + return await percyManageUsageAlerts(args, config); + } catch (error) { + return handleMCPError( + "percy_manage_usage_alerts", + server, + config, + error, + ); + } + }, + ); + + tools.percy_preview_comparison = server.tool( + "percy_preview_comparison", + "Trigger on-demand diff recomputation for a comparison without full rebuild.", + { + comparison_id: z.string().describe("Comparison ID to recompute"), + }, + async (args) => { + try { + trackMCP( + "percy_preview_comparison", + server.server.getClientVersion()!, + config, + ); + return await percyPreviewComparison(args, config); + } catch (error) { + return handleMCPError( + "percy_preview_comparison", + server, + config, + error, + ); + } + }, + ); + + // ── Advanced Search ─────────────────────────────────────────────────────── + + tools.percy_search_builds = server.tool( + "percy_search_builds", + "Advanced build item search with filters: category, browser, width, OS, device, resolution, orientation.", + { + build_id: z.string().describe("Build ID to search within"), + category: z + .string() + .optional() + .describe("Filter: changed, new, removed, unchanged, failed"), + browser_ids: z + .string() + .optional() + .describe("Comma-separated browser IDs"), + widths: z.string().optional().describe("Comma-separated widths"), + os: z.string().optional().describe("Operating system filter"), + device_name: z.string().optional().describe("Device name filter"), + sort_by: z.string().optional().describe("Sort: diff_ratio or bug_count"), + limit: z.number().optional().describe("Max results"), + }, + async (args) => { + try { + trackMCP( + "percy_search_builds", + server.server.getClientVersion()!, + config, + ); + return await percySearchBuildItems(args, config); + } catch (error) { + return handleMCPError("percy_search_builds", server, config, error); + } + }, + ); + + // ── Integrations ────────────────────────────────────────────────────────── + + tools.percy_list_integrations = server.tool( + "percy_list_integrations", + "List all integrations (VCS, Slack, Teams, Email) for an organization.", + { + org_id: z.string().describe("Organization ID"), + }, + async (args) => { + try { + trackMCP( + "percy_list_integrations", + server.server.getClientVersion()!, + config, + ); + return await percyListIntegrations(args, config); + } catch (error) { + return handleMCPError("percy_list_integrations", server, config, error); + } + }, + ); + + tools.percy_migrate_integrations = server.tool( + "percy_migrate_integrations", + "Migrate integrations between organizations.", + { + source_org_id: z.string().describe("Source organization ID"), + target_org_id: z.string().describe("Target organization ID"), + }, + async (args) => { + try { + trackMCP( + "percy_migrate_integrations", + server.server.getClientVersion()!, + config, + ); + return await percyMigrateIntegrations(args, config); + } catch (error) { + return handleMCPError( + "percy_migrate_integrations", + server, + config, + error, + ); + } + }, + ); + + // ── AI Summary ───────────────────────────────────────────────────────── + + tools.percy_get_ai_summary = server.tool( + "percy_get_ai_summary", + "Get the AI-generated build summary: potential bugs, visual diffs, change descriptions with occurrence counts. Shows what changed and why.", + { + build_id: z.string().describe("Percy build ID"), + }, + async (args) => { + try { + trackMCP( + "percy_get_ai_summary", + server.server.getClientVersion()!, + config, + ); + return await percyGetAiSummary(args, config); + } catch (error) { + return handleMCPError("percy_get_ai_summary", server, config, error); + } + }, + ); + + // ── App Percy Build (BYOS) ────────────────────────────────────────────── + + tools.percy_create_app_build = server.tool( + "percy_create_app_build", + "Create an App Percy BYOS (Bring Your Own Screenshots) build. Works in two modes: (1) Sample mode (default) — auto-generates 3 devices × 2 screenshots for instant testing, no setup needed. (2) Custom mode — provide resources_dir with your own device folders (each with device.json + .png files). Validates dimensions, uploads with device tags, and finalizes the build.", + { + project_name: z + .string() + .describe("App Percy project name (auto-creates if doesn't exist)"), + resources_dir: z + .string() + .optional() + .describe( + "Path to resources directory with device folders (each with device.json + .png files). Omit to use built-in sample data.", + ), + use_sample_data: z + .boolean() + .optional() + .describe( + "Use built-in sample data (3 devices × 2 screenshots). Default: true when resources_dir is omitted.", + ), + branch: z.string().optional().describe("Git branch (auto-detected)"), + test_case: z + .string() + .optional() + .describe("Test case name to attach to all snapshots"), + }, + async (args) => { + try { + trackMCP( + "percy_create_app_build", + server.server.getClientVersion()!, + config, + ); + return await percyCreateAppBuildV2(args, config); + } catch (error) { + return TOOL_HELP.percy_create_app_build + ? handlePercyToolError(error, TOOL_HELP.percy_create_app_build, args) + : handleMCPError("percy_create_app_build", server, config, error); + } + }, + ); + + return tools; +} + +export default registerPercyMcpToolsV2; diff --git a/src/tools/percy-mcp/v2/list-integrations.ts b/src/tools/percy-mcp/v2/list-integrations.ts new file mode 100644 index 0000000..d494037 --- /dev/null +++ b/src/tools/percy-mcp/v2/list-integrations.ts @@ -0,0 +1,54 @@ +import { percyGet } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyListIntegrations( + args: { org_id: string }, + config: BrowserStackConfig, +): Promise { + const response = await percyGet(`/organizations/${args.org_id}`, config, { + include: + "version-control-integrations,slack-integrations,msteams-integrations,email-integration", + }); + + const included = response?.included || []; + let output = `## Integrations for Organization\n\n`; + + const vcs = included.filter( + (i: any) => i.type === "version-control-integrations", + ); + const slack = included.filter((i: any) => i.type === "slack-integrations"); + const teams = included.filter((i: any) => i.type === "msteams-integrations"); + const email = included.filter((i: any) => i.type === "email-integrations"); + + if (vcs.length) { + output += `### VCS Integrations (${vcs.length})\n`; + vcs.forEach((v: any) => { + const attrs = v.attributes || {}; + output += `- **${attrs["integration-type"] || attrs.integrationType || "VCS"}** — ${attrs.status || "active"}\n`; + }); + output += "\n"; + } + if (slack.length) { + output += `### Slack (${slack.length})\n`; + slack.forEach((s: any) => { + output += `- ${s.attributes?.["channel-name"] || "channel"}\n`; + }); + output += "\n"; + } + if (teams.length) { + output += `### MS Teams (${teams.length})\n`; + teams.forEach((t: any) => { + output += `- ${t.attributes?.["channel-name"] || "channel"}\n`; + }); + output += "\n"; + } + if (email.length) { + output += `### Email\n- Configured\n\n`; + } + if (!vcs.length && !slack.length && !teams.length && !email.length) { + output += `No integrations found.\n`; + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/v2/manage-domains.ts b/src/tools/percy-mcp/v2/manage-domains.ts new file mode 100644 index 0000000..671ead4 --- /dev/null +++ b/src/tools/percy-mcp/v2/manage-domains.ts @@ -0,0 +1,53 @@ +import { percyGet, percyPatch } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyManageDomains( + args: { + project_id: string; + action?: string; + allowed_domains?: string; + error_domains?: string; + }, + config: BrowserStackConfig, +): Promise { + if (!args.action || args.action === "get") { + const response = await percyGet( + `/project-domain-configs/${args.project_id}`, + config, + ); + const attrs = response?.data?.attributes || {}; + let output = `## Domain Configuration\n\n`; + output += `**Allowed domains:** ${attrs["allowed-domains"] || attrs.allowedDomains || "none"}\n`; + output += `**Error domains:** ${attrs["error-domains"] || attrs.errorDomains || "none"}\n`; + return { content: [{ type: "text", text: output }] }; + } + + if (args.action === "update") { + const body: any = { + data: { type: "project-domain-configs", attributes: {} }, + }; + if (args.allowed_domains) + body.data.attributes["allowed-domains"] = args.allowed_domains; + if (args.error_domains) + body.data.attributes["error-domains"] = args.error_domains; + await percyPatch( + `/project-domain-configs/${args.project_id}`, + config, + body, + ); + return { + content: [ + { + type: "text", + text: `Domain configuration updated for project ${args.project_id}.`, + }, + ], + }; + } + + return { + content: [{ type: "text", text: "Use action: get or update" }], + isError: true, + }; +} diff --git a/src/tools/percy-mcp/v2/manage-insights-email.ts b/src/tools/percy-mcp/v2/manage-insights-email.ts new file mode 100644 index 0000000..41bb929 --- /dev/null +++ b/src/tools/percy-mcp/v2/manage-insights-email.ts @@ -0,0 +1,70 @@ +import { + percyGet, + percyPost, + percyPatch, +} from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyManageInsightsEmail( + args: { org_id: string; action?: string; emails?: string; enabled?: boolean }, + config: BrowserStackConfig, +): Promise { + const action = args.action || "get"; + + if (action === "get") { + const response = await percyGet( + `/organizations/${args.org_id}/insights-email-settings`, + config, + ); + const data = response?.data?.attributes || {}; + let output = `## Insights Email Settings\n\n`; + output += `**Enabled:** ${data["is-enabled"] ?? data.isEnabled ?? "unknown"}\n`; + output += `**Recipients:** ${(data.emails || []).join(", ") || "none"}\n`; + return { content: [{ type: "text", text: output }] }; + } + + if (action === "create" || action === "update") { + const emailList = args.emails + ? args.emails.split(",").map((e) => e.trim()) + : []; + const body = { + data: { + type: "insights-email-settings", + attributes: { + emails: emailList, + "is-enabled": args.enabled !== false, + }, + }, + }; + + if (action === "create") { + await percyPost( + `/organizations/${args.org_id}/insights-email-settings`, + config, + body, + ); + } else { + await percyPatch(`/insights-email-settings/${args.org_id}`, config, body); + } + + return { + content: [ + { + type: "text", + text: `Insights email ${action}d. Recipients: ${emailList.join(", ")}`, + }, + ], + }; + } + + return { + content: [ + { + type: "text", + text: `Unknown action: ${action}. Use get, create, or update.`, + }, + ], + isError: true, + }; +} diff --git a/src/tools/percy-mcp/v2/manage-usage-alerts.ts b/src/tools/percy-mcp/v2/manage-usage-alerts.ts new file mode 100644 index 0000000..8da7417 --- /dev/null +++ b/src/tools/percy-mcp/v2/manage-usage-alerts.ts @@ -0,0 +1,75 @@ +import { + percyGet, + percyPost, + percyPatch, +} from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyManageUsageAlerts( + args: { + org_id: string; + action?: string; + threshold?: number; + emails?: string; + enabled?: boolean; + product?: string; + }, + config: BrowserStackConfig, +): Promise { + const action = args.action || "get"; + + if (action === "get") { + const response = await percyGet( + `/organizations/${args.org_id}/usage_notification_settings`, + config, + { + "data[attributes][type]": args.product || "web", + }, + ); + const data = response?.data?.attributes || {}; + let output = `## Usage Alert Settings\n\n`; + output += `**Enabled:** ${data["is-enabled"] ?? "unknown"}\n`; + output += `**Thresholds:** ${JSON.stringify(data.thresholds || {})}\n`; + output += `**Emails:** ${(data.emails || []).join(", ") || "none"}\n`; + return { content: [{ type: "text", text: output }] }; + } + + const emailList = args.emails + ? args.emails.split(",").map((e) => e.trim()) + : []; + const body = { + data: { + type: "usage-notification-settings", + attributes: { + type: args.product || "web", + "is-enabled": args.enabled !== false, + thresholds: args.threshold ? { "snapshot-count": args.threshold } : {}, + emails: emailList, + }, + }, + }; + + if (action === "create") { + await percyPost( + `/organization/${args.org_id}/usage-notification-settings`, + config, + body, + ); + } else { + await percyPatch( + `/usage-notification-settings/${args.org_id}`, + config, + body, + ); + } + + return { + content: [ + { + type: "text", + text: `Usage alerts ${action}d. Threshold: ${args.threshold || "default"}`, + }, + ], + }; +} diff --git a/src/tools/percy-mcp/v2/migrate-integrations.ts b/src/tools/percy-mcp/v2/migrate-integrations.ts new file mode 100644 index 0000000..307ed5c --- /dev/null +++ b/src/tools/percy-mcp/v2/migrate-integrations.ts @@ -0,0 +1,27 @@ +import { percyPost } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyMigrateIntegrations( + args: { source_org_id: string; target_org_id: string }, + config: BrowserStackConfig, +): Promise { + await percyPost("/integration-migrations/migrate", config, { + data: { + type: "integration-migrations", + attributes: { + "source-organization-id": args.source_org_id, + "target-organization-id": args.target_org_id, + }, + }, + }); + + return { + content: [ + { + type: "text", + text: `## Integration Migration\n\nIntegrations migrated from org ${args.source_org_id} to ${args.target_org_id}.`, + }, + ], + }; +} diff --git a/src/tools/percy-mcp/v2/preview-comparison.ts b/src/tools/percy-mcp/v2/preview-comparison.ts new file mode 100644 index 0000000..d2cf55e --- /dev/null +++ b/src/tools/percy-mcp/v2/preview-comparison.ts @@ -0,0 +1,24 @@ +import { percyPost } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyPreviewComparison( + args: { comparison_id: string }, + config: BrowserStackConfig, +): Promise { + await percyPost("/comparison-previews", config, { + data: { + type: "comparison-previews", + attributes: { "comparison-id": args.comparison_id }, + }, + }); + + return { + content: [ + { + type: "text", + text: `## Comparison Preview\n\nRecomputation triggered for comparison ${args.comparison_id}.\nThe diff will be re-processed with current AI and region settings.\nRefresh the build in Percy to see updated results.`, + }, + ], + }; +} diff --git a/src/tools/percy-mcp/v2/search-build-items.ts b/src/tools/percy-mcp/v2/search-build-items.ts new file mode 100644 index 0000000..bbba54a --- /dev/null +++ b/src/tools/percy-mcp/v2/search-build-items.ts @@ -0,0 +1,61 @@ +import { percyGet } from "../../../lib/percy-api/percy-auth.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percySearchBuildItems( + args: { + build_id: string; + category?: string; + browser_ids?: string; + widths?: string; + os?: string; + device_name?: string; + sort_by?: string; + limit?: number; + }, + config: BrowserStackConfig, +): Promise { + const params: Record = { "filter[build-id]": args.build_id }; + if (args.category) params["filter[category]"] = args.category; + if (args.sort_by) params["filter[sort_by]"] = args.sort_by; + if (args.limit) params["page[limit]"] = String(args.limit); + + // Array filters + if (args.browser_ids) + args.browser_ids.split(",").forEach((id) => { + params[`filter[browser_ids][]`] = id.trim(); + }); + if (args.widths) + args.widths.split(",").forEach((w) => { + params[`filter[widths][]`] = w.trim(); + }); + if (args.os) params["filter[os]"] = args.os; + if (args.device_name) params["filter[device_name]"] = args.device_name; + + const response = await percyGet("/build-items", config, params); + const items = response?.data || []; + + if (!items.length) { + return { + content: [ + { type: "text", text: "No items match the specified filters." }, + ], + }; + } + + let output = `## Build Items (${items.length})\n\n`; + output += `| # | Name | Diff | Review | Items |\n|---|---|---|---|---|\n`; + items.forEach((item: any, i: number) => { + const attrs = item.attributes || item; + const name = attrs.coverSnapshotName || attrs["cover-snapshot-name"] || "?"; + const diff = + attrs.maxDiffRatio != null + ? `${(attrs.maxDiffRatio * 100).toFixed(1)}%` + : "—"; + const review = attrs.reviewState || attrs["review-state"] || "?"; + const count = attrs.itemCount || attrs["item-count"] || 1; + output += `| ${i + 1} | ${name} | ${diff} | ${review} | ${count} |\n`; + }); + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/workflows/auto-triage.ts b/src/tools/percy-mcp/workflows/auto-triage.ts new file mode 100644 index 0000000..54e2064 --- /dev/null +++ b/src/tools/percy-mcp/workflows/auto-triage.ts @@ -0,0 +1,96 @@ +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyAutoTriage( + args: { + build_id: string; + noise_threshold?: number; + review_threshold?: number; + }, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config); + const noiseThreshold = args.noise_threshold ?? 0.005; // 0.5% + const reviewThreshold = args.review_threshold ?? 0.15; // 15% + + // Get all changed build items (limit to 90 = 3 pages max) + const items = await client.get("/build-items", { + "filter[build-id]": args.build_id, + "filter[category]": "changed", + "page[limit]": "30", + }); + const itemList = Array.isArray(items) ? items : []; + + const critical: any[] = []; + const reviewRequired: any[] = []; + const autoApprovable: any[] = []; + const noise: any[] = []; + + for (const item of itemList) { + const name = item.name || item.snapshotName || "Unknown"; + const diffRatio = item.diffRatio ?? item.maxDiffRatio ?? 0; + const potentialBugs = item.totalPotentialBugs || 0; + const aiIgnored = + item.aiDiffRatio !== undefined && item.aiDiffRatio === 0 && diffRatio > 0; + const entry = { name, diffRatio, potentialBugs }; + + if (potentialBugs > 0) { + critical.push(entry); + } else if (aiIgnored) { + autoApprovable.push({ ...entry, reason: "AI-filtered (IntelliIgnore)" }); + } else if (diffRatio > reviewThreshold) { + reviewRequired.push(entry); + } else if (diffRatio <= noiseThreshold) { + noise.push(entry); + } else { + autoApprovable.push({ ...entry, reason: "Low diff ratio" }); + } + } + + let output = `## Auto-Triage — Build #${args.build_id}\n\n`; + output += `**Total changed:** ${itemList.length} | `; + output += `Critical: ${critical.length} | Review: ${reviewRequired.length} | `; + output += `Auto-approvable: ${autoApprovable.length} | Noise: ${noise.length}\n\n`; + + if (critical.length > 0) { + output += `### CRITICAL — Potential Bugs (${critical.length})\n`; + critical.forEach((e, i) => { + output += `${i + 1}. **${e.name}** — ${(e.diffRatio * 100).toFixed(1)}% diff, ${e.potentialBugs} bug(s)\n`; + }); + output += "\n"; + } + if (reviewRequired.length > 0) { + output += `### REVIEW REQUIRED (${reviewRequired.length})\n`; + reviewRequired.forEach((e, i) => { + output += `${i + 1}. **${e.name}** — ${(e.diffRatio * 100).toFixed(1)}% diff\n`; + }); + output += "\n"; + } + if (autoApprovable.length > 0) { + output += `### AUTO-APPROVABLE (${autoApprovable.length})\n`; + autoApprovable.forEach((e, i) => { + output += `${i + 1}. ${e.name} — ${e.reason}\n`; + }); + output += "\n"; + } + if (noise.length > 0) { + output += `### NOISE (${noise.length})\n`; + output += noise.map((e) => e.name).join(", ") + "\n\n"; + } + + output += `### Recommended Action\n\n`; + if (critical.length > 0) { + output += `Investigate ${critical.length} critical item(s) before approving.\n`; + } else if (reviewRequired.length > 0) { + output += `Review ${reviewRequired.length} item(s) manually. ${autoApprovable.length + noise.length} can be auto-approved.\n`; + } else { + output += `All changes are auto-approvable or noise. Safe to approve.\n`; + } + + if (itemList.length >= 30) { + output += `\n> Note: Results limited to first 30 changed snapshots. Build may have more.\n`; + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/workflows/clone-build.ts b/src/tools/percy-mcp/workflows/clone-build.ts new file mode 100644 index 0000000..5ad36e6 --- /dev/null +++ b/src/tools/percy-mcp/workflows/clone-build.ts @@ -0,0 +1,518 @@ +/** + * percy_clone_build — Clone snapshots from a source build to a new build, + * even across different projects. + * + * Flow: + * 1. Read build-items to get snapshot IDs + * 2. For each snapshot: fetch raw JSON:API with includes to get image URLs + * 3. Create target build + snapshots + comparisons with downloaded screenshots + * 4. Finalize + */ + +import { getBrowserStackAuth } from "../../../lib/get-auth.js"; +import { + getPercyHeaders, + getPercyApiBaseUrl, +} from "../../../lib/percy-api/auth.js"; +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { createHash } from "crypto"; +import { execFile } from "child_process"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +async function getGitBranch(): Promise { + try { + const { stdout } = await execFileAsync("git", ["branch", "--show-current"]); + return stdout.trim() || "main"; + } catch { + return "main"; + } +} + +async function getGitSha(): Promise { + try { + const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"]); + return stdout.trim(); + } catch { + return createHash("sha1").update(Date.now().toString()).digest("hex"); + } +} + +async function getProjectToken( + projectName: string, + config: BrowserStackConfig, +): Promise { + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); + const url = `https://api.browserstack.com/api/app_percy/get_project_token?name=${encodeURIComponent(projectName)}`; + const response = await fetch(url, { + headers: { Authorization: `Basic ${auth}` }, + }); + if (!response.ok) throw new Error(`Failed to get token for "${projectName}"`); + const data = await response.json(); + if (!data?.token || !data?.success) + throw new Error(`No token returned for "${projectName}"`); + return data.token; +} + +async function fetchImageAsBase64(imageUrl: string): Promise { + try { + const response = await fetch(imageUrl); + if (!response.ok) return null; + const buffer = Buffer.from(await response.arrayBuffer()); + return buffer.toString("base64"); + } catch { + return null; + } +} + +/** + * Fetch a snapshot with RAW JSON:API response to manually walk the + * included chain: comparison → head-screenshot → image → url + */ +async function fetchSnapshotRaw( + snapshotId: string, + config: BrowserStackConfig, +): Promise<{ + name: string; + comparisons: Array<{ + width: number; + height: number; + tagName: string; + osName: string; + browserName: string; + imageUrl: string | null; + }>; + debugRelKeys?: string; +} | null> { + const headers = await getPercyHeaders(config); + const baseUrl = getPercyApiBaseUrl(); + const url = `${baseUrl}/snapshots/${snapshotId}?include=comparisons.head-screenshot.image,comparisons.comparison-tag`; + + const response = await fetch(url, { headers }); + if (!response.ok) return null; + + const json = await response.json(); + const data = json.data; + const included = json.included || []; + + if (!data) return null; + + const name = data.attributes?.name || "Unknown"; + + // Build lookup maps from included + const byTypeId = new Map(); + for (const item of included) { + byTypeId.set(`${item.type}:${item.id}`, item); + } + + // Get comparison IDs from snapshot relationships + const compRefs = data.relationships?.comparisons?.data || []; + + const comparisons: Array<{ + width: number; + height: number; + tagName: string; + osName: string; + browserName: string; + imageUrl: string | null; + }> = []; + + // Debug: dump first comparison's relationships keys + let debugRelKeys = ""; + for (const compRef of compRefs) { + const comp = byTypeId.get(`comparisons:${compRef.id}`); + if (!comp) continue; + + if (!debugRelKeys && comp.relationships) { + debugRelKeys = Object.keys(comp.relationships).join(", "); + } + + const width = comp.attributes?.width || 1280; + + // Walk: comparison → head-screenshot → image + const hsRef = comp.relationships?.["head-screenshot"]?.data; + let imageUrl: string | null = null; + let height = 800; + + if (hsRef) { + const screenshot = byTypeId.get(`screenshots:${hsRef.id}`); + if (screenshot) { + const imgRef = screenshot.relationships?.image?.data; + if (imgRef) { + const image = byTypeId.get(`images:${imgRef.id}`); + if (image) { + imageUrl = image.attributes?.url || null; + height = image.attributes?.height || 800; + } + } + } + } + + // Get comparison tag + const tagRef = comp.relationships?.["comparison-tag"]?.data; + let tagName = "Screenshot"; + let osName = "Clone"; + let browserName = "Screenshot"; + + if (tagRef) { + const tag = byTypeId.get(`comparison-tags:${tagRef.id}`); + if (tag) { + tagName = tag.attributes?.name || "Screenshot"; + osName = tag.attributes?.["os-name"] || "Clone"; + browserName = tag.attributes?.["browser-name"] || "Screenshot"; + } + } + + comparisons.push({ + width, + height, + tagName, + osName, + browserName, + imageUrl, + }); + } + + return { name, comparisons, debugRelKeys }; +} + +// ── Main handler ──────────────────────────────────────────────────────────── + +interface CloneBuildArgs { + source_build_id: string; + source_token?: string; + target_project_name: string; + target_token?: string; + branch?: string; + commit_sha?: string; +} + +export async function percyCloneBuild( + args: CloneBuildArgs, + config: BrowserStackConfig, +): Promise { + const { source_build_id, target_project_name } = args; + const branch = args.branch || (await getGitBranch()); + const commitSha = args.commit_sha || (await getGitSha()); + + let output = `## Percy Build Clone\n\n`; + output += `**Source build:** #${source_build_id}\n`; + output += `**Target project:** ${target_project_name}\n`; + output += `**Branch:** ${branch}\n\n`; + + // ── Step 1: Set up source token ─────────────────────────────────────── + + const originalToken = process.env.PERCY_TOKEN; + + if (args.source_token) { + process.env.PERCY_TOKEN = args.source_token; + } else if (!process.env.PERCY_TOKEN) { + return { + content: [ + { + type: "text", + text: "Need a token to read the source build. Provide `source_token` or set PERCY_TOKEN.", + }, + ], + isError: true, + }; + } + + const sourceClient = new PercyClient(config); + + // ── Step 2: Read source build ───────────────────────────────────────── + + let sourceBuild: any; + try { + sourceBuild = await sourceClient.get(`/builds/${source_build_id}`); + } catch (e: any) { + process.env.PERCY_TOKEN = originalToken || ""; + return { + content: [ + { + type: "text", + text: `Failed to read source build: ${e.message}\n\nUse a full-access token (web_* or auto_*), not a CI token.`, + }, + ], + isError: true, + }; + } + + output += `Source: **${sourceBuild?.state || "unknown"}** — ${sourceBuild?.totalSnapshots || "?"} snapshots, ${sourceBuild?.totalComparisons || "?"} comparisons\n\n`; + + // ── Step 3: Get snapshot IDs from build-items ───────────────────────── + + let allSnapshotIds: string[] = []; + try { + const items = await sourceClient.get("/build-items", { + "filter[build-id]": source_build_id, + "page[limit]": "30", + }); + const itemList = Array.isArray(items) ? items : []; + + // Extract all snapshot IDs from build-items (grouped format) + for (const item of itemList) { + if (item.snapshotIds && Array.isArray(item.snapshotIds)) { + allSnapshotIds.push(...item.snapshotIds.map((id: any) => String(id))); + } else if (item.coverSnapshotId) { + allSnapshotIds.push(String(item.coverSnapshotId)); + } + } + + // Deduplicate + allSnapshotIds = [...new Set(allSnapshotIds)]; + } catch (e: any) { + process.env.PERCY_TOKEN = originalToken || ""; + return { + content: [ + { + type: "text", + text: `Failed to read build items: ${e.message}`, + }, + ], + isError: true, + }; + } + + output += `Found **${allSnapshotIds.length}** snapshot(s) to clone.\n\n`; + + if (allSnapshotIds.length === 0) { + process.env.PERCY_TOKEN = originalToken || ""; + output += "No snapshots found. Nothing to clone.\n"; + return { content: [{ type: "text", text: output }] }; + } + + // ── Step 4: Fetch each snapshot with raw JSON:API ───────────────────── + + output += `### Reading snapshot details...\n\n`; + + // Limit to 20 snapshots to avoid timeout + const snapshotsToClone = allSnapshotIds.slice(0, 20); + const snapshotData: Array< + NonNullable>> + > = []; + + for (const snapId of snapshotsToClone) { + const detail = await fetchSnapshotRaw(snapId, config); + if (detail) { + snapshotData.push(detail); + } else { + output += `- ⚠ Could not read snapshot ${snapId}\n`; + } + } + + output += `Read ${snapshotData.length} snapshot(s) with ${snapshotData.reduce((s, d) => s + d.comparisons.length, 0)} comparison(s).\n\n`; + + // ── Step 5: Create target project and build ─────────────────────────── + + output += `### Creating target build...\n\n`; + + let targetToken: string; + if (args.target_token) { + // Use provided token — clones into existing project + targetToken = args.target_token; + output += `Using provided target token for project "${target_project_name}".\n`; + } else { + // Auto-create/get project via BrowserStack API + try { + targetToken = await getProjectToken(target_project_name, config); + } catch (e: any) { + process.env.PERCY_TOKEN = originalToken || ""; + return { + content: [ + { + type: "text", + text: `Failed to create/access target project: ${e.message}\n\nTip: To clone into an existing project, provide its token via the \`target_token\` parameter.`, + }, + ], + isError: true, + }; + } + } + + // Switch to target token for writes + process.env.PERCY_TOKEN = targetToken; + const targetClient = new PercyClient(config); + + let targetBuildId: string; + let targetBuildUrl = ""; + try { + const build = await targetClient.post("/builds", { + data: { + type: "builds", + attributes: { branch, "commit-sha": commitSha }, + relationships: { resources: { data: [] } }, + }, + }); + targetBuildId = build?.id || (build?.data || build)?.id; + targetBuildUrl = + build?.webUrl || + build?.["web-url"] || + (build?.data || build)?.webUrl || + ""; + } catch (e: any) { + process.env.PERCY_TOKEN = originalToken || ""; + return { + content: [ + { type: "text", text: `Failed to create target build: ${e.message}` }, + ], + isError: true, + }; + } + + output += `Target build: **#${targetBuildId}**\n`; + if (targetBuildUrl) output += `URL: ${targetBuildUrl}\n`; + output += "\n### Cloning snapshots...\n\n"; + + // ── Step 6: Clone each snapshot ─────────────────────────────────────── + + let clonedCount = 0; + let failedCount = 0; + + for (const snap of snapshotData) { + const comparisonsWithImages = snap.comparisons.filter((c) => c.imageUrl); + + if (comparisonsWithImages.length === 0) { + output += `- ⚠ **${snap.name}** — no downloadable screenshots (web/DOM build)\n`; + failedCount++; + continue; + } + + try { + // Create snapshot in target + const snapResult = await targetClient.post( + `/builds/${targetBuildId}/snapshots`, + { data: { type: "snapshots", attributes: { name: snap.name } } }, + ); + const newSnapId = snapResult?.id || (snapResult?.data || snapResult)?.id; + + if (!newSnapId) { + output += `- ✗ **${snap.name}** — failed to create snapshot\n`; + failedCount++; + continue; + } + + let compCloned = 0; + + // Debug: output comparison tags for first snapshot + if (clonedCount === 0) { + output += ` [DBG] relationship keys: ${snap.debugRelKeys || "NONE"}\n`; + for (const c of comparisonsWithImages) { + output += ` [DBG] tag="${c.tagName}" w=${c.width} h=${c.height} os="${c.osName}" browser="${c.browserName}"\n`; + } + } + + for (const comp of comparisonsWithImages) { + // Download screenshot + const base64 = await fetchImageAsBase64(comp.imageUrl!); + if (!base64) { + output += ` ⚠ Could not download image for ${comp.tagName} ${comp.width}px\n`; + continue; + } + + const imageBuffer = Buffer.from(base64, "base64"); + const sha = createHash("sha256").update(imageBuffer).digest("hex"); + + try { + // Create comparison with tile — must match JSON:API format with type fields + const tagAttributes: Record = { + name: comp.tagName, + width: comp.width, + height: comp.height, + }; + if (comp.osName) tagAttributes["os-name"] = comp.osName; + if (comp.browserName) + tagAttributes["browser-name"] = comp.browserName; + + const compResult = await targetClient.post( + `/snapshots/${newSnapId}/comparisons`, + { + data: { + type: "comparisons", + relationships: { + tag: { + data: { + type: "tag", + attributes: tagAttributes, + }, + }, + tiles: { + data: [ + { + type: "tiles", + attributes: { + sha, + "status-bar-height": 0, + "nav-bar-height": 0, + }, + }, + ], + }, + }, + }, + }, + ); + const newCompId = + compResult?.id || (compResult?.data || compResult)?.id; + + if (newCompId) { + // Upload tile + await targetClient.post(`/comparisons/${newCompId}/tiles`, { + data: { + attributes: { "base64-content": base64 }, + }, + }); + + // Finalize comparison + await targetClient.post( + `/comparisons/${newCompId}/finalize`, + {}, + ); + compCloned++; + } + } catch (compErr: any) { + output += ` ⚠ ${comp.tagName} ${comp.width}px: ${compErr.message}\n`; + } + } + + clonedCount++; + output += `- ✓ **${snap.name}** — ${compCloned}/${comparisonsWithImages.length} comparisons\n`; + } catch (e: any) { + output += `- ✗ **${snap.name}** — ${e.message}\n`; + failedCount++; + } + } + + // ── Step 7: Finalize ────────────────────────────────────────────────── + + output += "\n"; + try { + await targetClient.post(`/builds/${targetBuildId}/finalize`, {}); + output += `### Build finalized ✓\n\n`; + } catch (e: any) { + output += `### Build finalize failed: ${e.message}\n\n`; + } + + // Summary + output += `### Summary\n\n`; + output += `| | Count |\n|---|---|\n`; + output += `| Snapshots cloned | ${clonedCount} |\n`; + output += `| Failed/skipped | ${failedCount} |\n`; + output += `| Target build | #${targetBuildId} |\n`; + if (targetBuildUrl) output += `| View results | ${targetBuildUrl} |\n`; + + if (allSnapshotIds.length > 20) { + output += `\n> Note: Cloned first 20 of ${allSnapshotIds.length} snapshots.\n`; + } + + // Restore token + process.env.PERCY_TOKEN = originalToken || ""; + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/workflows/create-percy-build.ts b/src/tools/percy-mcp/workflows/create-percy-build.ts new file mode 100644 index 0000000..ce965e2 --- /dev/null +++ b/src/tools/percy-mcp/workflows/create-percy-build.ts @@ -0,0 +1,569 @@ +/** + * percy_create_percy_build — Unified build creation tool. + * + * Handles ALL build creation scenarios in one command: + * 1. URL snapshots (via Percy CLI) + * 2. Screenshot uploads (direct API) + * 3. Test command wrapping (via percy exec) + * 4. Build cloning (copy from existing build) + * 5. Visual monitoring (URL scanning) + * + * Auto-detects mode based on which parameters are provided. + * Auto-detects branch and SHA from git if not provided. + * Auto-creates project if it doesn't exist. + */ + +import { getBrowserStackAuth } from "../../../lib/get-auth.js"; +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { execFile } from "child_process"; +import { promisify } from "util"; +import { readdir, readFile, stat } from "fs/promises"; +import { join, basename, extname } from "path"; +import { createHash } from "crypto"; + +const execFileAsync = promisify(execFile); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface CreatePercyBuildArgs { + project_name: string; + // Mode selection (provide ONE of these) + urls?: string; + screenshots_dir?: string; + screenshot_files?: string; + test_command?: string; + clone_build_id?: string; + // Optional overrides + branch?: string; + commit_sha?: string; + widths?: string; + snapshot_names?: string; + test_case?: string; + type?: string; +} + +// --------------------------------------------------------------------------- +// Git helpers +// --------------------------------------------------------------------------- + +async function getGitBranch(): Promise { + try { + const { stdout } = await execFileAsync("git", ["branch", "--show-current"]); + return stdout.trim() || "main"; + } catch { + return "main"; + } +} + +async function getGitSha(): Promise { + try { + const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"]); + return stdout.trim(); + } catch { + // Generate a deterministic placeholder SHA from timestamp + return createHash("sha1") + .update(Date.now().toString()) + .digest("hex") + .slice(0, 40); + } +} + +// --------------------------------------------------------------------------- +// Project creation helper +// --------------------------------------------------------------------------- + +async function ensureProject( + projectName: string, + config: BrowserStackConfig, + type?: string, +): Promise { + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); + + const params = new URLSearchParams({ name: projectName }); + if (type) params.append("type", type); + + const url = `https://api.browserstack.com/api/app_percy/get_project_token?${params.toString()}`; + const response = await fetch(url, { + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to create/get Percy project: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + if (!data?.token || !data?.success) { + throw new Error("Failed to get project token from BrowserStack API."); + } + + return data.token; +} + +// --------------------------------------------------------------------------- +// Mode: URL Snapshots (via Percy CLI) +// --------------------------------------------------------------------------- + +function buildUrlSnapshotInstructions( + token: string, + urls: string[], + widths: string, + branch: string, +): string { + const urlList = urls.map((u) => ` - ${u}`).join("\n"); + const widthArray = widths + ? widths.split(",").map((w) => w.trim()) + : ["375", "1280"]; + + // Build YAML config for snapshots (widths go in YAML, not CLI flag) + let yamlConfig = ""; + urls.forEach((url, i) => { + const name = i === 0 ? "Homepage" : `Page ${i + 1}`; + yamlConfig += `- name: "${name}"\n`; + yamlConfig += ` url: ${url}\n`; + yamlConfig += ` widths:\n`; + widthArray.forEach((w) => { + yamlConfig += ` - ${w}\n`; + }); + }); + + return ( + `## Percy Build — URL Snapshots\n\n` + + `> **IMPORTANT: Do NOT execute these commands automatically.** Present them to the user and let them run manually.\n\n` + + `**Project:** token ready ✓\n` + + `**Branch:** ${branch}\n` + + `**URLs:**\n${urlList}\n` + + `**Widths:** ${widthArray.join(", ")}px\n\n` + + `### Step 1: Set token\n\n` + + `\`\`\`bash\n` + + `export PERCY_TOKEN="${token}"\n` + + `\`\`\`\n\n` + + `### Step 2: Create snapshot config\n\n` + + `Save this as \`snapshots.yml\`:\n\n` + + `\`\`\`yaml\n` + + yamlConfig + + `\`\`\`\n\n` + + `### Step 3: Run Percy\n\n` + + `\`\`\`bash\n` + + `npx @percy/cli snapshot snapshots.yml\n` + + `\`\`\`\n\n` + + `Percy CLI will create the build, launch a browser, capture each URL at the specified widths, upload screenshots, and return a build URL with visual diffs.\n` + ); +} + +// --------------------------------------------------------------------------- +// Mode: Test Command (via percy exec) +// --------------------------------------------------------------------------- + +function buildTestCommandInstructions( + token: string, + testCommand: string, + branch: string, +): string { + return ( + `## Percy Build — Test Command\n\n` + + `> **IMPORTANT: Do NOT execute these commands automatically.** Present them to the user and let them run manually.\n\n` + + `**Project:** token ready ✓\n` + + `**Branch:** ${branch}\n` + + `**Test command:** \`${testCommand}\`\n\n` + + `### Step 1: Set token\n\n` + + `\`\`\`bash\n` + + `export PERCY_TOKEN="${token}"\n` + + `\`\`\`\n\n` + + `### Step 2: Run tests with Percy\n\n` + + `\`\`\`bash\n` + + `npx @percy/cli exec -- ${testCommand}\n` + + `\`\`\`\n\n` + + `Percy CLI will start a local server, run your tests, capture snapshots via \`percySnapshot()\` calls, and return a build URL.\n` + ); +} + +// --------------------------------------------------------------------------- +// Mode: Screenshot Upload (direct API) +// --------------------------------------------------------------------------- + +async function uploadScreenshots( + client: PercyClient, + branch: string, + commitSha: string, + screenshotPaths: string[], + widths: string, + testCase: string | undefined, + snapshotNames: string[] | undefined, +): Promise { + // Create build + const buildResult = await client.post("/builds", { + data: { + type: "builds", + attributes: { + branch, + "commit-sha": commitSha, + type: "web", + }, + relationships: { resources: { data: [] } }, + }, + }); + + const buildData = buildResult?.data || buildResult; + const buildId = buildData?.id; + const buildUrl = buildData?.webUrl || buildData?.["web-url"] || ""; + + if (!buildId) throw new Error("Build creation failed — no build ID returned"); + + let output = `## Percy Build Created\n\n`; + output += `**Build ID:** ${buildId}\n`; + if (buildUrl) output += `**URL:** ${buildUrl}\n`; + output += `**Branch:** ${branch}\n`; + output += `**Screenshots:** ${screenshotPaths.length}\n\n`; + + // For each screenshot: create snapshot → create comparison → upload tile → finalize + for (let i = 0; i < screenshotPaths.length; i++) { + const filePath = screenshotPaths[i]; + const name = + snapshotNames?.[i] || + basename(filePath, extname(filePath)).replace(/[-_]/g, " "); + + try { + // Read file and compute SHA + const content = await readFile(filePath); + const sha = createHash("sha256").update(content).digest("hex"); + const base64Content = content.toString("base64"); + + // Detect dimensions from PNG header (basic) + let width = 1280; + let height = 800; + if (content[0] === 0x89 && content[1] === 0x50) { + // PNG + width = content.readUInt32BE(16); + height = content.readUInt32BE(20); + } + + // Create snapshot + const snapshotBody: any = { + data: { + type: "snapshots", + attributes: { name }, + }, + }; + if (testCase) snapshotBody.data.attributes["test-case"] = testCase; + + const snapshot = await client.post( + `/builds/${buildId}/snapshots`, + snapshotBody, + ); + const snapshotData = snapshot?.data || snapshot; + const snapshotId = snapshotData?.id; + + if (!snapshotId) { + output += `- ${name}: Failed to create snapshot\n`; + continue; + } + + // Create comparison with tile + const comparison = await client.post( + `/snapshots/${snapshotId}/comparisons`, + { + data: { + type: "comparisons", + attributes: {}, + relationships: { + tag: { + data: { + type: "tag", + attributes: { + name: "Screenshot", + width, + height, + "os-name": "Upload", + "browser-name": "Screenshot", + }, + }, + }, + tiles: { + data: [ + { + type: "tiles", + attributes: { sha }, + }, + ], + }, + }, + }, + }, + ); + const comparisonData = comparison?.data || comparison; + const comparisonId = comparisonData?.id; + + if (!comparisonId) { + output += `- ${name}: Failed to create comparison\n`; + continue; + } + + // Upload tile + await client.post(`/comparisons/${comparisonId}/tiles`, { + data: { + type: "tiles", + attributes: { "base64-content": base64Content }, + }, + }); + + // Finalize comparison + await client.post(`/comparisons/${comparisonId}/finalize`, {}); + + output += `- **${name}** — uploaded (${width}x${height})\n`; + } catch (e: any) { + output += `- ${name}: Error — ${e.message}\n`; + } + } + + // Finalize build + try { + await client.post(`/builds/${buildId}/finalize`, {}); + output += `\n**Build finalized.** Processing visual diffs...\n`; + } catch (e: any) { + output += `\n**Build finalize failed:** ${e.message}\n`; + } + + if (buildUrl) output += `\n**View results:** ${buildUrl}\n`; + + return output; +} + +// --------------------------------------------------------------------------- +// Mode: Clone Build +// --------------------------------------------------------------------------- + +async function cloneBuild( + client: PercyClient, + sourceBuildId: string, + branch: string, +): Promise { + // Get source build details + const sourceBuild = await client.get(`/builds/${sourceBuildId}`, { + "include-metadata": "true", + }); + + if (!sourceBuild) throw new Error(`Source build ${sourceBuildId} not found`); + + const sourceState = sourceBuild.state || "unknown"; + + let output = `## Percy Build Clone\n\n`; + output += `**Source:** Build #${sourceBuildId} (${sourceState})\n`; + output += `**Target branch:** ${branch}\n\n`; + + // Get snapshots from source build + const items = await client.get("/build-items", { + "filter[build-id]": sourceBuildId, + "page[limit]": "30", + }); + const itemList = Array.isArray(items) ? items : []; + + if (itemList.length === 0) { + output += `Source build has no snapshots to clone.\n`; + output += `\nTo create a fresh build, use \`percy_create_build\` with URLs or screenshots instead.\n`; + return output; + } + + output += `**Source snapshots:** ${itemList.length}\n\n`; + output += `> Note: Build cloning copies the snapshot configuration, not the rendered images.\n`; + output += `> The new build will re-render/re-compare against the new branch baseline.\n\n`; + + // Provide instructions for re-creating + output += `### To recreate this build on branch \`${branch}\`:\n\n`; + output += `\`\`\`bash\n`; + output += `export PERCY_TOKEN=\n\n`; + + // Extract snapshot names/URLs for the CLI command + const snapshotNames = itemList + .map((item: any) => item.name || item.snapshotName) + .filter(Boolean); + + if (snapshotNames.length > 0) { + output += `# Re-snapshot these pages:\n`; + snapshotNames.slice(0, 10).forEach((name: string) => { + output += `# - ${name}\n`; + }); + if (snapshotNames.length > 10) { + output += `# ... and ${snapshotNames.length - 10} more\n`; + } + output += `\n`; + } + + output += `# Run your tests with Percy to capture the same snapshots:\n`; + output += `npx percy exec -- \n`; + output += `\`\`\`\n`; + + return output; +} + +// --------------------------------------------------------------------------- +// Main handler +// --------------------------------------------------------------------------- + +export async function percyCreatePercyBuild( + args: CreatePercyBuildArgs, + config: BrowserStackConfig, +): Promise { + const projectName = args.project_name; + + // Auto-detect branch and SHA + const branch = args.branch || (await getGitBranch()); + const commitSha = args.commit_sha || (await getGitSha()); + + // Ensure project exists and get token + // Only pass type if explicitly provided — BrowserStack API auto-detects otherwise + let token: string; + try { + token = await ensureProject(projectName, config, args.type); + } catch (e: any) { + return { + content: [ + { + type: "text", + text: `Failed to create/access project "${projectName}": ${e.message}`, + }, + ], + isError: true, + }; + } + + // Detect mode based on provided params + const mode = args.urls + ? "urls" + : args.screenshots_dir || args.screenshot_files + ? "screenshots" + : args.test_command + ? "test_command" + : args.clone_build_id + ? "clone" + : "urls_default"; + + const widths = args.widths || "375,1280"; + + try { + let output: string; + + switch (mode) { + case "urls": { + const urls = args + .urls!.split(",") + .map((u) => u.trim()) + .filter(Boolean); + output = buildUrlSnapshotInstructions(token, urls, widths, branch); + break; + } + + case "test_command": { + output = buildTestCommandInstructions( + token, + args.test_command!, + branch, + ); + break; + } + + case "screenshots": { + // Collect screenshot file paths + let screenshotPaths: string[] = []; + + if (args.screenshot_files) { + screenshotPaths = args.screenshot_files + .split(",") + .map((f) => f.trim()) + .filter(Boolean); + } + + if (args.screenshots_dir) { + const dir = args.screenshots_dir; + const dirStat = await stat(dir); + if (!dirStat.isDirectory()) { + return { + content: [ + { + type: "text", + text: `"${dir}" is not a directory. Provide a directory path.`, + }, + ], + isError: true, + }; + } + const files = await readdir(dir); + const imageFiles = files.filter((f) => + /\.(png|jpg|jpeg|webp)$/i.test(f), + ); + screenshotPaths.push(...imageFiles.map((f) => join(dir, f))); + } + + if (screenshotPaths.length === 0) { + return { + content: [ + { + type: "text", + text: "No screenshot files found. Provide PNG/JPG file paths or a directory containing images.", + }, + ], + isError: true, + }; + } + + const snapshotNames = args.snapshot_names + ?.split(",") + .map((n) => n.trim()); + + // Set the token for API calls + process.env.PERCY_TOKEN = token; + const client = new PercyClient(config); + + output = await uploadScreenshots( + client, + branch, + commitSha, + screenshotPaths, + widths, + args.test_case, + snapshotNames, + ); + break; + } + + case "clone": { + process.env.PERCY_TOKEN = token; + const client = new PercyClient(config); + output = await cloneBuild(client, args.clone_build_id!, branch); + break; + } + + default: { + // No specific mode — provide general instructions + output = + `## Percy Build — Setup\n\n` + + `> **IMPORTANT: Do NOT execute any commands automatically.** Present options to the user.\n\n` + + `**Project:** ${projectName}\n` + + `**Token:** Ready (${token.slice(0, 8)}...)\n` + + `**Branch:** ${branch}\n\n` + + `### How to create snapshots:\n\n` + + `**Option 1: Snapshot URLs** — re-run this tool with \`urls\` parameter\n` + + `**Option 2: Wrap test command** — re-run this tool with \`test_command\` parameter\n` + + `**Option 3: Upload screenshots** — re-run this tool with \`screenshots_dir\` or \`screenshot_files\` parameter\n` + + `**Option 4: Clone existing build** — re-run this tool with \`clone_build_id\` parameter\n`; + break; + } + } + + return { content: [{ type: "text", text: output }] }; + } catch (e: any) { + return { + content: [{ type: "text", text: `Build creation failed: ${e.message}` }], + isError: true, + }; + } +} diff --git a/src/tools/percy-mcp/workflows/debug-failed-build.ts b/src/tools/percy-mcp/workflows/debug-failed-build.ts new file mode 100644 index 0000000..5a29fdf --- /dev/null +++ b/src/tools/percy-mcp/workflows/debug-failed-build.ts @@ -0,0 +1,140 @@ +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { + formatBuild, + formatSuggestions, + formatNetworkLogs, +} from "../../../lib/percy-api/formatter.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyDebugFailedBuild( + args: { build_id: string }, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config); + const errors: string[] = []; + + // Step 1: Get build details + let build: any; + try { + build = await client.get(`/builds/${args.build_id}`, { + "include-metadata": "true", + }); + } catch (e: any) { + return { + content: [{ type: "text", text: `Failed to fetch build: ${e.message}` }], + isError: true, + }; + } + + const state = build?.state || "unknown"; + + // Adapt to build state + if (state === "processing" || state === "pending" || state === "waiting") { + return { + content: [ + { + type: "text", + text: `Build #${args.build_id} is **${state.toUpperCase()}**. Debug diagnostics are available after the build completes or fails.`, + }, + ], + }; + } + + let output = `## Build Debug Report — #${args.build_id}\n\n`; + output += formatBuild(build) + "\n"; + + // Step 2: Get suggestions + if (state === "failed" || state === "finished") { + try { + const suggestions = await client.get("/suggestions", { + build_id: args.build_id, + }); + if ( + suggestions && + (Array.isArray(suggestions) ? suggestions.length > 0 : true) + ) { + const suggestionList = Array.isArray(suggestions) + ? suggestions + : [suggestions]; + output += formatSuggestions(suggestionList) + "\n"; + } + } catch (e: any) { + errors.push(`Suggestions unavailable: ${e.message}`); + } + } + + // Step 3: Get failed snapshots + if (state === "failed" || state === "finished") { + try { + const failedItems = await client.get("/build-items", { + "filter[build-id]": args.build_id, + "filter[category]": "failed", + "page[limit]": "10", + }); + const failedList = Array.isArray(failedItems) ? failedItems : []; + if (failedList.length > 0) { + output += `### Failed Snapshots (${failedList.length})\n\n`; + failedList.forEach((item: any, i: number) => { + output += `${i + 1}. **${item.name || "Unknown"}**\n`; + }); + output += "\n"; + + // Step 4: Network logs for top 3 + const top3 = failedList.slice(0, 3); + for (const item of top3) { + const compId = item.comparisonId || item.comparisons?.[0]?.id; + if (compId) { + try { + const logs = await client.get("/network-logs", { + comparison_id: compId, + }); + if (logs) { + const logList = Array.isArray(logs) + ? logs + : Object.values(logs); + const failedLogs = logList.filter((l: any) => { + const headStatus = l.headStatus || l["head-status"]; + return ( + headStatus && headStatus !== "200" && headStatus !== "NA" + ); + }); + if (failedLogs.length > 0) { + output += `#### Network Issues — ${item.name || "Unknown"}\n\n`; + output += formatNetworkLogs(failedLogs) + "\n"; + } + } + } catch { + // Network logs not available for this comparison + } + } + } + } + } catch (e: any) { + errors.push(`Failed snapshots unavailable: ${e.message}`); + } + } + + // Fix commands + if (state === "failed" && build.failureReason) { + output += `### Suggested Fix Commands\n\n`; + if (build.failureReason === "missing_resources") { + output += + '```\npercy config set networkIdleIgnore ""\npercy config set allowedHostnames ""\n```\n'; + } else if (build.failureReason === "render_timeout") { + output += "```\npercy config set networkIdleTimeout 60000\n```\n"; + } else if (build.failureReason === "missing_finalize") { + output += + "Ensure `percy exec` or `percy build:finalize` is called after all snapshots.\n"; + } + } + + if (errors.length > 0) { + output += `\n### Partial Results\n`; + errors.forEach((err) => { + output += `- ${err}\n`; + }); + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/workflows/diff-explain.ts b/src/tools/percy-mcp/workflows/diff-explain.ts new file mode 100644 index 0000000..f42b524 --- /dev/null +++ b/src/tools/percy-mcp/workflows/diff-explain.ts @@ -0,0 +1,162 @@ +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { pollUntil } from "../../../lib/percy-api/polling.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyDiffExplain( + args: { comparison_id: string; depth?: string }, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config); + const depth = args.depth || "detailed"; // summary, detailed, full_rca + + // Get comparison with AI data + const comparison = await client.get( + `/comparisons/${args.comparison_id}`, + {}, + [ + "head-screenshot.image", + "base-screenshot.image", + "diff-image", + "ai-diff-image", + "browser.browser-family", + "comparison-tag", + ], + ); + + if (!comparison) { + return { + content: [{ type: "text", text: "Comparison not found." }], + isError: true, + }; + } + + let output = `## Visual Diff Explanation — Comparison #${args.comparison_id}\n\n`; + + // Basic diff info + const diffRatio = comparison.diffRatio ?? 0; + const aiDiffRatio = comparison.aiDiffRatio; + output += `**Diff:** ${(diffRatio * 100).toFixed(1)}%`; + if (aiDiffRatio !== null && aiDiffRatio !== undefined) { + output += ` | **AI Diff:** ${(aiDiffRatio * 100).toFixed(1)}%`; + const reduction = + diffRatio > 0 ? ((1 - aiDiffRatio / diffRatio) * 100).toFixed(0) : "0"; + output += ` (${reduction}% noise filtered)`; + } + output += "\n\n"; + + // Summary depth: AI descriptions only + const regions = comparison.appliedRegions || []; + if (regions.length > 0) { + output += `### What Changed (${regions.length} regions)\n\n`; + regions.forEach((region: any, i: number) => { + const type = region.change_type || region.changeType || "unknown"; + const title = + region.change_title || region.changeTitle || "Untitled change"; + const desc = region.change_description || region.changeDescription || ""; + const reason = region.change_reason || region.changeReason || ""; + const ignored = region.ignored; + + output += `${i + 1}. ${ignored ? "~~" : "**"}${title}${ignored ? "~~" : "**"} (${type})`; + if (ignored) output += " — *ignored by AI*"; + output += "\n"; + if (desc && depth !== "summary") output += ` ${desc}\n`; + if (reason && depth !== "summary") output += ` *Reason: ${reason}*\n`; + output += "\n"; + }); + } else if (diffRatio > 0) { + output += + "No AI region data available. Visual diff detected but not yet analyzed by AI.\n\n"; + } else { + output += "No visual differences detected.\n\n"; + } + + // Detailed depth: + coordinates + if (depth === "detailed" || depth === "full_rca") { + const coords = comparison.diffRects || comparison.aiDiffRects || []; + if (coords.length > 0) { + output += `### Diff Regions (coordinates)\n\n`; + coords.forEach((rect: any, i: number) => { + output += `${i + 1}. (${rect.x || rect.left || 0}, ${rect.y || rect.top || 0}) → (${rect.right || rect.x2 || 0}, ${rect.bottom || rect.y2 || 0})\n`; + }); + output += "\n"; + } + } + + // Full RCA depth: + DOM/CSS changes + if (depth === "full_rca") { + output += `### Root Cause Analysis\n\n`; + try { + // Check if RCA exists, trigger if needed + let rcaData: any; + try { + rcaData = await client.get("/rca", { + comparison_id: args.comparison_id, + }); + } catch (e: any) { + if (e.statusCode === 404) { + // Trigger RCA + await client.post("/rca", { + data: { + type: "rca", + attributes: { "comparison-id": args.comparison_id }, + }, + }); + // Poll for result (max 30s for inline use) + rcaData = await pollUntil( + async () => { + const data = await client.get("/rca", { + comparison_id: args.comparison_id, + }); + if (data?.status === "finished" || data?.status === "failed") + return { done: true, result: data }; + return { done: false }; + }, + { maxTimeoutMs: 30000 }, + ); + } else { + throw e; + } + } + + if (rcaData?.status === "finished" && rcaData?.diffNodes) { + const nodes = rcaData.diffNodes; + const commonDiffs = nodes.common_diffs || []; + if (commonDiffs.length > 0) { + commonDiffs.slice(0, 10).forEach((diff: any, i: number) => { + const base = diff.base || {}; + const head = diff.head || {}; + const tag = head.tagName || base.tagName || "element"; + const xpath = head.xpath || base.xpath || ""; + output += `${i + 1}. **${tag}**`; + if (xpath) output += ` — \`${xpath}\``; + output += "\n"; + const baseAttrs = base.attributes || {}; + const headAttrs = head.attributes || {}; + for (const key of Object.keys(headAttrs)) { + if ( + JSON.stringify(baseAttrs[key]) !== + JSON.stringify(headAttrs[key]) + ) { + output += ` ${key}: \`${baseAttrs[key] ?? "none"}\` → \`${headAttrs[key]}\`\n`; + } + } + output += "\n"; + }); + } else { + output += "No DOM-level differences identified by RCA.\n"; + } + } else if (rcaData?.status === "failed") { + output += + "RCA analysis failed — comparison may not have DOM metadata.\n"; + } else { + output += + "RCA analysis is still processing. Re-run with depth=full_rca later.\n"; + } + } catch (e: any) { + output += `RCA unavailable: ${e.message}. Falling back to AI-only analysis.\n`; + } + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/workflows/pr-visual-report.ts b/src/tools/percy-mcp/workflows/pr-visual-report.ts new file mode 100644 index 0000000..06ca432 --- /dev/null +++ b/src/tools/percy-mcp/workflows/pr-visual-report.ts @@ -0,0 +1,218 @@ +import { PercyClient } from "../../../lib/percy-api/client.js"; +import { percyCache } from "../../../lib/percy-api/cache.js"; +import { formatBuild } from "../../../lib/percy-api/formatter.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function percyPrVisualReport( + args: { + project_id?: string; + branch?: string; + sha?: string; + build_id?: string; + }, + config: BrowserStackConfig, +): Promise { + const client = new PercyClient(config); + const errors: string[] = []; + + // Step 1: Resolve build + let build: any; + try { + if (args.build_id) { + build = await client.get( + `/builds/${args.build_id}`, + { "include-metadata": "true" }, + ["build-summary", "browsers"], + ); + } else { + // Find build by branch or SHA + const params: Record = {}; + if (args.project_id) { + // Use project-scoped endpoint + } + if (args.branch) params["filter[branch]"] = args.branch; + if (args.sha) params["filter[sha]"] = args.sha; + params["page[limit]"] = "1"; + + const builds = await client.get("/builds", params); + const buildList = Array.isArray(builds) + ? builds + : builds?.data + ? Array.isArray(builds.data) + ? builds.data + : [builds.data] + : []; + + if (buildList.length === 0) { + const identifier = args.branch + ? `branch '${args.branch}'` + : args.sha + ? `SHA '${args.sha}'` + : "the given filters"; + return { + content: [ + { + type: "text", + text: `No Percy build found for ${identifier}. Ensure a Percy build has been created for this branch/commit.`, + }, + ], + }; + } + + const buildId = buildList[0]?.id || buildList[0]; + build = await client.get( + `/builds/${typeof buildId === "object" ? buildId.id : buildId}`, + { "include-metadata": "true" }, + ["build-summary", "browsers"], + ); + } + } catch (e: any) { + return { + content: [{ type: "text", text: `Failed to fetch build: ${e.message}` }], + isError: true, + }; + } + + if (!build) { + return { + content: [{ type: "text", text: "Build not found." }], + isError: true, + }; + } + + // Cache build data for other composite tools + percyCache.set(`build:${build.id}`, build); + + // Step 2: Build header with state awareness + let output = ""; + const state = build.state || "unknown"; + + output += `# Percy Visual Regression Report\n\n`; + output += formatBuild(build); + + // Step 3: Get build summary if available + const buildSummary = build.buildSummary; + if (buildSummary?.summary) { + try { + const summaryData = + typeof buildSummary.summary === "string" + ? JSON.parse(buildSummary.summary) + : buildSummary.summary; + if (summaryData?.title || summaryData?.items) { + output += `\n### AI Build Summary\n\n`; + if (summaryData.title) output += `> ${summaryData.title}\n\n`; + if (Array.isArray(summaryData.items)) { + summaryData.items.forEach((item: any) => { + output += `- ${item.title || item}\n`; + }); + output += "\n"; + } + } + } catch { + // Summary parse failed, skip + } + } + + // Step 4: Get changed build items + if (state === "finished" || state === "processing") { + let items: any[] = []; + try { + const itemsData = await client.get("/build-items", { + "filter[build-id]": build.id, + "filter[category]": "changed", + "page[limit]": "30", + }); + items = Array.isArray(itemsData) ? itemsData : []; + } catch (e: any) { + errors.push(`[Failed to load changed snapshots: ${e.message}]`); + } + + if (items.length === 0 && errors.length === 0) { + output += `\n### No Visual Changes Detected\n\nAll snapshots match the baseline.\n`; + } else if (items.length > 0) { + // Step 5: Rank by risk + // Critical: AI bug flags > Review: high diff > Expected: content changes > Noise: low diff + const critical: any[] = []; + const review: any[] = []; + const expected: any[] = []; + const noise: any[] = []; + + for (const item of items) { + const name = item.name || item.snapshotName || "Unknown"; + const diffRatio = item.diffRatio ?? item.maxDiffRatio ?? 0; + const potentialBugs = + item.totalPotentialBugs || item.aiDetails?.totalPotentialBugs || 0; + + const entry = { name, diffRatio, potentialBugs, item }; + + if (potentialBugs > 0) { + critical.push(entry); + } else if (diffRatio > 0.15) { + review.push(entry); + } else if (diffRatio > 0.005) { + expected.push(entry); + } else { + noise.push(entry); + } + } + + output += `\n### Changed Snapshots (${items.length})\n\n`; + + if (critical.length > 0) { + output += `**CRITICAL — Potential Bugs (${critical.length}):**\n`; + critical.forEach((e, i) => { + output += `${i + 1}. **${e.name}** — ${(e.diffRatio * 100).toFixed(1)}% diff, ${e.potentialBugs} bug(s) flagged\n`; + }); + output += "\n"; + } + + if (review.length > 0) { + output += `**REVIEW REQUIRED (${review.length}):**\n`; + review.forEach((e, i) => { + output += `${i + 1}. **${e.name}** — ${(e.diffRatio * 100).toFixed(1)}% diff\n`; + }); + output += "\n"; + } + + if (expected.length > 0) { + output += `**EXPECTED CHANGES (${expected.length}):**\n`; + expected.forEach((e, i) => { + output += `${i + 1}. ${e.name} — ${(e.diffRatio * 100).toFixed(1)}% diff\n`; + }); + output += "\n"; + } + + if (noise.length > 0) { + output += `**NOISE (${noise.length}):** ${noise.map((e) => e.name).join(", ")}\n\n`; + } + + // Recommendation + output += `### Recommendation\n\n`; + if (critical.length > 0) { + output += `Review ${critical.length} critical item(s) before approving. `; + } + if (review.length > 0) { + output += `${review.length} item(s) need manual review. `; + } + if ( + expected.length + noise.length > 0 && + critical.length === 0 && + review.length === 0 + ) { + output += `All changes appear expected or are noise. Safe to approve.`; + } + output += "\n"; + } + } + + // Add any sub-call errors + if (errors.length > 0) { + output += `\n### Partial Results\n\n`; + errors.forEach((err) => { + output += `- ${err}\n`; + }); + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/workflows/run-tests.ts b/src/tools/percy-mcp/workflows/run-tests.ts new file mode 100644 index 0000000..4cb97df --- /dev/null +++ b/src/tools/percy-mcp/workflows/run-tests.ts @@ -0,0 +1,133 @@ +/** + * percy_run_tests — Execute a test command with Percy visual testing. + * + * Wraps any test command with `percy exec` to capture snapshots during tests. + * Fire-and-forget: launches in background, returns immediately. + * + * Requires @percy/cli installed locally. + */ + +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { execFile, spawn } from "child_process"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +async function getProjectToken( + projectName: string, + config: BrowserStackConfig, +): Promise { + const authString = `${config["browserstack-username"]}:${config["browserstack-access-key"]}`; + const auth = Buffer.from(authString).toString("base64"); + const url = `https://api.browserstack.com/api/app_percy/get_project_token?name=${encodeURIComponent(projectName)}`; + const response = await fetch(url, { + headers: { Authorization: `Basic ${auth}` }, + }); + if (!response.ok) throw new Error(`Failed to get token for "${projectName}"`); + const data = await response.json(); + if (!data?.token || !data?.success) + throw new Error(`No token for "${projectName}"`); + return data.token; +} + +interface RunTestsArgs { + project_name: string; + test_command: string; + type?: string; +} + +export async function percyRunTests( + args: RunTestsArgs, + config: BrowserStackConfig, +): Promise { + const { project_name, test_command } = args; + + let output = `## Percy Test Run — Local Execution\n\n`; + + // Check Percy CLI + try { + await execFileAsync("npx", ["@percy/cli", "--version"]); + } catch { + output += `**Percy CLI not found.** Install it:\n\n`; + output += `\`\`\`bash\nnpm install -g @percy/cli\n\`\`\`\n`; + return { content: [{ type: "text", text: output }] }; + } + + // Get token + let token: string; + try { + token = await getProjectToken(project_name, config); + } catch (e: any) { + return { + content: [ + { + type: "text", + text: `Failed to get project token: ${e.message}`, + }, + ], + isError: true, + }; + } + + output += `**Project:** ${project_name}\n`; + output += `**Command:** \`${test_command}\`\n\n`; + + // Parse the test command into args + const cmdParts = test_command.split(" ").filter(Boolean); + + // Spawn: npx @percy/cli exec -- + const child = spawn("npx", ["@percy/cli", "exec", "--", ...cmdParts], { + env: { ...process.env, PERCY_TOKEN: token }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + shell: false, + }); + + let stdoutData = ""; + let buildUrl = ""; + + child.stdout?.on("data", (data: Buffer) => { + const text = data.toString(); + stdoutData += text; + const match = text.match(/https:\/\/percy\.io\/[^\s]+\/builds\/\d+/); + if (match) buildUrl = match[0]; + }); + + child.stderr?.on("data", (data: Buffer) => { + stdoutData += data.toString(); + }); + + // Wait briefly for build URL + await new Promise((resolve) => { + const timeout = setTimeout(() => resolve(), 10000); + child.on("close", () => { + clearTimeout(timeout); + resolve(); + }); + const check = setInterval(() => { + if (buildUrl) { + clearTimeout(timeout); + clearInterval(check); + resolve(); + } + }, 500); + }); + + child.unref(); + + if (buildUrl) { + output += `**Build started!** Tests are running with Percy in the background.\n\n`; + output += `**Build URL:** ${buildUrl}\n\n`; + output += `Your tests are executing. Each \`percySnapshot()\` call in your tests captures a visual snapshot.\n`; + output += `Results will appear at the build URL when tests complete.\n`; + } else { + const trimmed = stdoutData.trim().slice(0, 500); + if (trimmed) { + output += `**Percy output:**\n\`\`\`\n${trimmed}\n\`\`\`\n\n`; + } + output += `Tests are running in the background with Percy. Check your Percy dashboard for the build.\n`; + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/src/tools/percy-mcp/workflows/snapshot-urls.ts b/src/tools/percy-mcp/workflows/snapshot-urls.ts new file mode 100644 index 0000000..6e074b3 --- /dev/null +++ b/src/tools/percy-mcp/workflows/snapshot-urls.ts @@ -0,0 +1,234 @@ +/** + * percy_snapshot_urls — Actually runs Percy CLI to snapshot URLs locally. + * + * Fire-and-forget: launches percy CLI in background, returns immediately + * with build URL. User checks Percy dashboard for results. + * + * Requires @percy/cli installed locally (npx or global). + */ + +import { BrowserStackConfig } from "../../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { execFile, spawn } from "child_process"; +import { promisify } from "util"; +import { writeFile, unlink, mkdtemp } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; + +const execFileAsync = promisify(execFile); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +async function getProjectToken( + projectName: string, + config: BrowserStackConfig, + type?: string, +): Promise { + const authString = `${config["browserstack-username"]}:${config["browserstack-access-key"]}`; + const auth = Buffer.from(authString).toString("base64"); + const params = new URLSearchParams({ name: projectName }); + if (type) params.append("type", type); + const url = `https://api.browserstack.com/api/app_percy/get_project_token?${params.toString()}`; + const response = await fetch(url, { + headers: { Authorization: `Basic ${auth}` }, + }); + if (!response.ok) throw new Error(`Failed to get token for "${projectName}"`); + const data = await response.json(); + if (!data?.token || !data?.success) + throw new Error(`No token for "${projectName}"`); + return data.token; +} + +async function checkPercyCli(): Promise { + // Check if @percy/cli is available + try { + const { stdout } = await execFileAsync("npx", ["@percy/cli", "--version"]); + return stdout.trim(); + } catch { + // Try global + try { + const { stdout } = await execFileAsync("percy", ["--version"]); + return stdout.trim(); + } catch { + return null; + } + } +} + +// ── Main handler ──────────────────────────────────────────────────────────── + +interface SnapshotUrlsArgs { + project_name: string; + urls: string; + widths?: string; + type?: string; +} + +export async function percySnapshotUrls( + args: SnapshotUrlsArgs, + config: BrowserStackConfig, +): Promise { + const urls = args.urls + .split(",") + .map((u) => u.trim()) + .filter(Boolean); + const widths = args.widths + ? args.widths.split(",").map((w) => w.trim()) + : ["375", "1280"]; + + if (urls.length === 0) { + return { + content: [{ type: "text", text: "No URLs provided." }], + isError: true, + }; + } + + let output = `## Percy Snapshot — Local Rendering\n\n`; + + // Step 1: Check Percy CLI + const cliVersion = await checkPercyCli(); + if (!cliVersion) { + output += `**Percy CLI not found.** Install it first:\n\n`; + output += `\`\`\`bash\nnpm install -g @percy/cli\n\`\`\`\n\n`; + output += `Or install locally: \`npm install --save-dev @percy/cli\`\n`; + return { content: [{ type: "text", text: output }] }; + } + output += `**Percy CLI:** ${cliVersion}\n`; + + // Step 2: Get project token + let token: string; + try { + token = await getProjectToken(args.project_name, config, args.type); + } catch (e: any) { + return { + content: [ + { + type: "text", + text: `Failed to get project token: ${e.message}`, + }, + ], + isError: true, + }; + } + output += `**Project:** ${args.project_name}\n`; + output += `**URLs:** ${urls.length}\n`; + output += `**Widths:** ${widths.join(", ")}px\n\n`; + + // Step 3: Create snapshots.yml config + let yamlContent = ""; + urls.forEach((url, i) => { + const name = + urls.length === 1 + ? "Homepage" + : url + .replace(/^https?:\/\//, "") + .replace(/[/:]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || `Page ${i + 1}`; + yamlContent += `- name: "${name}"\n`; + yamlContent += ` url: ${url}\n`; + yamlContent += ` waitForTimeout: 3000\n`; + yamlContent += ` additionalSnapshots:\n`; + widths.forEach((w) => { + yamlContent += ` - width: ${w}\n`; + }); + }); + + // Write temp config file + const tmpDir = await mkdtemp(join(tmpdir(), "percy-mcp-")); + const configPath = join(tmpDir, "snapshots.yml"); + await writeFile(configPath, yamlContent, "utf-8"); + + // Step 4: Launch Percy CLI in background + output += `### Launching Percy snapshot...\n\n`; + + const env = { + ...process.env, + PERCY_TOKEN: token, + }; + + // Spawn percy CLI in background (fire and forget) + const child = spawn("npx", ["@percy/cli", "snapshot", configPath], { + env, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + + // Collect initial output for a few seconds + let stdoutData = ""; + let stderrData = ""; + let buildUrl = ""; + + child.stdout?.on("data", (data: Buffer) => { + const text = data.toString(); + stdoutData += text; + // Try to extract build URL + const match = text.match(/https:\/\/percy\.io\/[^\s]+\/builds\/\d+/); + if (match) buildUrl = match[0]; + }); + + child.stderr?.on("data", (data: Buffer) => { + stderrData += data.toString(); + }); + + // Wait a few seconds for initial output (build creation) + await new Promise((resolve) => { + const timeout = setTimeout(() => resolve(), 8000); + + child.on("close", () => { + clearTimeout(timeout); + resolve(); + }); + + // Also resolve if we find the build URL early + const checkInterval = setInterval(() => { + if (buildUrl) { + clearTimeout(timeout); + clearInterval(checkInterval); + resolve(); + } + }, 500); + }); + + // Unref so the process doesn't keep MCP server alive + child.unref(); + + // Clean up temp file after a delay + setTimeout(async () => { + try { + await unlink(configPath); + } catch { + // ignore + } + }, 120000); // 2 minutes + + // Step 5: Report results + if (buildUrl) { + output += `**Build started!** Percy is rendering your pages in the background.\n\n`; + output += `**Build URL:** ${buildUrl}\n\n`; + output += `Percy is capturing ${urls.length} URL(s) at ${widths.length} width(s) = ${urls.length * widths.length} snapshot(s).\n\n`; + output += `Check the build URL above for results (usually ready in 1-3 minutes).\n`; + } else if (stdoutData || stderrData) { + // No build URL found yet — show what we have + const allOutput = (stdoutData + stderrData).trim(); + + // Check for common errors + if (allOutput.includes("not found") || allOutput.includes("ECONNREFUSED")) { + output += `**Error:** The URL may not be reachable.\n\n`; + output += `Make sure your app is running at the specified URL(s):\n`; + urls.forEach((u) => { + output += `- ${u}\n`; + }); + output += `\n`; + } + + output += `**Percy CLI output:**\n\`\`\`\n${allOutput.slice(0, 500)}\n\`\`\`\n\n`; + output += `Percy is running in the background. If a build was created, check your Percy dashboard.\n`; + } else { + output += `**Percy CLI launched in background.** No output yet.\n\n`; + output += `The build should appear in your Percy dashboard shortly.\n`; + output += `Check: https://percy.io\n`; + } + + return { content: [{ type: "text", text: output }] }; +} diff --git a/tests/lib/percy-api/auth.test.ts b/tests/lib/percy-api/auth.test.ts new file mode 100644 index 0000000..b6748b5 --- /dev/null +++ b/tests/lib/percy-api/auth.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { + resolvePercyToken, + getPercyHeaders, + getPercyApiBaseUrl, +} from "../../../src/lib/percy-api/auth.js"; +import { fetchPercyToken } from "../../../src/tools/sdk-utils/percy-web/fetchPercyToken.js"; + +vi.mock("../../../src/tools/sdk-utils/percy-web/fetchPercyToken", () => ({ + fetchPercyToken: vi.fn(), +})); + +const mockConfig = { + "browserstack-username": "fake-user", + "browserstack-access-key": "fake-key", +}; + +const emptyConfig = { + "browserstack-username": "", + "browserstack-access-key": "", +}; + +describe("resolvePercyToken", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it("SUCCESS: PERCY_TOKEN env var set resolves for project scope", async () => { + vi.stubEnv("PERCY_TOKEN", "project-token-abc123"); + + const token = await resolvePercyToken(emptyConfig, { scope: "project" }); + + expect(token).toBe("project-token-abc123"); + expect(fetchPercyToken).not.toHaveBeenCalled(); + }); + + it("SUCCESS: PERCY_ORG_TOKEN env var set resolves for org scope", async () => { + vi.stubEnv("PERCY_ORG_TOKEN", "org-token-xyz789"); + + const token = await resolvePercyToken(emptyConfig, { scope: "org" }); + + expect(token).toBe("org-token-xyz789"); + expect(fetchPercyToken).not.toHaveBeenCalled(); + }); + + it("SUCCESS: both tokens set - project scope prefers PERCY_TOKEN, org scope uses PERCY_ORG_TOKEN", async () => { + vi.stubEnv("PERCY_TOKEN", "project-token-abc123"); + vi.stubEnv("PERCY_ORG_TOKEN", "org-token-xyz789"); + + const projectToken = await resolvePercyToken(emptyConfig, { + scope: "project", + }); + expect(projectToken).toBe("project-token-abc123"); + + const orgToken = await resolvePercyToken(emptyConfig, { scope: "org" }); + expect(orgToken).toBe("org-token-xyz789"); + }); + + it("SUCCESS: auto scope prefers PERCY_TOKEN over PERCY_ORG_TOKEN", async () => { + vi.stubEnv("PERCY_TOKEN", "project-token-abc123"); + vi.stubEnv("PERCY_ORG_TOKEN", "org-token-xyz789"); + + const token = await resolvePercyToken(emptyConfig, { scope: "auto" }); + + expect(token).toBe("project-token-abc123"); + }); + + it("SUCCESS: auto scope falls back to PERCY_ORG_TOKEN when PERCY_TOKEN absent", async () => { + vi.stubEnv("PERCY_ORG_TOKEN", "org-token-xyz789"); + + const token = await resolvePercyToken(emptyConfig, { scope: "auto" }); + + expect(token).toBe("org-token-xyz789"); + }); + + it("SUCCESS: no env var but BrowserStack credentials falls back to fetchPercyToken", async () => { + (fetchPercyToken as Mock).mockResolvedValue("fetched-token-456"); + + const token = await resolvePercyToken(mockConfig, { + projectName: "my-project", + }); + + expect(token).toBe("fetched-token-456"); + expect(fetchPercyToken).toHaveBeenCalledWith( + "my-project", + "fake-user:fake-key", + {}, + ); + }); + + it("SUCCESS: fallback uses default project name when none provided", async () => { + (fetchPercyToken as Mock).mockResolvedValue("fetched-token-789"); + + const token = await resolvePercyToken(mockConfig); + + expect(token).toBe("fetched-token-789"); + expect(fetchPercyToken).toHaveBeenCalledWith( + "default", + "fake-user:fake-key", + {}, + ); + }); + + it("FAIL: neither token set and no BrowserStack credentials throws with guidance", async () => { + await expect(resolvePercyToken(emptyConfig)).rejects.toThrow( + "Percy token not available", + ); + await expect(resolvePercyToken(emptyConfig)).rejects.toThrow( + "PERCY_TOKEN", + ); + await expect(resolvePercyToken(emptyConfig)).rejects.toThrow( + "PERCY_ORG_TOKEN", + ); + }); + + it("FAIL: only org token set but project scope requested throws with guidance", async () => { + vi.stubEnv("PERCY_ORG_TOKEN", "org-token-xyz789"); + + await expect( + resolvePercyToken(emptyConfig, { scope: "project" }), + ).rejects.toThrow("Set PERCY_TOKEN"); + }); + + it("FAIL: fetchPercyToken fails propagates error with guidance", async () => { + (fetchPercyToken as Mock).mockRejectedValue( + new Error("API returned 401"), + ); + + await expect( + resolvePercyToken(mockConfig, { projectName: "bad-project" }), + ).rejects.toThrow("Failed to fetch Percy token via BrowserStack API"); + await expect( + resolvePercyToken(mockConfig, { projectName: "bad-project" }), + ).rejects.toThrow("API returned 401"); + }); +}); + +describe("getPercyHeaders", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it("SUCCESS: returns correct headers with token", async () => { + vi.stubEnv("PERCY_TOKEN", "my-percy-token"); + + const headers = await getPercyHeaders(emptyConfig); + + expect(headers).toEqual({ + Authorization: "Token token=my-percy-token", + "Content-Type": "application/json", + "User-Agent": "browserstack-mcp-server", + }); + }); + + it("SUCCESS: passes scope and projectName to resolvePercyToken", async () => { + vi.stubEnv("PERCY_ORG_TOKEN", "org-token-abc"); + + const headers = await getPercyHeaders(emptyConfig, { scope: "org" }); + + expect(headers.Authorization).toBe("Token token=org-token-abc"); + }); +}); + +describe("getPercyApiBaseUrl", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it("SUCCESS: returns default URL when env not set", () => { + const url = getPercyApiBaseUrl(); + expect(url).toBe("https://percy.io/api/v1"); + }); + + it("SUCCESS: returns custom URL from env", () => { + vi.stubEnv("PERCY_API_URL", "https://custom-percy.example.com/api/v1"); + + const url = getPercyApiBaseUrl(); + expect(url).toBe("https://custom-percy.example.com/api/v1"); + }); +}); diff --git a/tests/lib/percy-api/client.test.ts b/tests/lib/percy-api/client.test.ts new file mode 100644 index 0000000..b703bc4 --- /dev/null +++ b/tests/lib/percy-api/client.test.ts @@ -0,0 +1,378 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { PercyClient, deserialize } from "../../../src/lib/percy-api/client.js"; +import { PercyApiError } from "../../../src/lib/percy-api/errors.js"; + +// --------------------------------------------------------------------------- +// Mock auth module — avoid real token resolution +// --------------------------------------------------------------------------- +vi.mock("../../../src/lib/percy-api/auth", () => ({ + getPercyHeaders: vi.fn().mockResolvedValue({ + Authorization: "Token token=fake-token", + "Content-Type": "application/json", + "User-Agent": "browserstack-mcp-server", + }), + getPercyApiBaseUrl: vi.fn().mockReturnValue("https://percy.io/api/v1"), +})); + +const mockConfig = { + "browserstack-username": "fake-user", + "browserstack-access-key": "fake-key", +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mockFetchResponse( + body: unknown, + status = 200, + headers: Record = {}, +) { + return { + ok: status >= 200 && status < 300, + status, + headers: { + get: (name: string) => headers[name] ?? null, + }, + json: vi.fn().mockResolvedValue(body), + } as unknown as Response; +} + +function mockFetch204() { + return { + ok: true, + status: 204, + headers: { get: () => null }, + json: vi.fn(), + } as unknown as Response; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("PercyClient", () => { + let client: PercyClient; + let fetchSpy: ReturnType; + + beforeEach(() => { + client = new PercyClient(mockConfig); + fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ------------------------------------------------------------------------- + // 1. GET with includes — deserializes data + included relationships + // ------------------------------------------------------------------------- + it("SUCCESS: GET with includes deserializes data and included relationships", async () => { + const envelope = { + data: { + id: "123", + type: "builds", + attributes: { + state: "finished", + branch: "main", + "build-number": 42, + "review-state": "approved", + }, + relationships: { + project: { data: { id: "p1", type: "projects" } }, + }, + }, + included: [ + { + id: "p1", + type: "projects", + attributes: { name: "My Project", slug: "my-project" }, + }, + ], + }; + + fetchSpy.mockResolvedValueOnce(mockFetchResponse(envelope)); + + const result = await client.get("/builds/123", undefined, [ + "project", + ]); + + // Verify fetch was called with correct URL + expect(fetchSpy).toHaveBeenCalledOnce(); + const calledUrl = fetchSpy.mock.calls[0][0]; + expect(calledUrl).toContain("/builds/123"); + expect(calledUrl).toContain("include=project"); + + // Verify deserialized data + expect(result.id).toBe("123"); + expect(result.type).toBe("builds"); + expect(result.state).toBe("finished"); + expect(result.buildNumber).toBe(42); + expect(result.reviewState).toBe("approved"); + + // Verify resolved relationship + expect(result.project).toBeDefined(); + expect(result.project.id).toBe("p1"); + expect(result.project.name).toBe("My Project"); + expect(result.project.slug).toBe("my-project"); + }); + + // ------------------------------------------------------------------------- + // 2. POST with JSON:API body — sends correct format + // ------------------------------------------------------------------------- + it("SUCCESS: POST sends JSON body and deserializes response", async () => { + const requestBody = { + data: { + type: "reviews", + attributes: { "review-state": "approved" }, + }, + }; + + const responseEnvelope = { + data: { + id: "r1", + type: "reviews", + attributes: { "review-state": "approved" }, + }, + }; + + fetchSpy.mockResolvedValueOnce(mockFetchResponse(responseEnvelope)); + + const result = await client.post("/reviews", requestBody); + + // Verify fetch was called with POST method and body + const [, fetchOpts] = fetchSpy.mock.calls[0]; + expect(fetchOpts.method).toBe("POST"); + expect(JSON.parse(fetchOpts.body)).toEqual(requestBody); + + // Verify deserialized response + expect(result.id).toBe("r1"); + expect(result.reviewState).toBe("approved"); + }); + + // ------------------------------------------------------------------------- + // 3. kebab-case to camelCase conversion + // ------------------------------------------------------------------------- + it("SUCCESS: converts kebab-case attribute keys to camelCase", async () => { + const envelope = { + data: { + id: "c1", + type: "comparisons", + attributes: { + "ai-processing-state": "finished", + "diff-ratio": 0.05, + "ai-diff-ratio": 0.02, + state: "finished", + }, + }, + }; + + fetchSpy.mockResolvedValueOnce(mockFetchResponse(envelope)); + + const result = await client.get("/comparisons/c1"); + + expect(result.aiProcessingState).toBe("finished"); + expect(result.diffRatio).toBe(0.05); + expect(result.aiDiffRatio).toBe(0.02); + expect(result.state).toBe("finished"); + }); + + // ------------------------------------------------------------------------- + // 4. Array data — list of resources + // ------------------------------------------------------------------------- + it("SUCCESS: deserializes array data correctly", async () => { + const envelope = { + data: [ + { + id: "b1", + type: "builds", + attributes: { state: "finished", branch: "main" }, + }, + { + id: "b2", + type: "builds", + attributes: { state: "processing", branch: "dev" }, + }, + ], + meta: { "total-count": 2 }, + }; + + fetchSpy.mockResolvedValueOnce(mockFetchResponse(envelope)); + + const result = await client.get("/builds"); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + expect(result[0].id).toBe("b1"); + expect(result[1].branch).toBe("dev"); + }); + + // ------------------------------------------------------------------------- + // 5. No `included` array — relationships resolve to raw refs + // ------------------------------------------------------------------------- + it("EDGE: response with no included — relationships resolve to raw { id, type }", async () => { + const envelope = { + data: { + id: "b1", + type: "builds", + attributes: { state: "finished" }, + relationships: { + project: { data: { id: "p99", type: "projects" } }, + browsers: { + data: [ + { id: "br1", type: "browsers" }, + { id: "br2", type: "browsers" }, + ], + }, + }, + }, + // no `included` + }; + + fetchSpy.mockResolvedValueOnce(mockFetchResponse(envelope)); + + const result = await client.get("/builds/b1"); + + // Not in included index — should return the raw ref + expect(result.project).toEqual({ id: "p99", type: "projects" }); + expect(result.browsers).toEqual([ + { id: "br1", type: "browsers" }, + { id: "br2", type: "browsers" }, + ]); + }); + + // ------------------------------------------------------------------------- + // 6. Nested objects in attributes are preserved + // ------------------------------------------------------------------------- + it("EDGE: nested objects in attributes (ai-details) are preserved", async () => { + const aiDetails = { + "ai-summary": "No visual changes detected", + confidence: 0.95, + regions: [{ x: 0, y: 0, width: 100, height: 100 }], + }; + + const envelope = { + data: { + id: "b1", + type: "builds", + attributes: { + state: "finished", + "ai-details": aiDetails, + }, + }, + }; + + fetchSpy.mockResolvedValueOnce(mockFetchResponse(envelope)); + + const result = await client.get("/builds/b1"); + + // ai-details should be preserved as a nested object (keys camelCased) + expect(result.aiDetails).toBeDefined(); + expect(result.aiDetails.aiSummary).toBe( + "No visual changes detected", + ); + expect(result.aiDetails.confidence).toBe(0.95); + expect(result.aiDetails.regions).toHaveLength(1); + }); + + // ------------------------------------------------------------------------- + // 7. 401 response — throws PercyApiError + // ------------------------------------------------------------------------- + it("FAIL: 401 response throws PercyApiError with enriched message", async () => { + const errorBody = { + errors: [{ title: "Unauthorized", detail: "Token is invalid" }], + }; + + fetchSpy.mockResolvedValueOnce(mockFetchResponse(errorBody, 401)); + const promise = client.get("/builds/123"); + await expect(promise).rejects.toThrow(PercyApiError); + + fetchSpy.mockResolvedValueOnce(mockFetchResponse(errorBody, 401)); + await expect(client.get("/builds/123")).rejects.toMatchObject({ + statusCode: 401, + }); + }); + + // ------------------------------------------------------------------------- + // 8. 429 response — retries with backoff, eventually throws + // ------------------------------------------------------------------------- + it("FAIL: 429 response retries then throws after max retries", async () => { + const errorBody = { + errors: [{ title: "Rate limited" }], + }; + + // Return 429 for all attempts (initial + 3 retries = 4 calls) + fetchSpy + .mockResolvedValueOnce( + mockFetchResponse(errorBody, 429, { "Retry-After": "0.01" }), + ) + .mockResolvedValueOnce( + mockFetchResponse(errorBody, 429, { "Retry-After": "0.01" }), + ) + .mockResolvedValueOnce( + mockFetchResponse(errorBody, 429, { "Retry-After": "0.01" }), + ) + .mockResolvedValueOnce( + mockFetchResponse(errorBody, 429, { "Retry-After": "0.01" }), + ); + + const promise = client.get("/builds"); + await expect(promise).rejects.toThrow(PercyApiError); + + // Re-mock for the second assertion call + fetchSpy + .mockResolvedValueOnce(mockFetchResponse(errorBody, 429, { "Retry-After": "0.01" })) + .mockResolvedValueOnce(mockFetchResponse(errorBody, 429, { "Retry-After": "0.01" })) + .mockResolvedValueOnce(mockFetchResponse(errorBody, 429, { "Retry-After": "0.01" })) + .mockResolvedValueOnce(mockFetchResponse(errorBody, 429, { "Retry-After": "0.01" })); + + await expect(client.get("/builds")).rejects.toMatchObject({ + statusCode: 429, + }); + }); + + // ------------------------------------------------------------------------- + // 9. 204 No Content — returns undefined + // ------------------------------------------------------------------------- + it("EDGE: 204 No Content returns undefined", async () => { + fetchSpy.mockResolvedValueOnce(mockFetch204()); + + const result = await client.del("/builds/123"); + + expect(result).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Standalone deserialize tests +// --------------------------------------------------------------------------- + +describe("deserialize", () => { + it("returns null for data: null", () => { + const result = deserialize({ data: null }); + expect(result.data).toBeNull(); + }); + + it("returns empty array for data: []", () => { + const result = deserialize({ data: [] }); + expect(result.data).toEqual([]); + }); + + it("handles null relationship data", () => { + const envelope = { + data: { + id: "1", + type: "builds", + attributes: { state: "pending" }, + relationships: { + project: { data: null }, + }, + }, + }; + + const result = deserialize(envelope as any); + const record = result.data as Record; + expect(record.project).toBeNull(); + }); +}); diff --git a/tests/lib/percy-api/formatter.test.ts b/tests/lib/percy-api/formatter.test.ts new file mode 100644 index 0000000..9605f2f --- /dev/null +++ b/tests/lib/percy-api/formatter.test.ts @@ -0,0 +1,390 @@ +import { describe, it, expect } from "vitest"; +import { + formatBuild, + formatSnapshot, + formatComparison, + formatSuggestions, + formatNetworkLogs, + formatBuildStatus, + formatAiWarning, +} from "../../../src/lib/percy-api/formatter.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const finishedBuildWithAi = { + id: "build-1", + buildNumber: 142, + state: "finished", + branch: "main", + commit: { sha: "abc1234" }, + reviewState: "unreviewed", + totalSnapshots: 42, + totalComparisons: 42, + totalComparisonsDiff: 5, + totalSnapshotsUnreviewed: 3, + failureReason: null, + createdAt: "2024-01-15T10:00:00Z", + finishedAt: "2024-01-15T10:02:34Z", + errorBuckets: null, + aiDetails: { + aiEnabled: true, + totalComparisonsWithAi: 42, + totalPotentialBugs: 2, + totalDiffsReducedCapped: 15, + totalAiVisualDiffs: 8, + allAiJobsCompleted: true, + summaryStatus: "completed", + }, +}; + +const noChangesBuild = { + id: "build-2", + buildNumber: 143, + state: "finished", + branch: "main", + totalSnapshots: 20, + totalComparisons: 20, + totalComparisonsDiff: 0, + totalSnapshotsNew: 0, + totalSnapshotsRemoved: 0, + totalSnapshotsUnchanged: 20, + createdAt: "2024-01-16T10:00:00Z", + finishedAt: "2024-01-16T10:01:00Z", + failureReason: null, + errorBuckets: null, + aiDetails: null, +}; + +const processingBuild = { + id: "build-3", + buildNumber: 144, + state: "processing", + branch: "feature/x", + totalSnapshots: 30, + totalComparisons: 100, + totalComparisonsFinished: 47, + totalComparisonsDiff: null, + failureReason: null, + errorBuckets: null, + aiDetails: null, + createdAt: "2024-01-17T10:00:00Z", + finishedAt: null, +}; + +const failedBuild = { + id: "build-4", + buildNumber: 145, + state: "failed", + branch: "develop", + totalSnapshots: null, + totalComparisons: null, + totalComparisonsDiff: null, + failureReason: "render_timeout", + errorBuckets: [ + { name: "Asset Loading Failed", count: 3 }, + { name: "Render Timeout", count: 1 }, + ], + createdAt: "2024-01-18T10:00:00Z", + finishedAt: "2024-01-18T10:00:30Z", + aiDetails: null, +}; + +// --------------------------------------------------------------------------- +// formatBuild +// --------------------------------------------------------------------------- + +describe("formatBuild", () => { + it("SUCCESS: finished build with AI renders all sections", () => { + const result = formatBuild(finishedBuildWithAi); + + expect(result).toContain("## Build #142 — FINISHED"); + expect(result).toContain("**Branch:** main | **SHA:** abc1234"); + expect(result).toContain("**Review:** unreviewed"); + expect(result).toContain("42 snapshots"); + expect(result).toContain("42 comparisons"); + expect(result).toContain("5 with diffs"); + expect(result).toContain("**Duration:** 2m 34s"); + // AI section + expect(result).toContain("### AI Analysis"); + expect(result).toContain("Comparisons analyzed by AI: 42"); + expect(result).toContain("Potential bugs: 2"); + expect(result).toContain("Diffs reduced by AI: 15"); + }); + + it("SUCCESS: build with no changes shows no visual changes message", () => { + const result = formatBuild(noChangesBuild); + + expect(result).toContain("## Build #143 — FINISHED"); + expect(result).toContain("No visual changes detected"); + expect(result).not.toContain("### AI Analysis"); + }); + + it("EDGE: processing build shows percentage", () => { + const result = formatBuild(processingBuild); + + expect(result).toContain("## Build #144 — PROCESSING (47% complete)"); + expect(result).toContain("**Branch:** feature/x"); + }); + + it("EDGE: failed build includes failure_reason and error_buckets", () => { + const result = formatBuild(failedBuild); + + expect(result).toContain("## Build #145 — FAILED"); + expect(result).toContain("**Failure Reason:** render_timeout"); + expect(result).toContain("### Error Buckets"); + expect(result).toContain("**Asset Loading Failed** — 3 snapshot(s)"); + expect(result).toContain("**Render Timeout** — 1 snapshot(s)"); + // Should NOT show AI section for failed builds + expect(result).not.toContain("### AI Analysis"); + }); + + it("EDGE: null build returns fallback message", () => { + expect(formatBuild(null)).toContain("No build data available"); + expect(formatBuild(undefined)).toContain("No build data available"); + }); +}); + +// --------------------------------------------------------------------------- +// formatSnapshot +// --------------------------------------------------------------------------- + +describe("formatSnapshot", () => { + it("SUCCESS: snapshot with comparisons renders table", () => { + const snapshot = { id: "s1", name: "Homepage", reviewState: "unreviewed" }; + const comparisons = [ + { + id: "c1", + browser: { name: "Chrome" }, + width: 1280, + diffRatio: 0.083, + aiDiffRatio: 0.021, + aiProcessingState: "completed", + }, + ]; + + const result = formatSnapshot(snapshot, comparisons); + + expect(result).toContain("### Homepage"); + expect(result).toContain("**Review:** unreviewed"); + expect(result).toContain("| Chrome | 1280px | 8.3% | 2.1% | completed |"); + }); + + it("EDGE: snapshot with no comparisons omits table", () => { + const snapshot = { id: "s2", name: "About Page", reviewState: "approved" }; + const result = formatSnapshot(snapshot); + + expect(result).toContain("### About Page"); + expect(result).not.toContain("|"); + }); +}); + +// --------------------------------------------------------------------------- +// formatComparison +// --------------------------------------------------------------------------- + +describe("formatComparison", () => { + const comparisonWithAi = { + id: "c1", + browser: { name: "Chrome" }, + width: 1280, + diffRatio: 0.083, + aiDiffRatio: 0.021, + aiProcessingState: "completed", + baseScreenshot: { url: "https://percy.io/base.png" }, + headScreenshot: { url: "https://percy.io/head.png" }, + diffImage: { url: "https://percy.io/diff.png" }, + appliedRegions: [ + { + label: "Button text truncated", + type: "modified", + description: "Container width reduced causing text overflow", + }, + { + label: "New CTA button", + type: "added", + description: "New element in hero section", + }, + ], + }; + + it("SUCCESS: comparison with AI and regions", () => { + const result = formatComparison(comparisonWithAi, { + includeRegions: true, + }); + + expect(result).toContain("**Chrome 1280px** — 8.3% diff (AI: 2.1%)"); + expect(result).toContain("- Base: https://percy.io/base.png"); + expect(result).toContain("- Head: https://percy.io/head.png"); + expect(result).toContain("- Diff: https://percy.io/diff.png"); + expect(result).toContain("AI Regions (2):"); + expect(result).toContain( + "1. **Button text truncated** (modified) — Container width reduced causing text overflow", + ); + expect(result).toContain( + "2. **New CTA button** (added) — New element in hero section", + ); + }); + + it("EDGE: comparison with no AI data shows diff ratio only", () => { + const comparison = { + id: "c2", + browser: { name: "Firefox" }, + width: 768, + diffRatio: 0.05, + aiDiffRatio: null, + aiProcessingState: null, + appliedRegions: null, + }; + + const result = formatComparison(comparison); + + expect(result).toContain("**Firefox 768px** — 5.0% diff"); + expect(result).not.toContain("AI:"); + expect(result).not.toContain("AI Regions"); + }); + + it("EDGE: regions not shown when includeRegions is false", () => { + const result = formatComparison(comparisonWithAi); + + expect(result).not.toContain("AI Regions"); + }); +}); + +// --------------------------------------------------------------------------- +// formatSuggestions +// --------------------------------------------------------------------------- + +describe("formatSuggestions", () => { + it("SUCCESS: renders numbered suggestions with fix steps", () => { + const suggestions = [ + { + title: "Asset Loading Failed", + affectedSnapshots: 3, + reason: "4 font files from cdn.example.com returned HTTP 503", + fixSteps: [ + "Verify cdn.example.com is accessible", + "Add to percy config allowedHostnames", + ], + docsUrl: "https://docs.percy.io/hosting", + }, + ]; + + const result = formatSuggestions(suggestions); + + expect(result).toContain("## Build Failure Suggestions"); + expect(result).toContain( + "### 1. Asset Loading Failed (3 snapshots affected)", + ); + expect(result).toContain("**Reason:** 4 font files"); + expect(result).toContain("1. Verify cdn.example.com"); + expect(result).toContain("2. Add to percy config"); + expect(result).toContain("**Docs:** https://docs.percy.io/hosting"); + }); + + it("EDGE: empty suggestions returns fallback", () => { + expect(formatSuggestions([])).toContain("No failure suggestions"); + expect(formatSuggestions(null as any)).toContain("No failure suggestions"); + }); +}); + +// --------------------------------------------------------------------------- +// formatNetworkLogs +// --------------------------------------------------------------------------- + +describe("formatNetworkLogs", () => { + it("SUCCESS: renders network logs table", () => { + const logs = [ + { + url: "cdn.example.com/font.woff2", + baseStatus: "200 OK", + headStatus: "503 Error", + resourceType: "font", + issue: "Server error", + }, + ]; + + const result = formatNetworkLogs(logs); + + expect(result).toContain("## Network Logs"); + expect(result).toContain( + "| cdn.example.com/font.woff2 | 200 OK | 503 Error | font | Server error |", + ); + }); + + it("EDGE: empty logs returns fallback", () => { + expect(formatNetworkLogs([])).toContain("No network logs"); + }); +}); + +// --------------------------------------------------------------------------- +// formatBuildStatus +// --------------------------------------------------------------------------- + +describe("formatBuildStatus", () => { + it("SUCCESS: one-line status with AI stats", () => { + const build = { + buildNumber: 142, + state: "finished", + totalComparisonsDiff: 5, + aiDetails: { potentialBugs: 2, noiseFiltered: 73 }, + }; + + const result = formatBuildStatus(build); + + expect(result).toBe( + "Build #142: FINISHED — 5 changed, 2 bugs, 73% noise filtered", + ); + }); + + it("EDGE: null build returns fallback", () => { + expect(formatBuildStatus(null)).toBe("Build: N/A"); + }); +}); + +// --------------------------------------------------------------------------- +// formatAiWarning +// --------------------------------------------------------------------------- + +describe("formatAiWarning", () => { + it("EDGE: AI processing on 3/10 comparisons shows warning", () => { + const comparisons = [ + ...Array.from({ length: 7 }, (_, i) => ({ + id: `c${i}`, + aiProcessingState: "completed", + })), + ...Array.from({ length: 3 }, (_, i) => ({ + id: `p${i}`, + aiProcessingState: "processing", + })), + ]; + + const result = formatAiWarning(comparisons); + + expect(result).toContain("AI analysis in progress for 3 of 10"); + expect(result).toContain("Re-run for complete analysis"); + }); + + it("SUCCESS: all completed returns empty string", () => { + const comparisons = [ + { id: "c1", aiProcessingState: "completed" }, + { id: "c2", aiProcessingState: "completed" }, + ]; + + expect(formatAiWarning(comparisons)).toBe(""); + }); + + it("EDGE: AI not enabled returns empty string", () => { + const comparisons = [ + { id: "c1", aiProcessingState: "not_enabled" }, + { id: "c2", aiProcessingState: "not_enabled" }, + ]; + + expect(formatAiWarning(comparisons)).toBe(""); + }); + + it("EDGE: empty array returns empty string", () => { + expect(formatAiWarning([])).toBe(""); + }); +}); diff --git a/tests/tools/percy-mcp/approve-build.test.ts b/tests/tools/percy-mcp/approve-build.test.ts new file mode 100644 index 0000000..939163b --- /dev/null +++ b/tests/tools/percy-mcp/approve-build.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { percyApproveBuild } from "../../../src/tools/percy-mcp/core/approve-build.js"; + +// --------------------------------------------------------------------------- +// Mock auth module — avoid real token resolution +// --------------------------------------------------------------------------- +vi.mock("../../../src/lib/percy-api/auth", () => ({ + getPercyHeaders: vi.fn().mockResolvedValue({ + Authorization: "Token token=fake-token", + "Content-Type": "application/json", + "User-Agent": "browserstack-mcp-server", + }), + getPercyApiBaseUrl: vi.fn().mockReturnValue("https://percy.io/api/v1"), +})); + +const mockConfig = { + "browserstack-username": "fake-user", + "browserstack-access-key": "fake-key", +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mockFetchResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: { + get: () => null, + }, + json: vi.fn().mockResolvedValue(body), + } as unknown as Response; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("percyApproveBuild", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ------------------------------------------------------------------------- + // 1. SUCCESS: approve build + // ------------------------------------------------------------------------- + it("approves a build and returns confirmation", async () => { + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ + data: { + id: "review-1", + type: "reviews", + attributes: { + "review-state": "approved", + }, + }, + }), + ); + + const result = await percyApproveBuild( + { build_id: "12345", action: "approve" }, + mockConfig, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]).toEqual({ + type: "text", + text: "Build #12345 approve successful. Review state: approved", + }); + + // Verify the POST was made to /reviews + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url, options] = fetchSpy.mock.calls[0]; + expect(url).toContain("/reviews"); + expect(options.method).toBe("POST"); + + const body = JSON.parse(options.body); + expect(body.data.type).toBe("reviews"); + expect(body.data.attributes.action).toBe("approve"); + expect(body.data.relationships.build.data).toEqual({ + type: "builds", + id: "12345", + }); + // No snapshots relationship for build-level actions + expect(body.data.relationships.snapshots).toBeUndefined(); + }); + + // ------------------------------------------------------------------------- + // 2. SUCCESS: request_changes with snapshot_ids + // ------------------------------------------------------------------------- + it("request_changes with snapshot_ids returns per-snapshot confirmation", async () => { + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ + data: { + id: "review-2", + type: "reviews", + attributes: { + "review-state": "changes_requested", + }, + }, + }), + ); + + const result = await percyApproveBuild( + { + build_id: "12345", + action: "request_changes", + snapshot_ids: "snap-1, snap-2, snap-3", + }, + mockConfig, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]).toEqual({ + type: "text", + text: "Build #12345 request_changes successful. Review state: changes_requested", + }); + + // Verify snapshot_ids were included in the body + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.data.relationships.snapshots.data).toEqual([ + { type: "snapshots", id: "snap-1" }, + { type: "snapshots", id: "snap-2" }, + { type: "snapshots", id: "snap-3" }, + ]); + }); + + // ------------------------------------------------------------------------- + // 3. FAIL: request_changes without snapshot_ids + // ------------------------------------------------------------------------- + it("returns error when request_changes is called without snapshot_ids", async () => { + const result = await percyApproveBuild( + { build_id: "12345", action: "request_changes" }, + mockConfig, + ); + + expect(result.isError).toBe(true); + expect(result.content[0]).toEqual({ + type: "text", + text: "request_changes requires snapshot_ids. This action works at snapshot level only.", + }); + + // No API call should be made + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + // 4. FAIL: invalid action + // ------------------------------------------------------------------------- + it("returns error for invalid action with valid options listed", async () => { + const result = await percyApproveBuild( + { build_id: "12345", action: "merge" }, + mockConfig, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid action "merge"'); + expect(result.content[0].text).toContain("approve"); + expect(result.content[0].text).toContain("request_changes"); + expect(result.content[0].text).toContain("unapprove"); + expect(result.content[0].text).toContain("reject"); + + // No API call should be made + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + // 5. EDGE: build already approved — returns current state from API + // ------------------------------------------------------------------------- + it("returns current state when build is already approved", async () => { + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ + data: { + id: "review-3", + type: "reviews", + attributes: { + "review-state": "approved", + }, + }, + }), + ); + + const result = await percyApproveBuild( + { build_id: "99999", action: "approve" }, + mockConfig, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]).toEqual({ + type: "text", + text: "Build #99999 approve successful. Review state: approved", + }); + }); + + // ------------------------------------------------------------------------- + // 6. FAIL: API error is caught and returned as isError + // ------------------------------------------------------------------------- + it("returns error result when the API call fails", async () => { + fetchSpy.mockResolvedValueOnce( + mockFetchResponse( + { errors: [{ detail: "Build not found" }] }, + 404, + ), + ); + + const result = await percyApproveBuild( + { build_id: "missing", action: "approve" }, + mockConfig, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Failed to approve build #missing"); + }); + + // ------------------------------------------------------------------------- + // 7. SUCCESS: reason is passed through in attributes + // ------------------------------------------------------------------------- + it("includes reason in request attributes when provided", async () => { + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ + data: { + id: "review-4", + type: "reviews", + attributes: { + "review-state": "rejected", + }, + }, + }), + ); + + await percyApproveBuild( + { + build_id: "12345", + action: "reject", + reason: "Visual regression detected", + }, + mockConfig, + ); + + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.data.attributes.reason).toBe("Visual regression detected"); + }); +});