diff --git a/src/lib/inmemory-store.ts b/src/lib/inmemory-store.ts index 76515ebc..0c09d201 100644 --- a/src/lib/inmemory-store.ts +++ b/src/lib/inmemory-store.ts @@ -1 +1,2 @@ export const signedUrlMap = new Map(); +export const testFilePathsMap = new Map(); diff --git a/src/server-factory.ts b/src/server-factory.ts index 82f93730..830dc46b 100644 --- a/src/server-factory.ts +++ b/src/server-factory.ts @@ -7,6 +7,7 @@ const require = createRequire(import.meta.url); const packageJson = require("../package.json"); import logger from "./logger.js"; import addSDKTools from "./tools/bstack-sdk.js"; +import addPercyTools from "./tools/percy-sdk.js"; import addBrowserLiveTools from "./tools/live.js"; import addAccessibilityTools from "./tools/accessibility.js"; import addTestManagementTools from "./tools/testmanagement.js"; @@ -48,6 +49,7 @@ export class BrowserStackMcpServer { const toolAdders = [ addAccessibilityTools, addSDKTools, + addPercyTools, addAppLiveTools, addBrowserLiveTools, addTestManagementTools, diff --git a/src/tools/add-percy-snapshots.ts b/src/tools/add-percy-snapshots.ts new file mode 100644 index 00000000..237ae55c --- /dev/null +++ b/src/tools/add-percy-snapshots.ts @@ -0,0 +1,32 @@ +import { testFilePathsMap } from "../lib/inmemory-store.js"; +import { updateFileAndStep } from "./percy-snapshot-utils/utils.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { percyWebSetupInstructions } from "../tools/sdk-utils/percy-web/handler.js"; + +export async function updateTestsWithPercyCommands(args: { + uuid: string; + index: number; +}): Promise { + const { uuid, index } = args; + const filePaths = testFilePathsMap.get(uuid); + + if (!filePaths) { + throw new Error(`No test files found in memory for UUID: ${uuid}`); + } + + if (index < 0 || index >= filePaths.length) { + throw new Error( + `Invalid index: ${index}. There are ${filePaths.length} files for UUID: ${uuid}`, + ); + } + const result = await updateFileAndStep( + filePaths[index], + index, + filePaths.length, + percyWebSetupInstructions, + ); + + return { + content: result, + }; +} diff --git a/src/tools/bstack-sdk.ts b/src/tools/bstack-sdk.ts index f2e597e5..84a84329 100644 --- a/src/tools/bstack-sdk.ts +++ b/src/tools/bstack-sdk.ts @@ -1,169 +1,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; -import { trackMCP } from "../lib/instrumentation.js"; -import { getSDKPrefixCommand } from "./sdk-utils/commands.js"; - -import { - SDKSupportedBrowserAutomationFramework, - SDKSupportedLanguage, - SDKSupportedTestingFramework, - SDKSupportedLanguageEnum, - SDKSupportedBrowserAutomationFrameworkEnum, - SDKSupportedTestingFrameworkEnum, -} from "./sdk-utils/types.js"; - -import { - generateBrowserStackYMLInstructions, - getInstructionsForProjectConfiguration, - formatInstructionsWithNumbers, -} from "./sdk-utils/instructions.js"; - -import { - formatPercyInstructions, - getPercyInstructions, -} from "./sdk-utils/percy/instructions.js"; -import { getBrowserStackAuth } from "../lib/get-auth.js"; import { BrowserStackConfig } from "../lib/types.js"; +import { RunTestsOnBrowserStackParamsShape } from "./sdk-utils/common/schema.js"; +import { runTestsOnBrowserStackHandler } from "./sdk-utils/handler.js"; +import { RUN_ON_BROWSERSTACK_DESCRIPTION } from "./sdk-utils/common/constants.js"; -/** - * BrowserStack SDK hooks into your test framework to seamlessly run tests on BrowserStack. - * This tool gives instructions to setup a browserstack.yml file in the project root and installs the necessary dependencies. - */ -export async function bootstrapProjectWithSDK({ - detectedBrowserAutomationFramework, - detectedTestingFramework, - detectedLanguage, - desiredPlatforms, - enablePercy, - config, -}: { - detectedBrowserAutomationFramework: SDKSupportedBrowserAutomationFramework; - detectedTestingFramework: SDKSupportedTestingFramework; - detectedLanguage: SDKSupportedLanguage; - desiredPlatforms: string[]; - enablePercy: boolean; - config: BrowserStackConfig; -}): Promise { - // Get credentials from config - const authString = getBrowserStackAuth(config); - const [username, accessKey] = authString.split(":"); - - // Handle frameworks with unique setup instructions that don't use browserstack.yml - if ( - detectedBrowserAutomationFramework === "cypress" || - detectedTestingFramework === "webdriverio" - ) { - let combinedInstructions = getInstructionsForProjectConfiguration( - detectedBrowserAutomationFramework, - detectedTestingFramework, - detectedLanguage, - username, - accessKey, - ); - - if (enablePercy) { - const percyInstructions = getPercyInstructions( - detectedLanguage, - detectedBrowserAutomationFramework, - detectedTestingFramework, - ); - - if (percyInstructions) { - combinedInstructions += - "\n\n" + formatPercyInstructions(percyInstructions); - } else { - throw new Error( - `Percy is currently not supported through MCP for ${detectedLanguage} with ${detectedTestingFramework}. If you want to run the test cases without Percy, disable Percy and run it again.`, - ); - } - } - - // Apply consistent formatting for all configurations - return formatFinalInstructions(combinedInstructions); - } - - // Handle default flow using browserstack.yml - const sdkSetupCommand = getSDKPrefixCommand( - detectedLanguage, - detectedTestingFramework, - username, - accessKey, - ); - - const ymlInstructions = generateBrowserStackYMLInstructions( - desiredPlatforms, - enablePercy, - ); - - const instructionsForProjectConfiguration = - getInstructionsForProjectConfiguration( - detectedBrowserAutomationFramework, - detectedTestingFramework, - detectedLanguage, - username, - accessKey, - ); - - let combinedInstructions = ""; - - // Step 1: Add SDK setup command - if (sdkSetupCommand) { - combinedInstructions += sdkSetupCommand; - } - - // Step 2: Add browserstack.yml setup - if (ymlInstructions) { - combinedInstructions += "\n\n---STEP---\n" + ymlInstructions; - } - - // Step 3: Add language/framework-specific setup - if (instructionsForProjectConfiguration) { - combinedInstructions += "\n\n" + instructionsForProjectConfiguration; - } - - // Step 4: Add Percy setup if applicable - if (enablePercy) { - const percyInstructions = getPercyInstructions( - detectedLanguage, - detectedBrowserAutomationFramework, - detectedTestingFramework, - ); - - if (percyInstructions) { - combinedInstructions += - "\n\n" + formatPercyInstructions(percyInstructions); - } else { - throw new Error( - `Percy is currently not supported through MCP for ${detectedLanguage} with ${detectedTestingFramework}. If you want to run the test cases without Percy, disable Percy and run it again.`, - ); - } - } - - // Apply consistent formatting for all configurations - return formatFinalInstructions(combinedInstructions); -} - -// Helper function to apply consistent formatting to all instruction types -function formatFinalInstructions(combinedInstructions: string): CallToolResult { - const fullInstructions = `⚠️ IMPORTANT: DO NOT SKIP ANY STEP - All the setup steps described in this file MUST be executed regardless of any existing configuration or setup. - This ensures proper BrowserStack SDK setup. - - ${formatInstructionsWithNumbers(combinedInstructions)}`; - - return { - content: [ - { - type: "text", - text: fullInstructions, - isError: false, - }, - ], - }; -} - -export default function addSDKTools( +export function registerRunBrowserStackTestsTool( server: McpServer, config: BrowserStackConfig, ) { @@ -171,84 +12,14 @@ export default function addSDKTools( tools.setupBrowserStackAutomateTests = server.tool( "setupBrowserStackAutomateTests", - "Set up and run automated web-based tests on BrowserStack using the BrowserStack SDK. Use for functional or integration tests on BrowserStack, with optional Percy visual testing for supported frameworks. Example prompts: run this test on browserstack; run this test on browserstack with Percy; set up this project for browserstack with Percy. Integrate BrowserStack SDK into your project", - { - detectedBrowserAutomationFramework: z - .nativeEnum(SDKSupportedBrowserAutomationFrameworkEnum) - .describe( - "The automation framework configured in the project. Example: 'playwright', 'selenium'", - ), - - detectedTestingFramework: z - .nativeEnum(SDKSupportedTestingFrameworkEnum) - .describe( - "The testing framework used in the project. Be precise with framework selection Example: 'webdriverio', 'jest', 'pytest', 'junit4', 'junit5', 'mocha'", - ), - - detectedLanguage: z - .nativeEnum(SDKSupportedLanguageEnum) - .describe( - "The programming language used in the project. Example: 'nodejs', 'python', 'java', 'csharp'", - ), - - desiredPlatforms: z - .array(z.enum(["windows", "macos", "android", "ios"])) - .describe( - "The platforms the user wants to test on. Always ask this to the user, do not try to infer this.", - ), - - enablePercy: z - .boolean() - .optional() - .default(false) - .describe( - "Set to true if the user wants to enable Percy for visual testing. Defaults to false.", - ), - }, - + RUN_ON_BROWSERSTACK_DESCRIPTION, + RunTestsOnBrowserStackParamsShape, async (args) => { - try { - trackMCP( - "runTestsOnBrowserStack", - server.server.getClientVersion()!, - undefined, - config, - ); - - return await bootstrapProjectWithSDK({ - detectedBrowserAutomationFramework: - args.detectedBrowserAutomationFramework as SDKSupportedBrowserAutomationFramework, - - detectedTestingFramework: - args.detectedTestingFramework as SDKSupportedTestingFramework, - - detectedLanguage: args.detectedLanguage as SDKSupportedLanguage, - - desiredPlatforms: args.desiredPlatforms, - enablePercy: args.enablePercy, - config, - }); - } catch (error) { - trackMCP( - "runTestsOnBrowserStack", - server.server.getClientVersion()!, - error, - config, - ); - - return { - content: [ - { - type: "text", - text: `Failed to bootstrap project with BrowserStack SDK. Error: ${error}. Please open an issue on GitHub if the problem persists`, - isError: true, - }, - ], - isError: true, - }; - } + return runTestsOnBrowserStackHandler(args, config); }, ); return tools; } + +export default registerRunBrowserStackTestsTool; diff --git a/src/tools/list-test-files.ts b/src/tools/list-test-files.ts new file mode 100644 index 00000000..7fb1d897 --- /dev/null +++ b/src/tools/list-test-files.ts @@ -0,0 +1,39 @@ +import { listTestFiles } from "./percy-snapshot-utils/detect-test-files.js"; +import { testFilePathsMap } from "../lib/inmemory-store.js"; +import crypto from "crypto"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function addListTestFiles(args: any): Promise { + const { dirs, language, framework } = args; + let testFiles: string[] = []; + + for (const dir of dirs) { + const files = await listTestFiles({ + language, + framework, + baseDir: dir, + }); + testFiles = testFiles.concat(files); + } + + if (testFiles.length === 0) { + throw new Error("No test files found"); + } + + // Generate a UUID and store the test files in memory + const uuid = crypto.randomUUID(); + testFilePathsMap.set(uuid, testFiles); + + return { + content: [ + { + type: "text", + text: `The Test files are stored in memory with id ${uuid} and the total number of tests files found is ${testFiles.length}. You can use this UUID to retrieve the tests file paths later.`, + }, + { + type: "text", + text: `You can now use the tool addPercySnapshotCommands to update the test file with Percy commands for visual testing with the UUID ${uuid}`, + }, + ], + }; +} diff --git a/src/tools/percy-sdk.ts b/src/tools/percy-sdk.ts new file mode 100644 index 00000000..1af0a484 --- /dev/null +++ b/src/tools/percy-sdk.ts @@ -0,0 +1,160 @@ +import { trackMCP } from "../index.js"; +import { BrowserStackConfig } from "../lib/types.js"; +import { fetchPercyChanges } from "./percy-change.js"; +import { addListTestFiles } from "./list-test-files.js"; +import { runPercyScan } from "./run-percy-scan.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SetUpPercyParamsShape } from "./sdk-utils/common/schema.js"; +import { updateTestsWithPercyCommands } from "./add-percy-snapshots.js"; +import { approveOrDeclinePercyBuild } from "./review-agent-utils/percy-approve-reject.js"; +import { setUpPercyHandler } from "./sdk-utils/handler.js"; + +import { + SETUP_PERCY_DESCRIPTION, + LIST_TEST_FILES_DESCRIPTION, + PERCY_SNAPSHOT_COMMANDS_DESCRIPTION, +} from "./sdk-utils/common/constants.js"; + +import { + ListTestFilesParamsShape, + UpdateTestFileWithInstructionsParams, +} from "./percy-snapshot-utils/constants.js"; + +import { + RunPercyScanParamsShape, + FetchPercyChangesParamsShape, + ManagePercyBuildApprovalParamsShape, +} from "./sdk-utils/common/schema.js"; + +export function registerPercyTools( + server: McpServer, + config: BrowserStackConfig, +) { + const tools: Record = {}; + + // Register setupPercyVisualTesting + tools.setupPercyVisualTesting = server.tool( + "setupPercyVisualTesting", + SETUP_PERCY_DESCRIPTION, + SetUpPercyParamsShape, + async (args) => { + try { + trackMCP( + "setupPercyVisualTesting", + server.server.getClientVersion()!, + config, + ); + return setUpPercyHandler(args, config); + } catch (error) { + trackMCP( + "setupPercyVisualTesting", + server.server.getClientVersion()!, + error, + config, + ); + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + ); + + // Register addPercySnapshotCommands + tools.addPercySnapshotCommands = server.tool( + "addPercySnapshotCommands", + PERCY_SNAPSHOT_COMMANDS_DESCRIPTION, + UpdateTestFileWithInstructionsParams, + async (args) => { + try { + trackMCP( + "addPercySnapshotCommands", + server.server.getClientVersion()!, + config, + ); + return await updateTestsWithPercyCommands(args); + } catch (error) { + trackMCP( + "addPercySnapshotCommands", + server.server.getClientVersion()!, + error, + config, + ); + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + ); + + // Register listTestFiles + tools.listTestFiles = server.tool( + "listTestFiles", + LIST_TEST_FILES_DESCRIPTION, + ListTestFilesParamsShape, + async (args) => { + try { + trackMCP("listTestFiles", server.server.getClientVersion()!, config); + return addListTestFiles(args); + } catch (error) { + trackMCP( + "listTestFiles", + server.server.getClientVersion()!, + error, + config, + ); + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + ); + + tools.runPercyScan = server.tool( + "runPercyScan", + "Run a Percy visual test scan. Example prompts : Run this Percy build/scan. Never run percy scan/build without this tool", + RunPercyScanParamsShape, + async (args) => { + return runPercyScan(args, config); + }, + ); + + tools.fetchPercyChanges = server.tool( + "fetchPercyChanges", + "Retrieves and summarizes all visual changes detected by Percy between the latest and previous builds, helping quickly review what has changed in your project.", + FetchPercyChangesParamsShape, + async (args) => { + return await fetchPercyChanges(args, config); + }, + ); + + tools.managePercyBuildApproval = server.tool( + "managePercyBuildApproval", + "Approve or reject a Percy build", + ManagePercyBuildApprovalParamsShape, + async (args) => { + return await approveOrDeclinePercyBuild(args, config); + }, + ); + + return tools; +} + +export default registerPercyTools; diff --git a/src/tools/percy-snapshot-utils/constants.ts b/src/tools/percy-snapshot-utils/constants.ts new file mode 100644 index 00000000..ef77a419 --- /dev/null +++ b/src/tools/percy-snapshot-utils/constants.ts @@ -0,0 +1,514 @@ +import { z } from "zod"; +import { + SDKSupportedLanguages, + SDKSupportedTestingFrameworks, +} from "../sdk-utils/common/types.js"; +import { SDKSupportedLanguage } from "../sdk-utils/common/types.js"; +import { DetectionConfig } from "./types.js"; + +export const UpdateTestFileWithInstructionsParams = { + uuid: z + .string() + .describe("UUID referencing the in-memory array of test file paths"), + index: z.number().describe("Index of the test file to update"), +}; + +export const ListTestFilesParamsShape = { + dirs: z + .array(z.string()) + .describe("Array of directory paths to search for test files"), + language: z + .enum(SDKSupportedLanguages as [string, ...string[]]) + .describe("Programming language"), + framework: z + .enum(SDKSupportedTestingFrameworks as [string, ...string[]]) + .describe("Testing framework (optional)"), +}; + +export const TEST_FILE_DETECTION: Record< + SDKSupportedLanguage, + DetectionConfig +> = { + java: { + extensions: [".java"], + namePatterns: [ + /Test\.java$/, + /Tests\.java$/, + /Steps\.java$/, + /.*UI.*Test\.java$/, + /.*Web.*Test\.java$/, + /.*E2E.*Test\.java$/, + /.*Integration.*Test\.java$/, + /.*Functional.*Test\.java$/, + ], + contentRegex: [ + /@Test\b/, + /@RunWith\b/, + /@CucumberOptions\b/, + /import\s+org\.junit/, + /import\s+org\.testng/, + /import\s+io\.cucumber/, + /import\s+org\.jbehave/, + ], + uiDriverRegex: [ + /import\s+org\.openqa\.selenium/, + /import\s+org\.seleniumhq\.selenium/, + /import\s+io\.appium\.java_client/, + /import\s+com\.microsoft\.playwright/, + /import\s+com\.codeborne\.selenide/, + /import\s+net\.serenitybdd/, + /import\s+cucumber\.api\.java\.en/, + /new\s+\w*Driver\s*\(/, + /\.findElement\s*\(/, + /\.get\s*\(['"]https?:/, + /\.click\s*\(/, + /\.navigate\(\)/, + /WebDriver/, + /RemoteWebDriver/, + /ChromeDriver/, + /FirefoxDriver/, + ], + uiIndicatorRegex: [ + // UI interactions without explicit driver imports + /\.sendKeys\s*\(/, + /\.getText\s*\(/, + /\.isDisplayed\s*\(/, + /By\.id\s*\(/, + /By\.className\s*\(/, + /By\.xpath\s*\(/, + /waitForElement/, + /waitForVisible/, + /assertTitle/, + /screenshot/, + /captureScreenshot/, + // Page Object patterns + /PageObject/, + /BasePage/, + /WebPage/, + // UI test annotations and patterns + /@UITest/, + /@WebTest/, + /@E2ETest/, + // Common UI assertions + /assertUrl/, + /verifyText/, + /checkElement/, + // Browser/window operations + /maximizeWindow/, + /setWindowSize/, + /switchTo/, + // Cucumber UI steps + /Given.*I\s+(open|visit|navigate)/, + /When.*I\s+(click|type|select)/, + /Then.*I\s+(see|verify|check)/, + /And.*I\s+(wait|scroll)/, + ], + backendRegex: [ + /import\s+org\.springframework\.test/, + /import\s+javax\.persistence/, + /@DataJpaTest/, + /@WebMvcTest/, + /@MockBean/, + /EntityManager/, + /JdbcTemplate/, + /TestRestTemplate/, + /@Repository/, + /@Service/, + /@Entity/, + ], + excludeRegex: [ + /UnitTest/, + /MockTest/, + /StubTest/, + /DatabaseTest/, + /import\s+org\.mockito/, + /@Mock\b/, + /@Spy\b/, + ], + }, + csharp: { + extensions: [".cs"], + namePatterns: [ + /Test\.cs$/, + /Tests\.cs$/, + /Steps\.cs$/, + /.*UI.*Test\.cs$/, + /.*Web.*Test\.cs$/, + /.*E2E.*Test\.cs$/, + ], + contentRegex: [ + /\[Test\]/, + /\[TestCase\]/, + /\[Fact\]/, + /\[Theory\]/, + /\[Binding\]/, + /using\s+NUnit\.Framework/, + /using\s+Xunit/, + /using\s+TechTalk\.SpecFlow/, + ], + uiDriverRegex: [ + /using\s+OpenQA\.Selenium/, + /using\s+Appium/, + /using\s+Microsoft\.Playwright/, + /using\s+Selenide/, + /using\s+Atata/, + /new\s+\w*Driver\s*\(/, + /\.FindElement\s*\(/, + /\.Navigate\(\)/, + /IWebDriver/, + /WebDriver/, + ], + uiIndicatorRegex: [ + /\.SendKeys\s*\(/, + /\.Click\s*\(/, + /\.Text/, + /\.Displayed/, + /By\.Id/, + /By\.ClassName/, + /By\.XPath/, + /WaitForElement/, + /TakeScreenshot/, + /PageObject/, + /\[UITest\]/, + /\[WebTest\]/, + /\[E2ETest\]/, + /NavigateTo/, + /VerifyText/, + /AssertUrl/, + ], + backendRegex: [ + /using\s+Microsoft\.EntityFrameworkCore/, + /using\s+System\.Data/, + /DbContext/, + /Repository/, + /Controller/, + /\[ApiTest\]/, + /\[DatabaseTest\]/, + ], + excludeRegex: [/\[UnitTest\]/, /Mock/, /Stub/, /using\s+Moq/], + }, + nodejs: { + extensions: [".js", ".ts"], + namePatterns: [ + /.test.js$/, + /.spec.js$/, + /.test.ts$/, + /.spec.ts$/, + /.*ui.*.test.(js|ts)$/, + /.*web.*.test.(js|ts)$/, + /.*e2e.*.(js|ts)$/, + /.*integration.*.test.(js|ts)$/, + ], + contentRegex: [ + /\bdescribe\s*\(/, + /\bit\s*\(/, + /\btest\s*\(/, + /require\(['"]mocha['"]\)/, + /require\(['"]jest['"]\)/, + /import.*from\s+['"]jest['"]/, + /from\s+['"]@jest/, + ], + uiDriverRegex: [ + /require\(['"]selenium-webdriver['"]\)/, + /require\(['"]webdriverio['"]\)/, + /require\(['"]puppeteer['"]\)/, + /require\(['"]playwright['"]\)/, + /require\(['"]cypress['"]\)/, + /require\(['"]@wdio\/sync['"]\)/, + /import.*from\s+['"]selenium-webdriver['"]/, + /import.*from\s+['"]webdriverio['"]/, + /import.*from\s+['"]puppeteer['"]/, + /import.*from\s+['"]playwright['"]/, + /import.*from\s+['"]cypress['"]/, + /import.*from\s+['"]@wdio/, + /\.launch\(/, + /\.goto\(/, + /driver\./, + /browser\./, + ], + uiIndicatorRegex: [ + // Browser automation - SPECIFIC CONTEXT + /driver\.click\(/, + /driver\.type\(/, + /driver\.fill\(/, + /browser\.click\(/, + /driver\.waitForSelector\(/, + /browser\.waitForElement\(/, + /driver\.screenshot\(/, + /browser\.screenshot\(/, + /driver\.evaluate\(/, + /driver\.focus\(/, + /driver\.hover\(/, + // Page object patterns - UI specific + /page\.goto/, + /page\.click/, + /page\.fill/, + /page\.screenshot/, + /page\.waitForSelector/, + /page\.locator/, + /page\.getByRole/, + // Cypress specific patterns + /cy\.visit/, + /cy\.get/, + /cy\.click/, + /cy\.type/, + /cy\.should/, + /cy\.wait/, + /cy\.screenshot/, + /cy\.viewport/, + // WebDriverIO specific patterns + /browser\.url/, + /browser\.click/, + /browser\.setValue/, + /\$\(['"][#.]/, + /\$\$\(['"][#.]/, // CSS/XPath selectors + // Playwright specific + /expect.*toBeVisible/, + /expect.*toHaveText/, + /expect.*toBeEnabled/, + /locator\(/, + /getByText\(/, + /getByRole\(/, + /getByTestId\(/, + // DOM queries in test context + /findElement/, + /querySelector.*\)\.click/, + /getElementById.*\)\.click/, + // Test descriptions clearly indicating UI + /describe.*['"`].*UI/, + /describe.*['"`].*Web/, + /describe.*['"`].*E2E/, + /describe.*['"`].*Browser/, + /describe.*['"`].*Selenium/, + /it.*['"`].*(click|type|navigate|visit|see).*element/, + /it.*['"`].*(open|load).*page/, + /it.*['"`].*browser/, + ], + backendRegex: [ + /require\(['"]express['"]\)/, + /require\(['"]fastify['"]\)/, + /require\(['"]supertest['"]\)/, + /request\(app\)/, + /mongoose/, + /sequelize/, + /prisma/, + /knex/, + /app\.get\(/, + /app\.post\(/, + /server\./, + /\.connect\(/, + /\.query\(/, + ], + excludeRegex: [ + /\.unit\./, + /\.mock\./, + /jest\.mock/, + /sinon/, + /describe.*['"`]Unit/, + /describe.*['"`]Mock/, + ], + }, + python: { + extensions: [".py"], + namePatterns: [ + /^test_.*\.py$/, + /_test\.py$/, + /test.*ui.*\.py$/, + /test.*web.*\.py$/, + /test.*e2e.*\.py$/, + /test.*integration.*\.py$/, + ], + contentRegex: [ + /import\s+pytest/, + /@pytest\.mark/, + /def\s+test_/, + /\bpytest\./, + /import\s+unittest/, + /class.*TestCase/, + ], + uiDriverRegex: [ + /import\s+selenium/, + /from\s+selenium/, + /import\s+playwright/, + /from\s+playwright/, + /import\s+appium/, + /from\s+appium/, + /import\s+splinter/, + /from\s+splinter/, + /driver\s*=\s*webdriver\./, + /webdriver\.Chrome/, + /webdriver\.Firefox/, + ], + uiIndicatorRegex: [ + // Selenium patterns without imports + /\.find_element/, + /\.click\(/, + /\.send_keys/, + /\.get\(/, + /\.screenshot/, + /\.execute_script/, + /\.switch_to/, + /By\.ID/, + /By\.CLASS_NAME/, + /By\.XPATH/, + // Playwright patterns + /page\.goto/, + /page\.click/, + /page\.fill/, + /page\.screenshot/, + /expect.*to_be_visible/, + /expect.*to_have_text/, + // Generic UI patterns + /WebDriverWait/, + /expected_conditions/, + /ActionChains/, + /@pytest\.mark\.ui/, + /@pytest\.mark\.web/, + /@pytest\.mark\.e2e/, + // Page object patterns + /BasePage/, + /PageObject/, + /WebPage/, + // BDD step patterns + /def\s+.*_(open|visit|navigate|click|type|see|verify)/, + ], + backendRegex: [ + /import\s+flask/, + /from\s+flask/, + /import\s+fastapi/, + /from\s+fastapi/, + /import\s+django/, + /from\s+django/, + /sqlalchemy/, + /requests\.get/, + /requests\.post/, + /TestClient/, + /@pytest\.mark\.django_db/, + /django\.test/, + ], + excludeRegex: [ + /unittest\.mock/, + /from\s+unittest\.mock/, + /mock\.patch/, + /@pytest\.mark\.unit/, + /@mock\./, + ], + }, + ruby: { + extensions: [".rb"], + namePatterns: [ + /_spec\.rb$/, + /_test\.rb$/, + /.*ui.*_spec\.rb$/, + /.*web.*_spec\.rb$/, + /.*e2e.*_spec\.rb$/, + ], + contentRegex: [ + /\bdescribe\s/, + /\bit\s/, + /require\s+['"]rspec/, + /require\s+['"]minitest/, + /RSpec\.describe/, + ], + uiDriverRegex: [ + /require\s+['"]selenium-webdriver['"]/, + /require\s+['"]capybara['"]/, + /require\s+['"]appium_lib['"]/, + /require\s+['"]watir['"]/, + /Selenium::WebDriver/, + /Capybara\./, + ], + uiIndicatorRegex: [ + // Capybara without explicit require + /visit\s/, + /click_button/, + /click_link/, + /fill_in/, + /find\(['"]/, + /has_content/, + /page\./, + /current_path/, + // Selenium patterns + /\.find_element/, + /\.click/, + /\.send_keys/, + // Generic UI patterns + /screenshot/, + /driver\./, + /browser\./, + /feature\s+['"]/, + /scenario\s+['"]/, + /expect.*to\s+have_content/, + /expect.*to\s+have_selector/, + ], + backendRegex: [ + /require\s+['"]sinatra['"]/, + /require\s+['"]rails['"]/, + /ActiveRecord/, + /DatabaseCleaner/, + /FactoryBot/, + ], + excludeRegex: [ + /double\(/, + /instance_double/, + /class_double/, + /allow\(.*\)\.to\s+receive/, + /mock/i, + ], + }, +}; + +export const EXCLUDED_DIRS = new Set([ + "node_modules", + ".venv", + "venv", + "__pycache__", + "site-packages", + "dist", + "build", + ".git", + ".mypy_cache", + ".pytest_cache", + ".tox", + ".idea", + ".vscode", + "coverage", + ".nyc_output", + "target", + "bin", + "obj", + "packages", + ".nuget", +]); + +export const backendIndicators = [ + /import\s+requests/, + /requests\.(get|post|put|delete|patch)/, + /@pytest\.mark\.(api|backend|integration)/, + /BASE_URL\s*=/, + /\.status_code/, + /\.json\(\)/, + /TestClient/, + /Bearer\s+/, + /Authorization.*Bearer/, +]; + +export const strongUIIndicators = [ + // Browser automation with specific context + /(driver|browser|page)\.(click|type|fill|screenshot|wait)/, + /webdriver\.(Chrome|Firefox|Safari|Edge)/, + /(selenium|playwright|puppeteer|cypress).*import/, + // CSS/XPath selectors + /By\.(ID|CLASS_NAME|XPATH|CSS_SELECTOR)/, + /\$\(['"#[.][^'"]*['"]\)/, // $(".class") or $("#id") + // Page Object Model + /class.*Page.*:/, + /class.*PageObject/, + // UI test markers + /@(ui|web|e2e|browser)_?test/, + /@pytest\.mark\.(ui|web|e2e|browser)/, + // Browser navigation + /\.goto\s*\(['"]https?:/, + /\.visit\s*\(['"]https?:/, + /\.navigate\(\)\.to\(/, +]; diff --git a/src/tools/percy-snapshot-utils/detect-test-files.ts b/src/tools/percy-snapshot-utils/detect-test-files.ts new file mode 100644 index 00000000..876b738f --- /dev/null +++ b/src/tools/percy-snapshot-utils/detect-test-files.ts @@ -0,0 +1,270 @@ +import fs from "fs"; +import path from "path"; +import logger from "../../logger.js"; + +import { + SDKSupportedLanguage, + SDKSupportedTestingFrameworkEnum, +} from "../sdk-utils/common/types.js"; + +import { + EXCLUDED_DIRS, + TEST_FILE_DETECTION, + backendIndicators, + strongUIIndicators, +} from "../percy-snapshot-utils/constants.js"; + +import { DetectionConfig } from "../percy-snapshot-utils/types.js"; + +async function walkDir( + dir: string, + extensions: string[], + depth: number = 6, +): Promise { + const result: string[] = []; + if (depth < 0) return result; + try { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + if (!EXCLUDED_DIRS.has(entry.name) && !entry.name.startsWith(".")) { + result.push(...(await walkDir(fullPath, extensions, depth - 1))); + } + } else if (extensions.some((ext) => entry.name.endsWith(ext))) { + result.push(fullPath); + } + } + } catch { + logger.error(`Failed to read directory: ${dir}`); + } + + return result; +} + +async function fileContainsRegex( + filePath: string, + regexes: RegExp[], +): Promise { + if (!regexes.length) return false; + + try { + const content = await fs.promises.readFile(filePath, "utf8"); + return regexes.some((re) => re.test(content)); + } catch { + logger.warn(`Failed to read file: ${filePath}`); + return false; + } +} + +async function batchRegexCheck( + filePath: string, + regexGroups: RegExp[][], +): Promise { + try { + const content = await fs.promises.readFile(filePath, "utf8"); + return regexGroups.map((regexes) => + regexes.length > 0 ? regexes.some((re) => re.test(content)) : false, + ); + } catch { + logger.warn(`Failed to read file: ${filePath}`); + return regexGroups.map(() => false); + } +} + +async function isLikelyUITest(filePath: string): Promise { + try { + const content = await fs.promises.readFile(filePath, "utf8"); + if (backendIndicators.some((pattern) => pattern.test(content))) { + return false; + } + return strongUIIndicators.some((pattern) => pattern.test(content)); + } catch { + return false; + } +} + +function getFileScore(fileName: string, config: DetectionConfig): number { + let score = 0; + + // Higher score for explicit UI test naming + if (/ui|web|e2e|integration|functional/i.test(fileName)) score += 3; + if (config.namePatterns.some((pattern) => pattern.test(fileName))) score += 2; + + return score; +} + +export interface ListTestFilesOptions { + language: SDKSupportedLanguage; + framework?: SDKSupportedTestingFrameworkEnum; + baseDir: string; + strictMode?: boolean; +} + +export async function listTestFiles( + options: ListTestFilesOptions, +): Promise { + const { language, framework, baseDir, strictMode = false } = options; + const config = TEST_FILE_DETECTION[language]; + + if (!config) { + logger.error(`Unsupported language: ${language}`); + return []; + } + + // Step 1: Collect all files with matching extensions + let files: string[] = []; + try { + files = await walkDir(baseDir, config.extensions, 6); + } catch { + return []; + } + + if (files.length === 0) { + throw new Error("No files found with the specified extensions"); + } + + const candidateFiles: Map = new Map(); + + // Step 2: Fast name-based identification with scoring + for (const file of files) { + const fileName = path.basename(file); + const score = getFileScore(fileName, config); + + if (config.namePatterns.some((pattern) => pattern.test(fileName))) { + candidateFiles.set(file, score); + logger.debug(`File matched by name pattern: ${file} (score: ${score})`); + } + } + + // Step 3: Content-based test detection for remaining files + const remainingFiles = files.filter((file) => !candidateFiles.has(file)); + const contentCheckPromises = remainingFiles.map(async (file) => { + const hasTestContent = await fileContainsRegex(file, config.contentRegex); + if (hasTestContent) { + const fileName = path.basename(file); + const score = getFileScore(fileName, config); + candidateFiles.set(file, score); + logger.debug(`File matched by content regex: ${file} (score: ${score})`); + } + }); + + await Promise.all(contentCheckPromises); + + // Step 4: Handle SpecFlow .feature files for C# + SpecFlow + if (language === "csharp" && framework === "specflow") { + try { + const featureFiles = await walkDir(baseDir, [".feature"], 6); + featureFiles.forEach((file) => candidateFiles.set(file, 2)); + logger.info(`Added ${featureFiles.length} SpecFlow .feature files`); + } catch { + logger.warn( + `Failed to collect SpecFlow .feature files from baseDir: ${baseDir}`, + ); + } + } + + if (candidateFiles.size === 0) { + logger.info("No test files found matching patterns"); + return []; + } + + // Step 6: UI Detection with fallback patterns + const uiFiles: string[] = []; + const filesToCheck = Array.from(candidateFiles.keys()); + + // Batch process UI detection for better performance + const batchSize = 10; + for (let i = 0; i < filesToCheck.length; i += batchSize) { + const batch = filesToCheck.slice(i, i + batchSize); + + const batchPromises = batch.map(async (file) => { + // First, use the new precise UI detection + const isUITest = await isLikelyUITest(file); + + if (isUITest) { + logger.debug(`File included - strong UI indicators: ${file}`); + return file; + } + + // If not clearly UI, run the traditional checks + const [hasExplicitUI, hasUIIndicators, hasBackend, shouldExclude] = + await batchRegexCheck(file, [ + config.uiDriverRegex, + config.uiIndicatorRegex, + config.backendRegex, + config.excludeRegex || [], + ]); + + // Skip if explicitly excluded (mocks, unit tests, etc.) + if (shouldExclude) { + logger.debug(`File excluded by exclude regex: ${file}`); + return null; + } + + // Skip backend tests in any mode + if (hasBackend) { + logger.debug(`File excluded as backend test: ${file}`); + return null; + } + + // Include if has explicit UI drivers + if (hasExplicitUI) { + logger.debug(`File included - explicit UI drivers: ${file}`); + return file; + } + + // Include if has UI indicators (for cases where drivers aren't explicitly imported) + if (hasUIIndicators) { + logger.debug(`File included - UI indicators: ${file}`); + return file; + } + + // In non-strict mode, include high-scoring test files even without explicit UI patterns + if (!strictMode) { + const score = candidateFiles.get(file) || 0; + if (score >= 3) { + // High confidence UI test based on naming + logger.debug( + `File included - high confidence score: ${file} (score: ${score})`, + ); + return file; + } + } + + logger.debug(`File excluded - no UI patterns detected: ${file}`); + return null; + }); + + const batchResults = await Promise.all(batchPromises); + uiFiles.push( + ...batchResults.filter((file): file is string => file !== null), + ); + } + + // Step 7: Sort by score (higher confidence files first) + uiFiles.sort((a, b) => { + const scoreA = candidateFiles.get(a) || 0; + const scoreB = candidateFiles.get(b) || 0; + return scoreB - scoreA; + }); + + logger.info( + `Returning ${uiFiles.length} UI test files from ${candidateFiles.size} total test files`, + ); + return uiFiles; +} + +export async function listUITestFilesStrict( + options: Omit, +): Promise { + return listTestFiles({ ...options, strictMode: true }); +} + +export async function listUITestFilesRelaxed( + options: Omit, +): Promise { + return listTestFiles({ ...options, strictMode: false }); +} diff --git a/src/tools/percy-snapshot-utils/types.ts b/src/tools/percy-snapshot-utils/types.ts new file mode 100644 index 00000000..03b8b513 --- /dev/null +++ b/src/tools/percy-snapshot-utils/types.ts @@ -0,0 +1,20 @@ +import { + SDKSupportedTestingFrameworkEnum, + SDKSupportedLanguageEnum, +} from "../sdk-utils/common/types.js"; + +export type ListTestFilesParams = { + dirs: string[]; + language: SDKSupportedLanguageEnum; + framework?: SDKSupportedTestingFrameworkEnum; +}; + +export interface DetectionConfig { + extensions: string[]; + namePatterns: RegExp[]; + contentRegex: RegExp[]; + uiDriverRegex: RegExp[]; + uiIndicatorRegex: RegExp[]; + backendRegex: RegExp[]; + excludeRegex?: RegExp[]; +} diff --git a/src/tools/percy-snapshot-utils/utils.ts b/src/tools/percy-snapshot-utils/utils.ts new file mode 100644 index 00000000..67625a68 --- /dev/null +++ b/src/tools/percy-snapshot-utils/utils.ts @@ -0,0 +1,42 @@ +const content: { type: "text"; text: string }[] = []; + +export async function updateFileAndStep( + file: string, + idx: number, + total: number, + instruction: string, +) { + content.length = 0; + const nextIndex = idx + 1; + + content.push({ + type: "text", + text: `Complete all steps in order. If a tool call is requested, update the file first, then call the tool. Follow instructions exactly— do not skip any steps to ensure all files are updated.`, + }); + + content.push({ + type: "text", + text: `Step 1 : You need to add percy snapshot commands in some key test cases in the file ${file} use the following instructions: \n${instruction}`, + }); + + content.push({ + type: "text", + text: `Step 2 : Confirm that Percy snapshot commands have been added at all key points of visual change in the file ${file}.`, + }); + + if (nextIndex < total) { + content.push({ + type: "text", + text: `Step 3 : Call the tool updateTestFileWithInstructions with index as ${nextIndex} out of ${total}`, + }); + } + + if (nextIndex === total) { + content.push({ + type: "text", + text: `Step 3: Percy snapshot commands have been added to all files. You can now run the tool runPercyScan to run the percy scan.`, + }); + } + + return content; +} diff --git a/src/tools/review-agent-utils/build-counts.ts b/src/tools/review-agent-utils/build-counts.ts new file mode 100644 index 00000000..63acbda9 --- /dev/null +++ b/src/tools/review-agent-utils/build-counts.ts @@ -0,0 +1,51 @@ +// Utility for fetching the count of Percy builds and orgId. +export async function getPercyBuildCount(percyToken: string) { + const apiUrl = `https://percy.io/api/v1/builds`; + + const response = await fetch(apiUrl, { + headers: { + Authorization: `Token token=${percyToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Percy builds: ${response.statusText}`); + } + + const data = await response.json(); + const builds = data.data ?? []; + const included = data.included ?? []; + + let isFirstBuild = false; + let lastBuildId: string | undefined; + let orgId: string | undefined; + let browserIds: string[] = []; + + if (builds.length === 0) { + return { + noBuilds: true, + isFirstBuild: false, + lastBuildId: undefined, + orgId, + browserIds: [], + }; + } else { + isFirstBuild = builds.length === 1; + lastBuildId = builds[0].id; + } + + // Extract browserIds from the latest build if available + browserIds = + builds[0]?.relationships?.browsers?.data + ?.map((b: any) => b.id) + ?.filter((id: any) => typeof id === "string") ?? []; + + // Extract orgId from the `included` projects block + const project = included.find((item: any) => item.type === "projects"); + if (project?.relationships?.organization?.data?.id) { + orgId = project.relationships.organization.data.id; + } + + return { noBuilds: false, isFirstBuild, lastBuildId, orgId, browserIds }; +} diff --git a/src/tools/review-agent-utils/percy-approve-reject.ts b/src/tools/review-agent-utils/percy-approve-reject.ts new file mode 100644 index 00000000..ba4bd439 --- /dev/null +++ b/src/tools/review-agent-utils/percy-approve-reject.ts @@ -0,0 +1,53 @@ +import { BrowserStackConfig } from "../../lib/types.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export async function approveOrDeclinePercyBuild( + args: { buildId: string; action: "approve" | "unapprove" | "reject" }, + config: BrowserStackConfig, +): Promise { + const { buildId, action } = args; + + // Get Basic Auth credentials + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); + + // Prepare request body + const body = { + data: { + type: "reviews", + attributes: { action }, + relationships: { + build: { data: { type: "builds", id: buildId } }, + }, + }, + }; + + // Send request to Percy API + const response = await fetch("https://percy.io/api/v1/reviews", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${auth}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Percy build ${action} failed: ${response.status} ${errorText}`, + ); + } + + const result = await response.json(); + + return { + content: [ + { + type: "text", + text: `Percy build ${buildId} was ${result.data.attributes["review-state"]} by ${result.data.attributes["action-performed-by"].user_name}`, + }, + ], + }; +} diff --git a/src/tools/review-agent-utils/percy-diffs.ts b/src/tools/review-agent-utils/percy-diffs.ts new file mode 100644 index 00000000..1b6a9efe --- /dev/null +++ b/src/tools/review-agent-utils/percy-diffs.ts @@ -0,0 +1,60 @@ +export interface PercySnapshotDiff { + id: string; + name: string | null; + title: string; + description: string | null; + coordinates: any; +} + +export async function getPercySnapshotDiff( + snapshotId: string, + percyToken: string, +): Promise { + const apiUrl = `https://percy.io/api/v1/snapshots/${snapshotId}`; + + const response = await fetch(apiUrl, { + headers: { + Authorization: `Token token=${percyToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch Percy snapshot ${snapshotId}: ${response.statusText}`, + ); + } + + const data = await response.json(); + const pageUrl = data.data.attributes?.name || null; + + const changes: PercySnapshotDiff[] = []; + const comparisons = + data.included?.filter((item: any) => item.type === "comparisons") ?? []; + + for (const comparison of comparisons) { + const appliedRegions = comparison.attributes?.["applied-regions"] ?? []; + for (const region of appliedRegions) { + if (region.ignored) continue; + changes.push({ + id: String(region.id), + name: pageUrl, + title: region.change_title, + description: region.change_description ?? null, + coordinates: region.coordinates ?? null, + }); + } + } + + return changes; +} + +export async function getPercySnapshotDiffs( + snapshotIds: string[], + percyToken: string, +): Promise { + const allDiffs = await Promise.all( + snapshotIds.map((id) => getPercySnapshotDiff(id, percyToken)), + ); + return allDiffs.flat(); +} diff --git a/src/tools/review-agent-utils/percy-snapshots.ts b/src/tools/review-agent-utils/percy-snapshots.ts new file mode 100644 index 00000000..11a48717 --- /dev/null +++ b/src/tools/review-agent-utils/percy-snapshots.ts @@ -0,0 +1,105 @@ +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; +import { sanitizeUrlParam } from "../../lib/utils.js"; + +// Utility for fetching only the IDs of changed Percy snapshots for a given build. +export async function getChangedPercySnapshotIds( + buildId: string, + config: BrowserStackConfig, + orgId: string | undefined, + browserIds: string[], +): Promise { + if (!buildId || !orgId) { + throw new Error( + "Failed to fetch AI Summary: Missing build ID or organization ID", + ); + } + + const urlStr = constructPercyBuildItemsUrl({ + buildId, + orgId, + category: ["changed"], + subcategories: ["unreviewed", "approved", "changes_requested"], + groupSnapshotsBy: "similar_diff", + browserIds, + widths: ["375", "1280", "1920"], + }); + + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); + const response = await fetch(urlStr, { + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch changed Percy snapshots: ${response.status} ${response.statusText}`, + ); + } + + const responseData = await response.json(); + const buildItems = responseData.data ?? []; + + if (buildItems.length === 0) { + return []; + } + + const snapshotIds = buildItems + .flatMap((item: any) => item.attributes?.["snapshot-ids"] ?? []) + .map((id: any) => String(id)); + + return snapshotIds; +} + +export function constructPercyBuildItemsUrl({ + buildId, + orgId, + category = [], + subcategories = [], + browserIds = [], + widths = [], + groupSnapshotsBy, +}: { + buildId: string; + orgId: string; + category?: string[]; + subcategories?: string[]; + browserIds?: string[]; + widths?: string[]; + groupSnapshotsBy?: string; +}): string { + const url = new URL("https://percy.io/api/v1/build-items"); + url.searchParams.set("filter[build-id]", sanitizeUrlParam(buildId)); + url.searchParams.set("filter[organization-id]", sanitizeUrlParam(orgId)); + + if (category && category.length > 0) { + category.forEach((cat) => + url.searchParams.append("filter[category][]", sanitizeUrlParam(cat)), + ); + } + if (subcategories && subcategories.length > 0) { + subcategories.forEach((sub) => + url.searchParams.append("filter[subcategories][]", sanitizeUrlParam(sub)), + ); + } + if (browserIds && browserIds.length > 0) { + browserIds.forEach((id) => + url.searchParams.append("filter[browser_ids][]", sanitizeUrlParam(id)), + ); + } + if (widths && widths.length > 0) { + widths.forEach((w) => + url.searchParams.append("filter[widths][]", sanitizeUrlParam(w)), + ); + } + if (groupSnapshotsBy) { + url.searchParams.set( + "filter[group_snapshots_by]", + sanitizeUrlParam(groupSnapshotsBy), + ); + } + return url.toString(); +} diff --git a/src/tools/review-agent.ts b/src/tools/review-agent.ts new file mode 100644 index 00000000..4b8e4d2b --- /dev/null +++ b/src/tools/review-agent.ts @@ -0,0 +1,84 @@ +import logger from "../logger.js"; +import { BrowserStackConfig } from "../lib/types.js"; +import { getBrowserStackAuth } from "../lib/get-auth.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { getPercyBuildCount } from "./review-agent-utils/build-counts.js"; +import { getChangedPercySnapshotIds } from "./review-agent-utils/percy-snapshots.js"; +import { PercyIntegrationTypeEnum } from "./sdk-utils/common/types.js"; +import { fetchPercyToken } from "./sdk-utils/percy-web/fetchPercyToken.js"; + +import { + getPercySnapshotDiffs, + PercySnapshotDiff, +} from "./review-agent-utils/percy-diffs.js"; + +export async function fetchPercyChanges( + args: { project_name: string }, + config: BrowserStackConfig, +): Promise { + const { project_name } = args; + const authorization = getBrowserStackAuth(config); + + // Get Percy token for the project + const percyToken = await fetchPercyToken(project_name, authorization, { + type: PercyIntegrationTypeEnum.WEB, + }); + + // Get build info (noBuilds, isFirstBuild, lastBuildId) + const { noBuilds, isFirstBuild, lastBuildId, orgId, browserIds } = + await getPercyBuildCount(percyToken); + + if (noBuilds) { + return { + content: [ + { + type: "text", + text: "No Percy builds found. Please run your first Percy scan to start visual testing.", + }, + ], + }; + } + + if (isFirstBuild || !lastBuildId) { + return { + content: [ + { + type: "text", + text: "This is the first Percy build. No baseline exists to compare changes.", + }, + ], + }; + } + + // Get snapshot IDs for the latest build + const snapshotIds = await getChangedPercySnapshotIds( + lastBuildId, + config, + orgId, + browserIds, + ); + logger.info( + `Fetched ${snapshotIds.length} snapshot IDs for build: ${lastBuildId} as ${snapshotIds.join(", ")}`, + ); + + // Fetch all diffs concurrently and flatten results + const allDiffs = await getPercySnapshotDiffs(snapshotIds, percyToken); + + if (allDiffs.length === 0) { + return { + content: [ + { + type: "text", + text: "AI Summary is not yet available for this build/framework. There may still be visual changes—please review the build on the dashboard.", + }, + ], + }; + } + + return { + content: allDiffs.map((diff: PercySnapshotDiff) => ({ + type: "text", + text: `${diff.name} → ${diff.title}: ${diff.description ?? ""}`, + })), + }; +} diff --git a/src/tools/run-percy-scan.ts b/src/tools/run-percy-scan.ts new file mode 100644 index 00000000..fac071a0 --- /dev/null +++ b/src/tools/run-percy-scan.ts @@ -0,0 +1,61 @@ +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { PercyIntegrationTypeEnum } from "./sdk-utils/common/types.js"; +import { BrowserStackConfig } from "../lib/types.js"; +import { getBrowserStackAuth } from "../lib/get-auth.js"; +import { fetchPercyToken } from "./sdk-utils/percy-web/fetchPercyToken.js"; + +export async function runPercyScan( + args: { + projectName: string; + integrationType: PercyIntegrationTypeEnum; + instruction?: string; + }, + config: BrowserStackConfig, +): Promise { + const { projectName, integrationType, instruction } = args; + const authorization = getBrowserStackAuth(config); + const percyToken = await fetchPercyToken(projectName, authorization, { + type: integrationType, + }); + + const steps: string[] = [generatePercyTokenInstructions(percyToken)]; + + if (instruction) { + steps.push( + `Use the provided test command with Percy:\n${instruction}`, + `If this command fails or is incorrect, fall back to the default approach below.`, + ); + } + + steps.push( + `Attempt to infer the project's test command from context (high confidence commands first): +- Java → mvn test +- Python → pytest +- Node.js → npm test or yarn test +- Cypress → cypress run +or from package.json scripts`, + `Wrap the inferred command with Percy:\nnpx percy exec -- `, + `If the test command cannot be inferred confidently, ask the user directly for the correct test command.`, + ); + + const instructionContext = steps + .map((step, index) => `${index + 1}. ${step}`) + .join("\n\n"); + + return { + content: [ + { + type: "text", + text: instructionContext, + }, + ], + }; +} + +function generatePercyTokenInstructions(percyToken: string): string { + return `Set the environment variable for your project: + +export PERCY_TOKEN="${percyToken}" + +(For Windows: use 'setx PERCY_TOKEN "${percyToken}"' or 'set PERCY_TOKEN=${percyToken}' as appropriate.)`; +} diff --git a/src/tools/sdk-utils/bstack/commands.ts b/src/tools/sdk-utils/bstack/commands.ts new file mode 100644 index 00000000..1f600d72 --- /dev/null +++ b/src/tools/sdk-utils/bstack/commands.ts @@ -0,0 +1,123 @@ +import { SDKSupportedLanguage } from "../common/types.js"; + +// Constants +const MAVEN_ARCHETYPE_GROUP_ID = "com.browserstack"; +const MAVEN_ARCHETYPE_ARTIFACT_ID = "browserstack-sdk-archetype-integrate"; +const MAVEN_ARCHETYPE_VERSION = "1.0"; + +// Mapping of test frameworks to their corresponding Maven archetype framework names +const JAVA_FRAMEWORK_MAP: Record = { + testng: "testng", + junit5: "junit5", + junit4: "junit4", + cucumber: "cucumber-testng", +} as const; + +// Template for Node.js SDK setup instructions +const NODEJS_SDK_INSTRUCTIONS = ( + username: string, + accessKey: string, +): string => `---STEP--- +Install BrowserStack Node SDK using command: +\`\`\`bash +npm i -D browserstack-node-sdk@latest +\`\`\` +---STEP--- +Run the following command to setup browserstack sdk: +\`\`\`bash +npx setup --username ${username} --key ${accessKey} +\`\`\``; + +// Template for Gradle setup instructions (platform-independent) +const GRADLE_SETUP_INSTRUCTIONS = ` +**For Gradle setup:** +1. Add browserstack-java-sdk to dependencies: + compileOnly 'com.browserstack:browserstack-java-sdk:latest.release' + +2. Add browserstackSDK path variable: + def browserstackSDKArtifact = configurations.compileClasspath.resolvedConfiguration.resolvedArtifacts.find { it.name == 'browserstack-java-sdk' } + +3. Add javaagent to gradle tasks: + jvmArgs "-javaagent:\${browserstackSDKArtifact.file}" +`; + +// Generates Maven archetype command for Windows platform +function getMavenCommandForWindows( + framework: string, + mavenFramework: string, +): string { + return ( + `mvn archetype:generate -B ` + + `-DarchetypeGroupId="${MAVEN_ARCHETYPE_GROUP_ID}" ` + + `-DarchetypeArtifactId="${MAVEN_ARCHETYPE_ARTIFACT_ID}" ` + + `-DarchetypeVersion="${MAVEN_ARCHETYPE_VERSION}" ` + + `-DgroupId="${MAVEN_ARCHETYPE_GROUP_ID}" ` + + `-DartifactId="${MAVEN_ARCHETYPE_ARTIFACT_ID}" ` + + `-Dversion="${MAVEN_ARCHETYPE_VERSION}" ` + + `-DBROWSERSTACK_USERNAME="${process.env.BROWSERSTACK_USERNAME}" ` + + `-DBROWSERSTACK_ACCESS_KEY="${process.env.BROWSERSTACK_ACCESS_KEY}" ` + + `-DBROWSERSTACK_FRAMEWORK="${mavenFramework}"` + ); +} + +// Generates Maven archetype command for Unix-like platforms (macOS/Linux) +function getMavenCommandForUnix( + username: string, + accessKey: string, + mavenFramework: string, +): string { + return `mvn archetype:generate -B -DarchetypeGroupId=${MAVEN_ARCHETYPE_GROUP_ID} \\ +-DarchetypeArtifactId=${MAVEN_ARCHETYPE_ARTIFACT_ID} -DarchetypeVersion=${MAVEN_ARCHETYPE_VERSION} \\ +-DgroupId=${MAVEN_ARCHETYPE_GROUP_ID} -DartifactId=${MAVEN_ARCHETYPE_ARTIFACT_ID} -Dversion=${MAVEN_ARCHETYPE_VERSION} \\ +-DBROWSERSTACK_USERNAME="${username}" \\ +-DBROWSERSTACK_ACCESS_KEY="${accessKey}" \\ +-DBROWSERSTACK_FRAMEWORK="${mavenFramework}"`; +} + +// Generates Java SDK setup instructions with Maven/Gradle options +function getJavaSDKInstructions( + framework: string, + username: string, + accessKey: string, +): string { + const mavenFramework = getJavaFrameworkForMaven(framework); + const isWindows = process.platform === "win32"; + const platformLabel = isWindows ? "Windows" : "macOS/Linux"; + + const mavenCommand = isWindows + ? getMavenCommandForWindows(framework, mavenFramework) + : getMavenCommandForUnix(username, accessKey, mavenFramework); + + return `---STEP--- +Install BrowserStack Java SDK + +**Maven command for ${framework} (${platformLabel}):** +Run the command, it is required to generate the browserstack-sdk-archetype-integrate project: +${mavenCommand} + +Alternative setup for Gradle users: +${GRADLE_SETUP_INSTRUCTIONS}`; +} + +// Main function to get SDK setup commands based on language and framework +export function getSDKPrefixCommand( + language: SDKSupportedLanguage, + framework: string, + username: string, + accessKey: string, +): string { + switch (language) { + case "nodejs": + return NODEJS_SDK_INSTRUCTIONS(username, accessKey); + + case "java": + return getJavaSDKInstructions(framework, username, accessKey); + + default: + return ""; + } +} + +export function getJavaFrameworkForMaven(framework: string): string { + return JAVA_FRAMEWORK_MAP[framework] || framework; +} diff --git a/src/tools/sdk-utils/bstack/configUtils.ts b/src/tools/sdk-utils/bstack/configUtils.ts new file mode 100644 index 00000000..587abd33 --- /dev/null +++ b/src/tools/sdk-utils/bstack/configUtils.ts @@ -0,0 +1,72 @@ +/** + * Utilities for generating BrowserStack configuration files. + */ + +export function generateBrowserStackYMLInstructions( + desiredPlatforms: string[], + enablePercy: boolean = false, + projectName: string, +) { + let ymlContent = ` +# ====================== +# BrowserStack Reporting +# ====================== +# A single name for your project to organize all your tests. This is required for Percy. +projectName: ${projectName} +# TODO: Replace these sample values with your actual project details +buildName: Sample-Build + +# ======================================= +# Platforms (Browsers / Devices to test) +# ======================================= +# Platforms object contains all the browser / device combinations you want to test on. +# Generate this on the basis of the following platforms requested by the user: +# Requested platforms: ${desiredPlatforms} +platforms: + - os: Windows + osVersion: 11 + browserName: chrome + browserVersion: latest + +# ======================= +# Parallels per Platform +# ======================= +# The number of parallel threads to be used for each platform set. +# BrowserStack's SDK runner will select the best strategy based on the configured value +# +# Example 1 - If you have configured 3 platforms and set \`parallelsPerPlatform\` as 2, a total of 6 (2 * 3) parallel threads will be used on BrowserStack +# +# Example 2 - If you have configured 1 platform and set \`parallelsPerPlatform\` as 5, a total of 5 (1 * 5) parallel threads will be used on BrowserStack +parallelsPerPlatform: 1 + +# ================= +# Local Testing +# ================= +# Set to true to test local +browserstackLocal: true + +# =================== +# Debugging features +# =================== +debug: true # Visual logs, text logs, etc. +testObservability: true # For Test Observability`; + + if (enablePercy) { + ymlContent += ` + +# ===================== +# Percy Visual Testing +# ===================== +# Set percy to true to enable visual testing. +# Set percyCaptureMode to 'manual' to control when screenshots are taken. +percy: true +percyCaptureMode: manual`; + } + return ` +---STEP--- +Create a browserstack.yml file in the project root. The file should be in the following format: + +\`\`\`yaml${ymlContent} +\`\`\` +\n`; +} diff --git a/src/tools/sdk-utils/constants.ts b/src/tools/sdk-utils/bstack/constants.ts similarity index 89% rename from src/tools/sdk-utils/constants.ts rename to src/tools/sdk-utils/bstack/constants.ts index 6bd0c075..161be95e 100644 --- a/src/tools/sdk-utils/constants.ts +++ b/src/tools/sdk-utils/bstack/constants.ts @@ -1,10 +1,11 @@ -import { ConfigMapping } from "./types.js"; +import { ConfigMapping } from "../common/types.js"; /** * ---------- PYTHON INSTRUCTIONS ---------- */ -const pythonInstructions = (username: string, accessKey: string) => ` +export const pythonInstructions = (username: string, accessKey: string) => { + const setup = ` ---STEP--- Install the BrowserStack SDK: @@ -18,7 +19,9 @@ Setup the BrowserStack SDK with your credentials: \`\`\`bash browserstack-sdk setup --username "${username}" --key "${accessKey}" \`\`\` +`; + const run = ` ---STEP--- Run your tests on BrowserStack: @@ -27,8 +30,12 @@ browserstack-sdk python \`\`\` `; -const generatePythonFrameworkInstructions = - (framework: string) => (username: string, accessKey: string) => ` + return { setup, run }; +}; + +export const generatePythonFrameworkInstructions = + (framework: string) => (username: string, accessKey: string) => { + const setup = ` ---STEP--- Install the BrowserStack SDK: @@ -43,7 +50,9 @@ Setup the BrowserStack SDK with framework-specific configuration: \`\`\`bash browserstack-sdk setup --framework "${framework}" --username "${username}" --key "${accessKey}" \`\`\` +`; + const run = ` ---STEP--- Run your ${framework} tests on BrowserStack: @@ -52,9 +61,12 @@ browserstack-sdk ${framework} \`\`\` `; -const robotInstructions = generatePythonFrameworkInstructions("robot"); -const behaveInstructions = generatePythonFrameworkInstructions("behave"); -const pytestInstructions = generatePythonFrameworkInstructions("pytest"); + return { setup, run }; + }; + +export const robotInstructions = generatePythonFrameworkInstructions("robot"); +export const behaveInstructions = generatePythonFrameworkInstructions("behave"); +export const pytestInstructions = generatePythonFrameworkInstructions("pytest"); /** * ---------- JAVA INSTRUCTIONS ---------- @@ -63,7 +75,8 @@ const pytestInstructions = generatePythonFrameworkInstructions("pytest"); const argsInstruction = '-javaagent:"${com.browserstack:browserstack-java-sdk:jar}"'; -const javaInstructions = (username: string, accessKey: string) => ` +export const javaInstructions = (username: string, accessKey: string) => { + const setup = ` ---STEP--- Add the BrowserStack Java SDK dependency to your \`pom.xml\`: @@ -92,7 +105,9 @@ Export your BrowserStack credentials as environment variables: export BROWSERSTACK_USERNAME=${username} export BROWSERSTACK_ACCESS_KEY=${accessKey} \`\`\` +`; + const run = ` ---STEP--- Run your tests using Maven: @@ -106,68 +121,18 @@ gradle clean test \`\`\` `; -const serenityInstructions = (username: string, accessKey: string) => ` ----STEP--- - -Set BrowserStack credentials as environment variables: -For macOS/Linux: -\`\`\`bash -export BROWSERSTACK_USERNAME=${username} -export BROWSERSTACK_ACCESS_KEY=${accessKey} -\`\`\` - -For Windows Command Prompt: -\`\`\`cmd -set BROWSERSTACK_USERNAME=${username} -set BROWSERSTACK_ACCESS_KEY=${accessKey} -\`\`\` - ----STEP--- - -Add serenity-browserstack dependency in pom.xml: -Add the following dependency to your pom.xml file and save it: -\`\`\`xml - - net.serenity-bdd - serenity-browserstack - 3.3.4 - -\`\`\` - ----STEP--- - -Set up serenity.conf file: -Create or update your serenity.conf file in the project root with the following configuration: -\`\`\` -webdriver { - driver = remote - remote.url = "https://hub.browserstack.com/wd/hub" -} -browserstack.user="${username}" -browserstack.key="${accessKey}" -\`\`\` - ----STEP--- - -Run your Serenity tests: -You can continue running your tests as you normally would. For example: - -Using Maven: -\`\`\`bash -mvn clean verify -\`\`\` - -Using Gradle: -\`\`\`bash -gradle clean test -\`\`\` -`; + return { setup, run }; +}; /** * ---------- CSharp INSTRUCTIONS ---------- */ -const csharpCommonInstructions = (username: string, accessKey: string) => ` +export const csharpCommonInstructions = ( + username: string, + accessKey: string, +) => { + const setup = ` ---STEP--- Install BrowserStack TestAdapter NuGet package: @@ -216,7 +181,9 @@ Install the x64 version of .NET for BrowserStack compatibility. sudo dotnet browserstack-sdk setup-dotnet --dotnet-path "" --dotnet-version "" \`\`\` Common paths: /usr/local/share/dotnet, ~/dotnet-x64, or /opt/dotnet-x64 +`; + const run = ` ---STEP--- Run the tests: @@ -230,10 +197,14 @@ Run the tests: \`\`\` `; -const csharpPlaywrightCommonInstructions = ( + return { setup, run }; +}; + +export const csharpPlaywrightCommonInstructions = ( username: string, accessKey: string, -) => ` +) => { + const setup = ` ---STEP--- Install BrowserStack TestAdapter NuGet package: @@ -295,7 +266,9 @@ Fix for Playwright architecture (macOS only): If the folder exists: \`/bin/Debug/net8.0/.playwright/node/darwin-arm64\` Rename \`darwin-arm64\` to \`darwin-x64\` +`; + const run = ` ---STEP--- Run the tests: @@ -309,11 +282,15 @@ Run the tests: \`\`\` `; + return { setup, run }; +}; + /** * ---------- NODEJS INSTRUCTIONS ---------- */ -const nodejsInstructions = (username: string, accessKey: string) => ` +export const nodejsInstructions = (username: string, accessKey: string) => { + const setup = ` ---STEP--- Ensure \`browserstack-node-sdk\` is present in package.json with the latest version: @@ -350,11 +327,27 @@ Run your tests: You can now run your tests on BrowserStack using your standard command or Use the commands defined in your package.json file to run the tests. `; + const run = ` +---STEP--- + +Run your tests on BrowserStack: +\`\`\`bash +npm run test:browserstack +\`\`\` +`; + + return { setup, run }; +}; + /** * ---------- EXPORT CONFIG ---------- */ -const webdriverioInstructions = (username: string, accessKey: string) => ` +export const webdriverioInstructions = ( + username: string, + accessKey: string, +) => { + const setup = ` ---STEP--- Set BrowserStack Credentials: @@ -453,14 +446,20 @@ exports.config.capabilities.forEach(function (caps) { caps[i] = { ...caps[i], ...exports.config.commonCapabilities[i]}; }); \`\`\` +`; + const run = ` ---STEP--- Run your tests: You can now run your tests on BrowserStack using your standard WebdriverIO command or Use the commands defined in your package.json file to run the tests. `; -const cypressInstructions = (username: string, accessKey: string) => ` + return { setup, run }; +}; + +export const cypressInstructions = (username: string, accessKey: string) => { + const setup = ` ---STEP--- Install the BrowserStack Cypress CLI: @@ -521,7 +520,9 @@ Open the generated \`browserstack.json\` file and update it with your BrowserSta \`\`\` **Note:** For Cypress v9 or lower, use \`"cypress_config_file": "./cypress.json"\`. The \`testObservability: true\` flag enables the [Test Reporting & Analytics dashboard](https://www.browserstack.com/docs/test-management/test-reporting-and-analytics) for deeper insights into your test runs. +`; + const run = ` ---STEP--- Run Your Tests on BrowserStack: @@ -530,9 +531,74 @@ Execute your tests on BrowserStack using the following command: npx browserstack-cypress run --sync \`\`\` -After the tests complete, you can view the results on your [BrowserStack Automate Dashboard](https://automate.browserstack.com/dashboard/). +After the tests complete, you can view the results on your [BrowserStack Automate Dashboard](https://automate.browserstack.com/dashboard/).`; + + return { setup, run }; +}; + +const serenityInstructions = (username: string, accessKey: string) => { + const setup = ` +---STEP--- + +Set BrowserStack credentials as environment variables: +For macOS/Linux: +\`\`\`bash +export BROWSERSTACK_USERNAME=${username} +export BROWSERSTACK_ACCESS_KEY=${accessKey} +\`\`\` + +For Windows Command Prompt: +\`\`\`cmd +set BROWSERSTACK_USERNAME=${username} +set BROWSERSTACK_ACCESS_KEY=${accessKey} +\`\`\` + +---STEP--- + +Add serenity-browserstack dependency in pom.xml: +Add the following dependency to your pom.xml file and save it: +\`\`\`xml + + net.serenity-bdd + serenity-browserstack + 3.3.4 + +\`\`\` + +---STEP--- + +Set up serenity.conf file: +Create or update your serenity.conf file in the project root with the following configuration: +\`\`\` +webdriver { + driver = remote + remote.url = "https://hub.browserstack.com/wd/hub" +} +browserstack.user="${username}" +browserstack.key="${accessKey}" +\`\`\` +`; + + const run = ` +---STEP--- + +Run your Serenity tests: +You can continue running your tests as you normally would. For example: + +Using Maven: +\`\`\`bash +mvn clean verify +\`\`\` + +Using Gradle: +\`\`\`bash +gradle clean test +\`\`\` `; + return { setup, run }; +}; + export const SUPPORTED_CONFIGURATIONS: ConfigMapping = { python: { playwright: { @@ -588,8 +654,5 @@ export const SUPPORTED_CONFIGURATIONS: ConfigMapping = { cypress: { cypress: { instructions: cypressInstructions }, }, - webdriverio: { - mocha: { instructions: webdriverioInstructions }, - }, }, }; diff --git a/src/tools/sdk-utils/bstack/frameworks.ts b/src/tools/sdk-utils/bstack/frameworks.ts new file mode 100644 index 00000000..cfacb315 --- /dev/null +++ b/src/tools/sdk-utils/bstack/frameworks.ts @@ -0,0 +1,59 @@ +import { ConfigMapping } from "../common/types.js"; +import * as constants from "./constants.js"; + +export const SUPPORTED_CONFIGURATIONS: ConfigMapping = { + python: { + playwright: { + pytest: { instructions: constants.pythonInstructions }, + }, + selenium: { + pytest: { instructions: constants.pytestInstructions }, + robot: { instructions: constants.robotInstructions }, + behave: { instructions: constants.behaveInstructions }, + }, + }, + java: { + playwright: { + junit4: { instructions: constants.javaInstructions }, + junit5: { instructions: constants.javaInstructions }, + testng: { instructions: constants.javaInstructions }, + }, + selenium: { + testng: { instructions: constants.javaInstructions }, + cucumber: { instructions: constants.javaInstructions }, + junit4: { instructions: constants.javaInstructions }, + junit5: { instructions: constants.javaInstructions }, + }, + }, + csharp: { + playwright: { + nunit: { instructions: constants.csharpPlaywrightCommonInstructions }, + mstest: { instructions: constants.csharpPlaywrightCommonInstructions }, + }, + selenium: { + xunit: { instructions: constants.csharpCommonInstructions }, + nunit: { instructions: constants.csharpCommonInstructions }, + mstest: { instructions: constants.csharpCommonInstructions }, + specflow: { instructions: constants.csharpCommonInstructions }, + reqnroll: { instructions: constants.csharpCommonInstructions }, + }, + }, + nodejs: { + playwright: { + jest: { instructions: constants.nodejsInstructions }, + codeceptjs: { instructions: constants.nodejsInstructions }, + playwright: { instructions: constants.nodejsInstructions }, + }, + selenium: { + jest: { instructions: constants.nodejsInstructions }, + webdriverio: { instructions: constants.webdriverioInstructions }, + mocha: { instructions: constants.nodejsInstructions }, + cucumber: { instructions: constants.nodejsInstructions }, + nightwatch: { instructions: constants.nodejsInstructions }, + codeceptjs: { instructions: constants.nodejsInstructions }, + }, + cypress: { + cypress: { instructions: constants.cypressInstructions }, + }, + }, +}; diff --git a/src/tools/sdk-utils/bstack/index.ts b/src/tools/sdk-utils/bstack/index.ts new file mode 100644 index 00000000..d11f85f1 --- /dev/null +++ b/src/tools/sdk-utils/bstack/index.ts @@ -0,0 +1,5 @@ +// BrowserStack SDK utilities +export { runBstackSDKOnly } from "./sdkHandler.js"; +export { getSDKPrefixCommand, getJavaFrameworkForMaven } from "./commands.js"; +export { generateBrowserStackYMLInstructions } from "./configUtils.js"; +export { SUPPORTED_CONFIGURATIONS } from "./frameworks.js"; diff --git a/src/tools/sdk-utils/bstack/sdkHandler.ts b/src/tools/sdk-utils/bstack/sdkHandler.ts new file mode 100644 index 00000000..23957d7b --- /dev/null +++ b/src/tools/sdk-utils/bstack/sdkHandler.ts @@ -0,0 +1,123 @@ +// Handler for BrowserStack SDK only (no Percy) - Sets up BrowserStack SDK with YML configuration +import { RunTestsInstructionResult, RunTestsStep } from "../common/types.js"; +import { RunTestsOnBrowserStackInput } from "../common/schema.js"; +import { getBrowserStackAuth } from "../../../lib/get-auth.js"; +import { getSDKPrefixCommand } from "./commands.js"; +import { generateBrowserStackYMLInstructions } from "./configUtils.js"; +import { getInstructionsForProjectConfiguration } from "../common/instructionUtils.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { + SDKSupportedBrowserAutomationFramework, + SDKSupportedTestingFramework, + SDKSupportedLanguage, +} from "../common/types.js"; + +export function runBstackSDKOnly( + input: RunTestsOnBrowserStackInput, + config: BrowserStackConfig, + isPercyAutomate = false, +): RunTestsInstructionResult { + const steps: RunTestsStep[] = []; + const authString = getBrowserStackAuth(config); + const [username, accessKey] = authString.split(":"); + + // Handle frameworks with unique setup instructions that don't use browserstack.yml + if ( + input.detectedBrowserAutomationFramework === "cypress" || + input.detectedTestingFramework === "webdriverio" + ) { + const frameworkInstructions = getInstructionsForProjectConfiguration( + input.detectedBrowserAutomationFramework as SDKSupportedBrowserAutomationFramework, + input.detectedTestingFramework as SDKSupportedTestingFramework, + input.detectedLanguage as SDKSupportedLanguage, + username, + accessKey, + ); + + if (frameworkInstructions) { + if (frameworkInstructions.setup) { + steps.push({ + type: "instruction", + title: "Framework-Specific Setup", + content: frameworkInstructions.setup, + }); + } + + if (frameworkInstructions.run && !isPercyAutomate) { + steps.push({ + type: "instruction", + title: "Run the tests", + content: frameworkInstructions.run, + }); + } + } + + return { + steps, + requiresPercy: false, + missingDependencies: [], + }; + } + + // Default flow using browserstack.yml + const sdkSetupCommand = getSDKPrefixCommand( + input.detectedLanguage as SDKSupportedLanguage, + input.detectedTestingFramework as SDKSupportedTestingFramework, + username, + accessKey, + ); + + if (sdkSetupCommand) { + steps.push({ + type: "instruction", + title: "Install BrowserStack SDK", + content: sdkSetupCommand, + }); + } + + const ymlInstructions = generateBrowserStackYMLInstructions( + input.desiredPlatforms as string[], + false, + input.projectName, + ); + + if (ymlInstructions) { + steps.push({ + type: "instruction", + title: "Configure browserstack.yml", + content: ymlInstructions, + }); + } + + const frameworkInstructions = getInstructionsForProjectConfiguration( + input.detectedBrowserAutomationFramework as SDKSupportedBrowserAutomationFramework, + input.detectedTestingFramework as SDKSupportedTestingFramework, + input.detectedLanguage as SDKSupportedLanguage, + username, + accessKey, + ); + + if (frameworkInstructions) { + if (frameworkInstructions.setup) { + steps.push({ + type: "instruction", + title: "Framework-Specific Setup", + content: frameworkInstructions.setup, + }); + } + + if (frameworkInstructions.run && !isPercyAutomate) { + steps.push({ + type: "instruction", + title: "Run the tests", + content: frameworkInstructions.run, + }); + } + } + + return { + steps, + requiresPercy: false, + missingDependencies: [], + }; +} diff --git a/src/tools/sdk-utils/commands.ts b/src/tools/sdk-utils/commands.ts deleted file mode 100644 index f2573475..00000000 --- a/src/tools/sdk-utils/commands.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Utility to get the language-dependent prefix command for BrowserStack SDK setup -import { SDKSupportedLanguage } from "./types.js"; - -// Framework mapping for Java Maven archetype generation -const JAVA_FRAMEWORK_MAP: Record = { - testng: "testng", - junit5: "junit5", - junit4: "junit4", - cucumber: "cucumber-testng", - serenity: "serenity", -}; - -// Common Gradle setup instructions (platform-independent) -const GRADLE_SETUP_INSTRUCTIONS = ` -**For Gradle setup:** -1. Add browserstack-java-sdk to dependencies: - compileOnly 'com.browserstack:browserstack-java-sdk:latest.release' - -2. Add browserstackSDK path variable: - def browserstackSDKArtifact = configurations.compileClasspath.resolvedConfiguration.resolvedArtifacts.find { it.name == 'browserstack-java-sdk' } - -3. Add javaagent to gradle tasks: - jvmArgs "-javaagent:\${browserstackSDKArtifact.file}" -`; - -export function getSDKPrefixCommand( - language: SDKSupportedLanguage, - framework: string, - username: string, - accessKey: string, -): string { - switch (language) { - case "nodejs": - return `---STEP--- -Install BrowserStack Node SDK using command: -\`\`\`bash -npm i -D browserstack-node-sdk@latest -\`\`\` ----STEP--- -Run the following command to setup browserstack sdk: -\`\`\`bash -npx setup --username ${username} --key ${accessKey} -\`\`\` ----STEP--- -Edit the browserstack.yml file that was created in the project root to add your desired platforms and browsers.`; - - case "java": { - const mavenFramework = getJavaFrameworkForMaven(framework); - const isWindows = process.platform === "win32"; - - const mavenCommand = isWindows - ? `mvn archetype:generate -B -DarchetypeGroupId="com.browserstack" -DarchetypeArtifactId="browserstack-sdk-archetype-integrate" -DarchetypeVersion="1.0" -DgroupId="com.browserstack" -DartifactId="browserstack-sdk-archetype-integrate" -Dversion="1.0" -DBROWSERSTACK_USERNAME="${process.env.BROWSERSTACK_USERNAME}" -DBROWSERSTACK_ACCESS_KEY="${process.env.BROWSERSTACK_ACCESS_KEY}" -DBROWSERSTACK_FRAMEWORK="${mavenFramework}"` - : `mvn archetype:generate -B -DarchetypeGroupId=com.browserstack \\ --DarchetypeArtifactId=browserstack-sdk-archetype-integrate -DarchetypeVersion=1.0 \\ --DgroupId=com.browserstack -DartifactId=browserstack-sdk-archetype-integrate -Dversion=1.0 \\ --DBROWSERSTACK_USERNAME="${username}" \\ --DBROWSERSTACK_ACCESS_KEY="${accessKey}" \\ --DBROWSERSTACK_FRAMEWORK="${mavenFramework}"`; - - const platformLabel = isWindows ? "Windows" : "macOS/Linux"; - - return `---STEP--- -Install BrowserStack Java SDK - -**Maven command for ${framework} (${platformLabel}):** -Run the command, it is required to generate the browserstack-sdk-archetype-integrate project: -${mavenCommand} - -Alternative setup for Gradle users: -${GRADLE_SETUP_INSTRUCTIONS}`; - } - - // Add more languages as needed - default: - return ""; - } -} - -export function getJavaFrameworkForMaven(framework: string): string { - return JAVA_FRAMEWORK_MAP[framework] || framework; -} diff --git a/src/tools/sdk-utils/common/constants.ts b/src/tools/sdk-utils/common/constants.ts new file mode 100644 index 00000000..36bfee70 --- /dev/null +++ b/src/tools/sdk-utils/common/constants.ts @@ -0,0 +1,102 @@ +export const IMPORTANT_SETUP_WARNING = + "IMPORTANT: DO NOT SKIP ANY STEP. All the setup steps described below MUST be executed regardless of any existing configuration or setup. This ensures proper BrowserStack SDK setup."; + +export const SETUP_PERCY_DESCRIPTION = + "Set up Percy visual testing for your project. This supports both Percy Web Standalone and Percy Automate."; + +export const LIST_TEST_FILES_DESCRIPTION = + "Lists all test files for a given set of directories."; + +export const PERCY_SNAPSHOT_COMMANDS_DESCRIPTION = + "Adds Percy snapshot commands to the specified test files."; + +export const RUN_ON_BROWSERSTACK_DESCRIPTION = + "Set up and run automated web-based tests on BrowserStack using the BrowserStack SDK. Use this tool for functional or integration test setup on BrowserStack only. For any visual testing or Percy integration, use the dedicated Percy setup tool. Example prompts: run this test on browserstack; set up this project for browserstack."; + +export const SIMULATE_PERCY_CHANGE_DESCRIPTION = + "This tool helps new users simulate a Percy visual change by guiding them to make a visible UI modification, rerun Percy, and verify that the change is detected. Only Percy web is supported"; + +export const PERCY_REPLACE_REGEX = + /Invoke listTestFiles\(\) with the provided directories[\s\S]*?- DO NOT STOP until you add commands in all the files or you reach end of the files\./; + +export const PERCY_SNAPSHOT_INSTRUCTION = ` +Invoke listTestFiles() with the provided directories from user to gather all test files in memory and obtain the generated UUID ---STEP--- +Process files in STRICT sequential order using tool addPercySnapshotCommands() with below instructions: +- Start with index 0 +- Then index 1 +- Then index 2 +- Continue sequentially until the last index +- DO NOT skip any index numbers +- DO NOT jump to random indices like 10, 20, 30 +- DO NOT worry about time consuming and any constraints. +- DO NOT STOP until you add commands in all the files or you reach end of the files. +`; + +export const PERCY_SIMULATE_INSTRUCTION = ` +MANDATORY: Aggressive, Accurate Percy Snapshots Integration + +Prerequisite: Before making any code changes in the user's repo, switch to a branch named temp-percy (run git init first if the repo is not yet initialized). + +You MUST follow these steps with NO EXCEPTIONS: +1. Identify 4-5 key test files in the project that are critical for visual testing. +2. For EACH identified test file, analyze the code to find EVERY location where a Percy snapshot should be called (e.g., after each navigation, UI change, or significant state in tests). +3. Insert snapshot calls at ALL such points, not just the first or most obvious one. + +Iteratively update every selected test file with percy snapshot calls at all identified points, ensuring comprehensive coverage of visual changes.`; + +export const PERCY_SIMULATION_DRIVER_INSTRUCTION = ` +MANDATORY: Step-by-Step Percy Visual Change Simulation +You MUST follow these steps with NO EXCEPTIONS: + +STEP 1: At this case we have added Percy snapshot commands to the test files at the appropriate locations (e.g., after navigation, UI change, or significant state). +- Use ONLY existing snapshot names. +- Do NOT add any popup injection or visual changes yet. +- Do NOT run any Percy builds at this stage. + +STEP 2: Run ONE comprehensive baseline Percy build that executes ALL tests containing Percy snapshots in a SINGLE build. This creates one baseline build with all snapshots for comparison. Use a command like: npx percy exec -- python -m pytest tests/ -k 'test_name1 or test_name2 or test_name3' -v to run multiple specific tests in one build." + +STEP 3: Modify your test to inject a visible UI change (such as a popup) IMMEDIATELY BEFORE an EXISTING snapshot command (e.g., before percy_snapshot(self.driver, "screenshot name")). +- Do NOT add a new snapshot name for the popup. +- The popup must appear in an existing snapshot, not a new one. +- Add this popup code in some test files before the percy_snapshot command you've added, to display the visual changes. + +\`\`\`Javascript +popup_script = \` +var popup = document.createElement('div'); +popup.id = 'percy-test-popup'; +popup.style.cssText = popup.style.cssText = \` + /* TODO: Add styles to make the popup large, centered, and visually noticeable. + Suggested properties: position: fixed; top/left; transform; background; color; font-size; padding; z-index; animation, etc. */ +\`; +popup.innerHTML = 'PERCY TEST
VISUAL CHANGE
DETECTED!'; +document.body.appendChild(popup); +\`; + +# Insert this just before the EXISTING snapshot command: +driver.execute_script(popup_script) +percy_snapshot(self.driver, "Before Adding to Cart") # (Do NOT change the snapshot name, keep existing one) +\`\`\` + +STEP 4: Run a second Percy build. +- The snapshot names must remain the same as in the baseline. +- The visual change should now appear in the same snapshot as before. +- Use the same build command you ran for the baseline. + +STEP 5: Compare the two Percy builds to see the detected visual difference. + +STEP 6: Now ask user if they want to setup percy for full project coverage? If yes, call the "setupPercyVisualTesting" tool to enable complete coverage for the entire project. + +CONSTRAINTS: +- Do NOT run any builds until explicitly instructed in the steps. +- Do NOT add new snapshot names—only use existing ones. +- Do NOT add popup injection until the baseline is established. +- Visual changes must appear in EXISTING snapshots, not new ones. + +VALIDATION CHECKPOINTS (before proceeding to the next step): +- Are you adding only snapshot commands (not running builds)? +- Are you reusing existing snapshot names (not creating new ones)? +- Have you established the baseline first (before adding visual changes) + +CRITICAL: +Do NOT run tests separately or create multiple builds during baseline establishment. The goal is to have exactly TWO builds total: (1) baseline build with all original snapshots, (2) modified build with the same tests but visual changes injected. +`; diff --git a/src/tools/sdk-utils/common/formatUtils.ts b/src/tools/sdk-utils/common/formatUtils.ts new file mode 100644 index 00000000..e50527b5 --- /dev/null +++ b/src/tools/sdk-utils/common/formatUtils.ts @@ -0,0 +1,34 @@ +export function formatInstructionsWithNumbers( + instructionText: string, + separator: string = "---STEP---", +): { formattedSteps: string; stepCount: number } { + // Split the instructions by the separator + const steps = instructionText + .split(separator) + .map((step) => step.trim()) + .filter((step) => step.length > 0); + + // If no separators found, treat the entire text as one step + if (steps.length === 1 && !instructionText.includes(separator)) { + return { + formattedSteps: `**Step 1:**\n${instructionText.trim()}`, + stepCount: 1, + }; + } + + // Format each step with numbering + const formattedSteps = steps + .map((step, index) => { + return `**Step ${index + 1}:**\n${step.trim()}`; + }) + .join("\n\n"); + + return { + formattedSteps, + stepCount: steps.length, + }; +} + +export function generateVerificationMessage(stepCount: number): string { + return `**✅ Verification:**\nPlease verify that you have completed all ${stepCount} steps above to ensure proper setup. If you encounter any issues, double-check each step and ensure all commands executed successfully.`; +} diff --git a/src/tools/sdk-utils/common/index.ts b/src/tools/sdk-utils/common/index.ts new file mode 100644 index 00000000..7a145f57 --- /dev/null +++ b/src/tools/sdk-utils/common/index.ts @@ -0,0 +1,4 @@ +// Common utilities and types for SDK tools +export * from "./types.js"; +export * from "./constants.js"; +export * from "./formatUtils.js"; diff --git a/src/tools/sdk-utils/common/instructionUtils.ts b/src/tools/sdk-utils/common/instructionUtils.ts new file mode 100644 index 00000000..484928ed --- /dev/null +++ b/src/tools/sdk-utils/common/instructionUtils.ts @@ -0,0 +1,49 @@ +/** + * Core instruction configuration utilities for runTestsOnBrowserStack tool. + */ + +import { SUPPORTED_CONFIGURATIONS } from "../bstack/frameworks.js"; +import { + SDKSupportedLanguage, + SDKSupportedBrowserAutomationFramework, + SDKSupportedTestingFramework, +} from "./types.js"; + +const errorMessageSuffix = + "Please open an issue at our Github repo: https://github.com/browserstack/browserstack-mcp-server/issues to request support for your project configuration"; + +export const getInstructionsForProjectConfiguration = ( + detectedBrowserAutomationFramework: SDKSupportedBrowserAutomationFramework, + detectedTestingFramework: SDKSupportedTestingFramework, + detectedLanguage: SDKSupportedLanguage, + username: string, + accessKey: string, +) => { + const configuration = SUPPORTED_CONFIGURATIONS[detectedLanguage]; + + if (!configuration) { + throw new Error( + `BrowserStack MCP Server currently does not support ${detectedLanguage}, ${errorMessageSuffix}`, + ); + } + + if (!configuration[detectedBrowserAutomationFramework]) { + throw new Error( + `BrowserStack MCP Server currently does not support ${detectedBrowserAutomationFramework} for ${detectedLanguage}, ${errorMessageSuffix}`, + ); + } + + if ( + !configuration[detectedBrowserAutomationFramework][detectedTestingFramework] + ) { + throw new Error( + `BrowserStack MCP Server currently does not support ${detectedTestingFramework} for ${detectedBrowserAutomationFramework} on ${detectedLanguage}, ${errorMessageSuffix}`, + ); + } + + const instructionFunction = + configuration[detectedBrowserAutomationFramework][detectedTestingFramework] + .instructions; + + return instructionFunction(username, accessKey); +}; diff --git a/src/tools/sdk-utils/common/schema.ts b/src/tools/sdk-utils/common/schema.ts new file mode 100644 index 00000000..754d30d0 --- /dev/null +++ b/src/tools/sdk-utils/common/schema.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; +import { + SDKSupportedBrowserAutomationFrameworkEnum, + SDKSupportedTestingFrameworkEnum, + SDKSupportedLanguageEnum, +} from "./types.js"; +import { PercyIntegrationTypeEnum } from "./types.js"; + +export const SetUpPercyParamsShape = { + projectName: z.string().describe("A unique name for your Percy project."), + detectedLanguage: z.nativeEnum(SDKSupportedLanguageEnum), + detectedBrowserAutomationFramework: z.nativeEnum( + SDKSupportedBrowserAutomationFrameworkEnum, + ), + detectedTestingFramework: z.nativeEnum(SDKSupportedTestingFrameworkEnum), + integrationType: z + .nativeEnum(PercyIntegrationTypeEnum) + .describe( + "Specifies whether to integrate with Percy Web or Percy Automate. If not explicitly provided, prompt the user to select the desired integration type.", + ), + folderPaths: z + .array(z.string()) + .describe( + "An array of folder paths to include in which Percy will be integrated. If not provided, strictly inspect the code and return the folders which contain UI test cases.", + ), +}; + +export const RunTestsOnBrowserStackParamsShape = { + projectName: z + .string() + .describe("A single name for your project to organize all your tests."), + detectedLanguage: z.nativeEnum(SDKSupportedLanguageEnum), + detectedBrowserAutomationFramework: z.nativeEnum( + SDKSupportedBrowserAutomationFrameworkEnum, + ), + detectedTestingFramework: z.nativeEnum(SDKSupportedTestingFrameworkEnum), + desiredPlatforms: z + .array(z.enum(["windows", "macos", "android", "ios"])) + .describe("An array of platforms to run tests on."), +}; + +export const SetUpPercySchema = z.object(SetUpPercyParamsShape); +export const RunTestsOnBrowserStackSchema = z.object( + RunTestsOnBrowserStackParamsShape, +); + +export type SetUpPercyInput = z.infer; +export type RunTestsOnBrowserStackInput = z.infer< + typeof RunTestsOnBrowserStackSchema +>; + +export const RunPercyScanParamsShape = { + projectName: z.string().describe("The name of the project to run Percy on."), + percyRunCommand: z + .string() + .optional() + .describe( + "The test command to run with Percy. Optional — the LLM should try to infer it first from project context.", + ), + integrationType: z + .nativeEnum(PercyIntegrationTypeEnum) + .describe( + "Specifies whether to integrate with Percy Web or Percy Automate. If not explicitly provided, prompt the user to select the desired integration type.", + ), +}; + +export const FetchPercyChangesParamsShape = { + project_name: z + .string() + .describe( + "The name of the BrowserStack project. If not found, ask user directly.", + ), +}; + +export const ManagePercyBuildApprovalParamsShape = { + buildId: z + .string() + .describe("The ID of the Percy build to approve or reject."), + action: z + .enum(["approve", "unapprove", "reject"]) + .describe("The action to perform on the Percy build."), +}; diff --git a/src/tools/sdk-utils/common/types.ts b/src/tools/sdk-utils/common/types.ts new file mode 100644 index 00000000..3994a899 --- /dev/null +++ b/src/tools/sdk-utils/common/types.ts @@ -0,0 +1,94 @@ +export enum PercyIntegrationTypeEnum { + WEB = "web", + AUTOMATE = "automate", +} + +export enum SDKSupportedLanguageEnum { + nodejs = "nodejs", + python = "python", + java = "java", + csharp = "csharp", + ruby = "ruby", +} +export type SDKSupportedLanguage = keyof typeof SDKSupportedLanguageEnum; + +export enum SDKSupportedBrowserAutomationFrameworkEnum { + playwright = "playwright", + selenium = "selenium", + cypress = "cypress", +} +export type SDKSupportedBrowserAutomationFramework = + keyof typeof SDKSupportedBrowserAutomationFrameworkEnum; + +export enum SDKSupportedTestingFrameworkEnum { + jest = "jest", + codeceptjs = "codeceptjs", + playwright = "playwright", + pytest = "pytest", + robot = "robot", + behave = "behave", + cucumber = "cucumber", + nightwatch = "nightwatch", + webdriverio = "webdriverio", + mocha = "mocha", + junit4 = "junit4", + junit5 = "junit5", + testng = "testng", + cypress = "cypress", + nunit = "nunit", + mstest = "mstest", + xunit = "xunit", + specflow = "specflow", + reqnroll = "reqnroll", + rspec = "rspec", + serenity = "serenity", +} + +export const SDKSupportedLanguages = Object.values(SDKSupportedLanguageEnum); +export type SDKSupportedTestingFramework = + keyof typeof SDKSupportedTestingFrameworkEnum; +export const SDKSupportedTestingFrameworks = Object.values( + SDKSupportedTestingFrameworkEnum, +); + +export type ConfigMapping = Partial< + Record< + SDKSupportedLanguageEnum, + Partial< + Record< + SDKSupportedBrowserAutomationFrameworkEnum, + Partial< + Record< + SDKSupportedTestingFrameworkEnum, + { + instructions: ( + username: string, + accessKey: string, + ) => { setup: string; run: string }; + } + > + > + > + > + > +>; + +// Common interfaces for instruction results +export interface RunTestsStep { + type: "instruction" | "error" | "warning"; + title: string; + content: string; + isError?: boolean; +} + +export interface RunTestsInstructionResult { + steps: RunTestsStep[]; + requiresPercy: boolean; + missingDependencies: string[]; + shouldSkipFormatting?: boolean; +} + +export enum PercyAutomateNotImplementedType { + LANGUAGE = "language", + FRAMEWORK = "framework", +} diff --git a/src/tools/sdk-utils/common/utils.ts b/src/tools/sdk-utils/common/utils.ts new file mode 100644 index 00000000..d21cbc9e --- /dev/null +++ b/src/tools/sdk-utils/common/utils.ts @@ -0,0 +1,136 @@ +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { PercyIntegrationTypeEnum } from "../common/types.js"; +import { isPercyAutomateFrameworkSupported } from "../percy-automate/frameworks.js"; +import { isPercyWebFrameworkSupported } from "../percy-web/frameworks.js"; +import { + formatInstructionsWithNumbers, + generateVerificationMessage, +} from "./formatUtils.js"; +import { + RunTestsInstructionResult, + PercyAutomateNotImplementedType, +} from "./types.js"; +import { IMPORTANT_SETUP_WARNING } from "./index.js"; + +export function checkPercyIntegrationSupport(input: { + integrationType: string; + detectedLanguage: string; + detectedTestingFramework?: string; + detectedBrowserAutomationFramework?: string; +}): { supported: boolean; errorMessage?: string } { + if (input.integrationType === PercyIntegrationTypeEnum.AUTOMATE) { + const isSupported = isPercyAutomateFrameworkSupported( + input.detectedLanguage, + input.detectedBrowserAutomationFramework || "", + input.detectedTestingFramework || "", + ); + if (!isSupported) { + return { + supported: false, + errorMessage: `Percy Automate is not supported for this configuration. Language: ${input.detectedLanguage} Testing Framework: ${input.detectedTestingFramework}`, + }; + } + } else if (input.integrationType === PercyIntegrationTypeEnum.WEB) { + const isSupported = isPercyWebFrameworkSupported( + input.detectedLanguage, + input.detectedBrowserAutomationFramework || "", + ); + if (!isSupported) { + return { + supported: false, + errorMessage: `Percy Web is not supported for this configuration. Language: ${input.detectedLanguage} Browser Automation Framework: ${input.detectedBrowserAutomationFramework}`, + }; + } + } + return { supported: true }; +} + +export async function formatToolResult( + resultPromise: Promise | RunTestsInstructionResult, + runType?: "percy-web" | "bstack" | "percy-bstack" | "percy-automate", +): Promise { + const { steps, requiresPercy, missingDependencies, shouldSkipFormatting } = + await resultPromise; + + if (shouldSkipFormatting) { + return { + content: steps.map((step) => ({ + type: "text" as const, + text: step.content, + })), + isError: steps.some((s) => s.isError), + steps, + requiresPercy, + missingDependencies, + }; + } + + const combinedInstructions = steps.map((step) => step.content).join("\n"); + const { formattedSteps, stepCount } = + formatInstructionsWithNumbers(combinedInstructions); + const verificationMessage = generateVerificationMessage(stepCount); + + const finalContent = [ + { type: "text" as const, text: IMPORTANT_SETUP_WARNING }, + { type: "text" as const, text: formattedSteps }, + { type: "text" as const, text: verificationMessage }, + ]; + + if (runType != "percy-web") { + finalContent.push({ + type: "text", + text: "CRITICAL: After the build is run, you MUST explicitly ask the user if they want to perform RCA (Root Cause Analysis) for any failed tests. This step is mandatory and should not be skipped.", + }); + } + + return { + content: finalContent, + isError: steps.some((s) => s.isError), + requiresPercy, + missingDependencies, + }; +} + +export function getPercyAutomateNotImplementedMessage( + type: PercyAutomateNotImplementedType, + input: { + detectedLanguage: string; + detectedBrowserAutomationFramework: string; + }, + supported: string[], +): string { + if (type === PercyAutomateNotImplementedType.LANGUAGE) { + return `Percy Automate does not support the language: ${input.detectedLanguage}. Supported languages are: ${supported.join(", ")}.`; + } else { + return `Percy Automate does not support ${input.detectedBrowserAutomationFramework} for ${input.detectedLanguage}. Supported frameworks for ${input.detectedLanguage} are: ${supported.join(", ")}.`; + } +} + +export function getBootstrapFailedMessage( + error: unknown, + context: { config: unknown; percyMode?: string; sdkVersion?: string }, +): string { + return `Failed to bootstrap project with BrowserStack SDK. +Error: ${error} +Percy Mode: ${context.percyMode ?? "automate"} +SDK Version: ${context.sdkVersion ?? "N/A"} +Please open an issue on GitHub if the problem persists.`; +} + +export function percyUnsupportedResult( + integrationType: PercyIntegrationTypeEnum, + supportCheck?: { errorMessage?: string }, +): CallToolResult { + const defaultMessage = `Percy ${integrationType} integration is not supported for this configuration.`; + + return { + content: [ + { + type: "text", + text: supportCheck?.errorMessage || defaultMessage, + }, + ], + isError: true, + shouldSkipFormatting: true, + }; +} diff --git a/src/tools/sdk-utils/handler.ts b/src/tools/sdk-utils/handler.ts new file mode 100644 index 00000000..e744580e --- /dev/null +++ b/src/tools/sdk-utils/handler.ts @@ -0,0 +1,163 @@ +import { + SetUpPercySchema, + RunTestsOnBrowserStackSchema, +} from "./common/schema.js"; +import { + getBootstrapFailedMessage, + percyUnsupportedResult, +} from "./common/utils.js"; +import { formatToolResult } from "./common/utils.js"; +import { BrowserStackConfig } from "../../lib/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { PercyIntegrationTypeEnum } from "./common/types.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { fetchPercyToken } from "./percy-web/fetchPercyToken.js"; +import { runPercyWeb } from "./percy-web/handler.js"; +import { runPercyAutomateOnly } from "./percy-automate/handler.js"; +import { runBstackSDKOnly } from "./bstack/sdkHandler.js"; +import { runPercyWithBrowserstackSDK } from "./percy-bstack/handler.js"; +import { checkPercyIntegrationSupport } from "./common/utils.js"; + +export async function runTestsOnBrowserStackHandler( + rawInput: unknown, + config: BrowserStackConfig, +): Promise { + try { + const input = RunTestsOnBrowserStackSchema.parse(rawInput); + + // Only handle BrowserStack SDK setup for functional/integration tests. + const result = runBstackSDKOnly(input, config); + return await formatToolResult(result); + } catch (error) { + throw new Error(getBootstrapFailedMessage(error, { config })); + } +} + +export async function setUpPercyHandler( + rawInput: unknown, + config: BrowserStackConfig, +): Promise { + try { + const input = SetUpPercySchema.parse(rawInput); + const authorization = getBrowserStackAuth(config); + + const percyInput = { + projectName: input.projectName, + detectedLanguage: input.detectedLanguage, + detectedBrowserAutomationFramework: + input.detectedBrowserAutomationFramework, + detectedTestingFramework: input.detectedTestingFramework, + integrationType: input.integrationType, + folderPaths: input.folderPaths || [], + }; + + // Check for Percy Web integration support + if (input.integrationType === PercyIntegrationTypeEnum.WEB) { + const supportCheck = checkPercyIntegrationSupport(percyInput); + if (!supportCheck.supported) { + return percyUnsupportedResult( + PercyIntegrationTypeEnum.WEB, + supportCheck, + ); + } + + // Fetch the Percy token + const percyToken = await fetchPercyToken( + input.projectName, + authorization, + { type: PercyIntegrationTypeEnum.WEB }, + ); + + const result = runPercyWeb(percyInput, percyToken); + return await formatToolResult(result, "percy-web"); + } else if (input.integrationType === PercyIntegrationTypeEnum.AUTOMATE) { + // First try Percy with BrowserStack SDK + const percyWithBrowserstackSDKResult = runPercyWithBrowserstackSDK( + { + ...percyInput, + desiredPlatforms: [], + }, + config, + ); + const hasPercySDKError = + percyWithBrowserstackSDKResult.steps && + percyWithBrowserstackSDKResult.steps.some((step) => step.isError); + + if (!hasPercySDKError) { + // Percy with SDK is supported, prepend warning and return those steps + if (percyWithBrowserstackSDKResult.steps) { + percyWithBrowserstackSDKResult.steps.unshift({ + type: "instruction" as const, + title: "Important: Existing SDK Setup", + content: + "If you have already set up the BrowserStack SDK, do not override it unless you have explicitly decided to do so.", + }); + } + return await formatToolResult(percyWithBrowserstackSDKResult); + } else { + // Fallback to standalone Percy Automate if supported + const supportCheck = checkPercyIntegrationSupport({ + ...percyInput, + integrationType: PercyIntegrationTypeEnum.AUTOMATE, + }); + if (!supportCheck.supported) { + return percyUnsupportedResult( + PercyIntegrationTypeEnum.AUTOMATE, + supportCheck, + ); + } + // SDK setup instructions (for Automate, without Percy) + const sdkInput = { + projectName: input.projectName, + detectedLanguage: input.detectedLanguage, + detectedBrowserAutomationFramework: + input.detectedBrowserAutomationFramework, + detectedTestingFramework: input.detectedTestingFramework, + desiredPlatforms: [], + }; + const sdkResult = runBstackSDKOnly(sdkInput, config, true); + // Percy Automate instructions + const percyToken = await fetchPercyToken( + input.projectName, + authorization, + { type: PercyIntegrationTypeEnum.AUTOMATE }, + ); + const percyAutomateResult = runPercyAutomateOnly( + percyInput, + percyToken, + ); + + // Combine steps: warning, SDK steps, Percy Automate steps + const steps = [ + { + type: "instruction" as const, + title: "Important: Existing SDK Setup", + content: + "If you have already set up the BrowserStack SDK, do not override it unless you have explicitly decided to do so.", + }, + ...(sdkResult.steps || []), + ...(percyAutomateResult.steps || []), + ]; + + // Combine all steps into the final result + return await formatToolResult({ + ...percyAutomateResult, + steps, + }); + } + } else { + return { + content: [ + { + type: "text", + text: "Unknown or unsupported Percy integration type requested.", + }, + ], + isError: true, + shouldSkipFormatting: true, + }; + } + } catch (error) { + throw new Error(getBootstrapFailedMessage(error, { config })); + } +} diff --git a/src/tools/sdk-utils/instructions.ts b/src/tools/sdk-utils/instructions.ts deleted file mode 100644 index d98c872c..00000000 --- a/src/tools/sdk-utils/instructions.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { SUPPORTED_CONFIGURATIONS } from "./constants.js"; -import { SDKSupportedLanguage } from "./types.js"; -import { SDKSupportedBrowserAutomationFramework } from "./types.js"; -import { SDKSupportedTestingFramework } from "./types.js"; - -const errorMessageSuffix = - "Please open an issue at our Github repo: https://github.com/browserstack/browserstack-mcp-server/issues to request support for your project configuration"; - -export const getInstructionsForProjectConfiguration = ( - detectedBrowserAutomationFramework: SDKSupportedBrowserAutomationFramework, - detectedTestingFramework: SDKSupportedTestingFramework, - detectedLanguage: SDKSupportedLanguage, - username: string, - accessKey: string, -) => { - const configuration = SUPPORTED_CONFIGURATIONS[detectedLanguage]; - - if (!configuration) { - throw new Error( - `BrowserStack MCP Server currently does not support ${detectedLanguage}, ${errorMessageSuffix}`, - ); - } - - if (!configuration[detectedBrowserAutomationFramework]) { - throw new Error( - `BrowserStack MCP Server currently does not support ${detectedBrowserAutomationFramework} for ${detectedLanguage}, ${errorMessageSuffix}`, - ); - } - - if ( - !configuration[detectedBrowserAutomationFramework][detectedTestingFramework] - ) { - throw new Error( - `BrowserStack MCP Server currently does not support ${detectedTestingFramework} for ${detectedBrowserAutomationFramework} on ${detectedLanguage}, ${errorMessageSuffix}`, - ); - } - - const instructionFunction = - configuration[detectedBrowserAutomationFramework][detectedTestingFramework] - .instructions; - - return instructionFunction(username, accessKey); -}; - -export function generateBrowserStackYMLInstructions( - desiredPlatforms: string[], - enablePercy: boolean = false, -) { - let ymlContent = ` -# ====================== -# BrowserStack Reporting -# ====================== -# Project and build names help organize your test runs in BrowserStack dashboard and Percy. -# TODO: Replace these sample values with your actual project details -projectName: Sample Project -buildName: Sample Build - -# ======================================= -# Platforms (Browsers / Devices to test) -# ======================================= -# Platforms object contains all the browser / device combinations you want to test on. -# Generate this on the basis of the following platforms requested by the user: -# Requested platforms: ${desiredPlatforms} -platforms: - - os: Windows - osVersion: 11 - browserName: chrome - browserVersion: latest - -# ======================= -# Parallels per Platform -# ======================= -# The number of parallel threads to be used for each platform set. -# BrowserStack's SDK runner will select the best strategy based on the configured value -# -# Example 1 - If you have configured 3 platforms and set \`parallelsPerPlatform\` as 2, a total of 6 (2 * 3) parallel threads will be used on BrowserStack -# -# Example 2 - If you have configured 1 platform and set \`parallelsPerPlatform\` as 5, a total of 5 (1 * 5) parallel threads will be used on BrowserStack -parallelsPerPlatform: 1 - -# ================= -# Local Testing -# ================= -# Set to true to test local -browserstackLocal: true - -# =================== -# Debugging features -# =================== -debug: true # Visual logs, text logs, etc. -testObservability: true # For Test Observability`; - - if (enablePercy) { - ymlContent += ` - -# ===================== -# Percy Visual Testing -# ===================== -# Set percy to true to enable visual testing. -# Set percyCaptureMode to 'manual' to control when screenshots are taken. -percy: true -percyCaptureMode: manual`; - } - return ` - Create a browserstack.yml file in the project root. The file should be in the following format: - - \`\`\`yaml${ymlContent} - \`\`\` - \n`; -} - -export function formatInstructionsWithNumbers( - instructionText: string, - separator: string = "---STEP---", -): string { - // Split the instructions by the separator - const steps = instructionText - .split(separator) - .map((step) => step.trim()) - .filter((step) => step.length > 0); - - // If no separators found, treat the entire text as one step - if (steps.length === 1 && !instructionText.includes(separator)) { - return `**Step 1:**\n${instructionText.trim()}\n\n**✅ Verification:**\nPlease verify that you have completed all the steps above to ensure proper setup.`; - } - - // Format each step with numbering - const formattedSteps = steps - .map((step, index) => { - return `**Step ${index + 1}:**\n${step.trim()}`; - }) - .join("\n\n"); - - // Add verification statement at the end - const verificationText = `\n\n**✅ Verification:**\nPlease verify that you have completed all ${steps.length} steps above to ensure proper setup. If you encounter any issues, double-check each step and ensure all commands executed successfully.`; - - return formattedSteps + verificationText; -} diff --git a/src/tools/sdk-utils/percy-automate/constants.ts b/src/tools/sdk-utils/percy-automate/constants.ts new file mode 100644 index 00000000..e73e7bde --- /dev/null +++ b/src/tools/sdk-utils/percy-automate/constants.ts @@ -0,0 +1,348 @@ +import { PERCY_SNAPSHOT_INSTRUCTION } from "../common/constants.js"; +export const percyAutomateReviewSnapshotsStep = ` +---STEP--- +Review the snapshots + - Go to your Percy project on https://percy.io to review snapshots and approve/reject any visual changes. +`; + +export const pythonPytestSeleniumInstructions = ` +Install Percy Automate dependencies + - Install Percy CLI: + npm install --save-dev @percy/cli + - Install Percy Python SDK for Automate: + pip install percy-selenium + +---STEP--- +Update your Pytest test script +${PERCY_SNAPSHOT_INSTRUCTION} + - Import the Percy snapshot helper: + from percy import percy_screenshot + - In your test, take snapshots at key points: + percy_screenshot(driver, "Your snapshot name") + +Example: +\`\`\`python +import pytest +from selenium import webdriver +from percy import percy_screenshot + +@pytest.fixture +def driver(): + driver = webdriver.Chrome() + yield driver + driver.quit() + +def test_homepage(driver): + driver.get("http://localhost:8000") + percy_screenshot(driver, "Home page") + # ... more test steps ... + percy_screenshot(driver, "After login") +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- browserstack-sdk pytest'). +${percyAutomateReviewSnapshotsStep} +`; + +export const pythonPytestPlaywrightInstructions = ` +Install Percy Automate dependencies + - Install Percy CLI: + npm install --save @percy/cli + - Install Percy Playwright SDK for Automate: + pip install percy-playwright + +---STEP--- +Update your Playwright test script +${PERCY_SNAPSHOT_INSTRUCTION} + - Import the Percy screenshot helper: + from percy import percy_screenshot + - In your test, take snapshots at key points: + percy_screenshot(page, name="Your snapshot name") + # You can pass \`options\`: + percy_screenshot(page, name="Your snapshot name", options={ "full_page": True }) + +Example: +\`\`\`python +from playwright.sync_api import sync_playwright +from percy import percy_screenshot + +def test_visual_regression(): + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto("http://localhost:8000") + percy_screenshot(page, name="Home page") + # ... more test steps ... + percy_screenshot(page, name="After login", options={ "full_page": True }) + browser.close() +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- '). +${percyAutomateReviewSnapshotsStep} +`; + +export const jsCypressPercyAutomateInstructions = ` +Install Percy Automate dependencies + - Install Percy CLI: + npm install --save-dev @percy/cli + - Install Percy Cypress SDK: + npm install --save-dev @percy/cypress + +---STEP--- +Update your Cypress test script +${PERCY_SNAPSHOT_INSTRUCTION} + - Import and initialize Percy in your cypress/support/index.js: + import '@percy/cypress'; + - In your test, take snapshots at key points: + cy.percySnapshot('Your snapshot name'); + +Example: +\`\`\`javascript +describe('Percy Automate Cypress Example', () => { + it('should take Percy snapshots', () => { + cy.visit('http://localhost:8000'); + cy.percySnapshot('Home page'); + // ... more test steps ... + cy.percySnapshot('After login'); + }); +}); +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- cypress run'). +${percyAutomateReviewSnapshotsStep} +`; + +export const mochaPercyAutomateInstructions = ` +Install Percy Automate dependencies + - Install Percy CLI: + npm install --save @percy/cli + - Install Percy Selenium SDK: + npm install @percy/selenium-webdriver@2.0.1 + +---STEP--- +Update your Mocha Automate test script + - Import the Percy screenshot helper: + const { percyScreenshot } = require('@percy/selenium-webdriver'); + - Use the Percy screenshot command to take required screenshots in your Automate session: + await percyScreenshot(driver, 'Screenshot 1'); + options = { percyCSS: 'h1{color:red;}' }; + await percyScreenshot(driver, 'Screenshot 2', options); + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- mocha'). +${percyAutomateReviewSnapshotsStep} +`; + +// Mocha Percy Playwright Instructions +export const mochaPercyPlaywrightInstructions = ` +Install Percy Automate dependencies + - Install the latest Percy CLI: + npm install --save @percy/cli + - Install the Percy Playwright SDK: + npm install @percy/playwright + +---STEP--- +Update your Mocha Playwright test script + - Import the Percy screenshot helper: + const { percyScreenshot } = require("@percy/playwright"); + - Use the Percy screenshot command to take required screenshots in your Automate session. + +Example: +\`\`\`javascript +const { percyScreenshot } = require("@percy/playwright"); +await percyScreenshot(page, "Screenshot 1"); +// With options +await percyScreenshot(page, "Screenshot 2", { percyCSS: "h1{color:green;}" }); +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- '). +${percyAutomateReviewSnapshotsStep} +`; + +export const jestPercyAutomateInstructions = ` +Install or upgrade the BrowserStack SDK: + - Install the SDK: + npm i -D browserstack-node-sdk@latest + - Run the setup: + npx setup --username "YOUR_USERNAME" --key "YOUR_ACCESS_KEY" + +---STEP--- +Manually capture screenshots: + 1. Import the BrowserStack Percy SDK in your test script: + const { percy } = require('browserstack-node-sdk'); + 2. Use \`percy.screenshot(driver, name)\` at desired points in your test. + +Example: +\`\`\`javascript +const { percy } = require('browserstack-node-sdk'); +describe("JestJS test", () => { + let driver; + const caps = require("../" + conf_file).capabilities; + + beforeAll(() => { + driver = new Builder() + .usingServer("http://example-servername/hub") + .withCapabilities(caps) + .build(); + }); + + test("my test", async () => { + // ... + await percy.screenshot(driver, "My Screenshot"); + // ... + }); +}); +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npm run [your-test-script-name]-browserstack'). +${percyAutomateReviewSnapshotsStep} +`; + +export const webdriverioPercyAutomateInstructions = ` +Install or upgrade BrowserStack SDK + - Install the BrowserStack SDK: + npm i -D @wdio/browserstack-service + +---STEP--- +Update your WebdriverIO config file + 1. Set \`percy: true\` + 2. Set a \`projectName\` + 3. Set \`percyCaptureMode: auto\` (or another mode as needed) + +Example WebdriverIO config: +\`\`\`js +exports.config = { + user: process.env.BROWSERSTACK_USERNAME || 'YOUR_USERNAME', + key: process.env.BROWSERSTACK_ACCESS_KEY || 'YOUR_ACCESS_KEY', + hostname: 'hub.browserstack.com', + services: [ + [ + 'browserstack', + { browserstackLocal: true, opts: { forcelocal: false }, percy: true, percyCaptureMode: 'auto' } + ], + ], + // add path to the test file +} +\`\`\` + +---STEP--- +(Optional) Manually capture screenshots + 1. Import the BrowserStack Percy SDK in your test script: + const { percy } = require('browserstack-node-sdk'); + 2. Add the \`await percy.screenshot(driver, name)\` method at required points in your test script. + +Example: +\`\`\`javascript + const { percy } = require('browserstack-node-sdk'); + 2. Add the \`await percy.screenshot(driver, name)\` method at required points in your test script. + +Example: +\`\`\`javascript +const { percy } = require('browserstack-node-sdk'); +describe("WebdriverIO Test", () => { + it("my test", async () => { + // .... + await percy.screenshot(driver, "My Screenshot") + // .... + }); +}); +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command as defined in your package.json file. +${percyAutomateReviewSnapshotsStep} +`; + +export const testcafePercyAutomateInstructions = ` +Install Percy dependencies + - Install the required dependencies: + npm install --save-dev @percy/cli @percy/testcafe + +---STEP--- +Update your test script +${PERCY_SNAPSHOT_INSTRUCTION} + - Import the Percy library and use the percySnapshot function to take screenshots. + +Example: +\`\`\`javascript +import percySnapshot from '@percy/testcafe'; +fixture('MyFixture') + .page('https://devexpress.github.io/testcafe/example/'); +test('Test1', async t => { + await t.typeText('#developer-name', 'John Doe'); + await percySnapshot(t, 'TestCafe Example'); +}); +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- testcafe chrome:headless tests'). +${percyAutomateReviewSnapshotsStep} +`; + +// Java Playwright Percy Automate Instructions +export const javaPlaywrightJunitInstructions = ` +Install Percy Automate dependencies + - Install the latest Percy CLI: + npm install --save @percy/cli + - Add the Percy Playwright Java SDK to your pom.xml: +\`\`\`xml + + io.percy + percy-playwright-java + 1.0.0 + +\`\`\` + +---STEP--- +Update your Automate test script + - Import the Percy library: + import io.percy.playwright.Percy; + - Use the Percy screenshot command to take required screenshots in your Automate session. + +Example: +\`\`\`java +Percy percy = new Percy(page); +percy.screenshot("screenshot_1"); +// With options +percy.screenshot("screenshot_2", options); +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- '). + +${percyAutomateReviewSnapshotsStep} +`; + +// C# Playwright NUnit Percy Automate Instructions +export const csharpPlaywrightNunitInstructions = ` +Install Percy Automate dependencies + - Install the latest Percy CLI: + npm install --save @percy/cli + - Add the Percy Playwright SDK to your .csproj file: +\`\`\`xml + +\`\`\` + +---STEP--- +Update your NUnit Playwright test script + - Import the Percy library: + using PercyIO.Playwright; + - Use the Percy screenshot command to take required screenshots in your Automate session. + +Example: +\`\`\`csharp +using PercyIO.Playwright; +Percy.Screenshot(page, "example_screenshot_1"); +// With options +Percy.Screenshot(page, "example_screenshot_2", options); +\`\`\` + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- '). + +${percyAutomateReviewSnapshotsStep} +`; diff --git a/src/tools/sdk-utils/percy-automate/frameworks.ts b/src/tools/sdk-utils/percy-automate/frameworks.ts new file mode 100644 index 00000000..50ba8444 --- /dev/null +++ b/src/tools/sdk-utils/percy-automate/frameworks.ts @@ -0,0 +1,56 @@ +import { ConfigMapping } from "./types.js"; +import * as instructions from "./constants.js"; + +export const SUPPORTED_CONFIGURATIONS: ConfigMapping = { + python: { + selenium: { + pytest: { + instructions: instructions.pythonPytestSeleniumInstructions, + }, + }, + playwright: { + pytest: { + instructions: instructions.pythonPytestPlaywrightInstructions, + }, + }, + }, + java: { + playwright: { + junit: { instructions: instructions.javaPlaywrightJunitInstructions }, + }, + }, + nodejs: { + selenium: { + mocha: { instructions: instructions.mochaPercyAutomateInstructions }, + jest: { instructions: instructions.jestPercyAutomateInstructions }, + webdriverio: { + instructions: instructions.webdriverioPercyAutomateInstructions, + }, + testcafe: { + instructions: instructions.testcafePercyAutomateInstructions, + }, + }, + playwright: { + mocha: { instructions: instructions.mochaPercyPlaywrightInstructions }, + jest: { instructions: instructions.jestPercyAutomateInstructions }, + }, + }, +}; + +/** + * Utility function to check if a given language, driver, and testing framework + * are supported by Percy Automate. + * This now expects the structure: language -> driver -> framework + */ +export function isPercyAutomateFrameworkSupported( + language: string, + driver: string, + framework: string, +): boolean { + const languageConfig = + SUPPORTED_CONFIGURATIONS[language as keyof typeof SUPPORTED_CONFIGURATIONS]; + if (!languageConfig) return false; + const driverConfig = languageConfig[driver as keyof typeof languageConfig]; + if (!driverConfig) return false; + return !!driverConfig[framework as keyof typeof driverConfig]; +} diff --git a/src/tools/sdk-utils/percy-automate/handler.ts b/src/tools/sdk-utils/percy-automate/handler.ts new file mode 100644 index 00000000..f5e43135 --- /dev/null +++ b/src/tools/sdk-utils/percy-automate/handler.ts @@ -0,0 +1,43 @@ +import { RunTestsInstructionResult, RunTestsStep } from "../common/types.js"; +import { SetUpPercyInput } from "../common/schema.js"; +import { SUPPORTED_CONFIGURATIONS } from "./frameworks.js"; +import { SDKSupportedLanguage } from "../common/types.js"; + +export function runPercyAutomateOnly( + input: SetUpPercyInput, + percyToken: string, +): RunTestsInstructionResult { + const steps: RunTestsStep[] = []; + + // Assume configuration is supported due to guardrails at orchestration layer + const languageConfig = + SUPPORTED_CONFIGURATIONS[input.detectedLanguage as SDKSupportedLanguage]; + const driverConfig = languageConfig[input.detectedBrowserAutomationFramework]; + const testingFrameworkConfig = driverConfig + ? driverConfig[input.detectedTestingFramework] + : undefined; + + // Generate instructions for the supported configuration with project name + const instructions = testingFrameworkConfig + ? testingFrameworkConfig.instructions + : ""; + + // Prepend a step to set the Percy token in the environment + steps.push({ + type: "instruction", + title: "Set Percy Token in Environment", + content: `Here is percy token if required {${percyToken}}`, + }); + + steps.push({ + type: "instruction", + title: `Percy Automate Setup for ${input.detectedLanguage} with ${input.detectedTestingFramework}`, + content: instructions, + }); + + return { + steps, + requiresPercy: true, + missingDependencies: [], + }; +} diff --git a/src/tools/sdk-utils/percy-automate/index.ts b/src/tools/sdk-utils/percy-automate/index.ts new file mode 100644 index 00000000..230bfcb5 --- /dev/null +++ b/src/tools/sdk-utils/percy-automate/index.ts @@ -0,0 +1,2 @@ +// Percy Automate utilities +export { runPercyAutomateOnly } from "./handler.js"; diff --git a/src/tools/sdk-utils/percy-automate/types.ts b/src/tools/sdk-utils/percy-automate/types.ts new file mode 100644 index 00000000..c1860f80 --- /dev/null +++ b/src/tools/sdk-utils/percy-automate/types.ts @@ -0,0 +1,13 @@ +/** + * Type for Percy Automate configuration mapping. + * Structure: language -> driver -> testingFramework -> { instructions: string } + */ +export type ConfigMapping = { + [language: string]: { + [driver: string]: { + [framework: string]: { + instructions: string; + }; + }; + }; +}; diff --git a/src/tools/sdk-utils/percy/constants.ts b/src/tools/sdk-utils/percy-bstack/constants.ts similarity index 72% rename from src/tools/sdk-utils/percy/constants.ts rename to src/tools/sdk-utils/percy-bstack/constants.ts index 39dea6a1..7f9da914 100644 --- a/src/tools/sdk-utils/percy/constants.ts +++ b/src/tools/sdk-utils/percy-bstack/constants.ts @@ -1,10 +1,10 @@ -import { PercyConfigMapping } from "./types.js"; +import { PERCY_SNAPSHOT_INSTRUCTION } from "../common/constants.js"; -const javaSeleniumInstructions = ` +export const javaSeleniumInstructions = ` Import the BrowserStack Percy SDK in your test script: Add the Percy import to your test file. ----STEP--- +${PERCY_SNAPSHOT_INSTRUCTION} Add screenshot capture method at required points: Use the \`PercySDK.screenshot(driver, name)\` method at points in your test script where you want to capture screenshots. @@ -35,6 +35,8 @@ export const nodejsSeleniumInstructions = ` Import the BrowserStack Percy SDK in your test script: Add the Percy import to your test file. +${PERCY_SNAPSHOT_INSTRUCTION} + ---STEP--- Add screenshot capture method at required points: @@ -47,20 +49,20 @@ describe("sample Test", () => { test("my test", async () => { // .... - await percy.snapshot(driver, "My Snapshot") + await percy.screenshot(driver, "My Snapshot") // .... }); }) \`\`\` `; -const webdriverioPercyInstructions = ` +export const webdriverioPercyInstructions = ` Enable Percy in \`wdio.conf.js\`: In your WebdriverIO configuration file, modify the 'browserstack' service options to enable Percy. - Set \`percy: true\`. - Set a \`projectName\`. This is required and will be used for both your Automate and Percy projects. -- Set \`percyCaptureMode\`. The default \`auto\` mode is recommended, which captures screenshots on events like clicks. Other modes are \`testcase\`, \`click\`, \`screenshot\`, and \`manual\`. +- Set \`percyCaptureMode\`. The default \`manual\` as we are adding screenshot commands manually. Here's how to modify the service configuration: \`\`\`javascript @@ -74,7 +76,7 @@ exports.config = { { // ... other service options percy: true, - percyCaptureMode: 'auto' // or 'manual', 'testcase', etc. + percyCaptureMode: 'manual' // or 'auto', etc. }, ], ], @@ -89,6 +91,8 @@ exports.config = { }; \`\`\` +${PERCY_SNAPSHOT_INSTRUCTION} + ---STEP--- Manually Capturing Screenshots (Optional): @@ -117,11 +121,11 @@ describe("My WebdriverIO Test", () => { \`\`\` `; -const csharpSeleniumInstructions = ` +export const csharpSeleniumInstructions = ` Import the BrowserStack Percy SDK in your test script: Add the Percy import to your test file. ----STEP--- +${PERCY_SNAPSHOT_INSTRUCTION} Add screenshot capture method at required points: Use the \`PercySDK.Screenshot(driver, name)\` method at points in your test script where you want to capture screenshots. @@ -130,8 +134,6 @@ Here's an example: \`\`\`csharp using BrowserStackSDK.Percy; -using NUnit.Framework; - namespace Tests; public class MyTest @@ -151,33 +153,3 @@ public class MyTest } \`\`\` `; - -export const PERCY_INSTRUCTIONS: PercyConfigMapping = { - java: { - selenium: { - testng: { script_updates: javaSeleniumInstructions }, - cucumber: { script_updates: javaSeleniumInstructions }, - junit4: { script_updates: javaSeleniumInstructions }, - junit5: { script_updates: javaSeleniumInstructions }, - serenity: { script_updates: javaSeleniumInstructions }, - }, - }, - csharp: { - selenium: { - nunit: { script_updates: csharpSeleniumInstructions }, - }, - }, - nodejs: { - selenium: { - mocha: { - script_updates: nodejsSeleniumInstructions, - }, - jest: { - script_updates: nodejsSeleniumInstructions, - }, - webdriverio: { - script_updates: webdriverioPercyInstructions, - }, - }, - }, -}; diff --git a/src/tools/sdk-utils/percy-bstack/frameworks.ts b/src/tools/sdk-utils/percy-bstack/frameworks.ts new file mode 100644 index 00000000..ed51557e --- /dev/null +++ b/src/tools/sdk-utils/percy-bstack/frameworks.ts @@ -0,0 +1,29 @@ +import { ConfigMapping } from "./types.js"; +import * as constants from "./constants.js"; + +export const PERCY_INSTRUCTIONS: ConfigMapping = { + java: { + selenium: { + testng: { instructions: constants.javaSeleniumInstructions }, + cucumber: { instructions: constants.javaSeleniumInstructions }, + junit4: { instructions: constants.javaSeleniumInstructions }, + junit5: { instructions: constants.javaSeleniumInstructions }, + selenide: { instructions: constants.javaSeleniumInstructions }, + jbehave: { instructions: constants.javaSeleniumInstructions }, + }, + }, + csharp: { + selenium: { + nunit: { instructions: constants.csharpSeleniumInstructions }, + xunit: { instructions: constants.csharpSeleniumInstructions }, + specflow: { instructions: constants.csharpSeleniumInstructions }, + }, + }, + nodejs: { + selenium: { + mocha: { instructions: constants.nodejsSeleniumInstructions }, + jest: { instructions: constants.nodejsSeleniumInstructions }, + webdriverio: { instructions: constants.webdriverioPercyInstructions }, + }, + }, +}; diff --git a/src/tools/sdk-utils/percy-bstack/handler.ts b/src/tools/sdk-utils/percy-bstack/handler.ts new file mode 100644 index 00000000..e0711779 --- /dev/null +++ b/src/tools/sdk-utils/percy-bstack/handler.ts @@ -0,0 +1,158 @@ +// Percy + BrowserStack SDK combined handler +import { RunTestsInstructionResult, RunTestsStep } from "../common/types.js"; +import { RunTestsOnBrowserStackInput } from "../common/schema.js"; +import { getBrowserStackAuth } from "../../../lib/get-auth.js"; +import { getSDKPrefixCommand } from "../bstack/commands.js"; +import { generateBrowserStackYMLInstructions } from "../bstack/configUtils.js"; +import { getInstructionsForProjectConfiguration } from "../common/instructionUtils.js"; +import { + formatPercyInstructions, + getPercyInstructions, +} from "./instructions.js"; +import { BrowserStackConfig } from "../../../lib/types.js"; +import { + SDKSupportedBrowserAutomationFramework, + SDKSupportedTestingFramework, + SDKSupportedLanguage, +} from "../common/types.js"; + +export function runPercyWithBrowserstackSDK( + input: RunTestsOnBrowserStackInput, + config: BrowserStackConfig, +): RunTestsInstructionResult { + const steps: RunTestsStep[] = []; + const authString = getBrowserStackAuth(config); + const [username, accessKey] = authString.split(":"); + + // Check if Percy is supported for this configuration + const percyResult = getPercyInstructions( + input.detectedLanguage as SDKSupportedLanguage, + input.detectedBrowserAutomationFramework as SDKSupportedBrowserAutomationFramework, + input.detectedTestingFramework as SDKSupportedTestingFramework, + ); + + if (!percyResult) { + // Percy not supported for this configuration + return { + steps: [ + { + type: "error", + title: "Percy Not Supported", + content: `Percy is not supported for this ${input.detectedBrowserAutomationFramework} framework configuration. Please use BrowserStack SDK only mode or try a different framework combination.`, + isError: true, + }, + ], + requiresPercy: true, + shouldSkipFormatting: true, + missingDependencies: [], + }; + } + + // Handle frameworks with unique setup instructions that don't use browserstack.yml + if ( + input.detectedBrowserAutomationFramework === "cypress" || + input.detectedTestingFramework === "webdriverio" + ) { + const frameworkInstructions = getInstructionsForProjectConfiguration( + input.detectedBrowserAutomationFramework as SDKSupportedBrowserAutomationFramework, + input.detectedTestingFramework as SDKSupportedTestingFramework, + input.detectedLanguage as SDKSupportedLanguage, + username, + accessKey, + ); + + if (frameworkInstructions && frameworkInstructions.setup) { + steps.push({ + type: "instruction", + title: "Framework-Specific Setup", + content: frameworkInstructions.setup, + }); + } + + steps.push({ + type: "instruction", + title: "Percy Setup (BrowserStack SDK + Percy)", + content: formatPercyInstructions(percyResult), + }); + + if (frameworkInstructions && frameworkInstructions.run) { + steps.push({ + type: "instruction", + title: "Run the tests", + content: frameworkInstructions.run, + }); + } + + return { + steps, + requiresPercy: true, + missingDependencies: [], + }; + } + + // Default flow using browserstack.yml with Percy + const sdkSetupCommand = getSDKPrefixCommand( + input.detectedLanguage as SDKSupportedLanguage, + input.detectedTestingFramework as SDKSupportedTestingFramework, + username, + accessKey, + ); + + if (sdkSetupCommand) { + steps.push({ + type: "instruction", + title: "Install BrowserStack SDK", + content: sdkSetupCommand, + }); + } + + const ymlInstructions = generateBrowserStackYMLInstructions( + input.desiredPlatforms as string[], + true, + input.projectName, + ); + + if (ymlInstructions) { + steps.push({ + type: "instruction", + title: "Configure browserstack.yml", + content: ymlInstructions, + }); + } + + const frameworkInstructions = getInstructionsForProjectConfiguration( + input.detectedBrowserAutomationFramework as SDKSupportedBrowserAutomationFramework, + input.detectedTestingFramework as SDKSupportedTestingFramework, + input.detectedLanguage as SDKSupportedLanguage, + username, + accessKey, + ); + + if (frameworkInstructions && frameworkInstructions.setup) { + steps.push({ + type: "instruction", + title: "Framework-Specific Setup", + content: frameworkInstructions.setup, + }); + } + + steps.push({ + type: "instruction", + title: "Percy Setup (BrowserStack SDK + Percy)", + content: formatPercyInstructions(percyResult), + }); + + if (frameworkInstructions && frameworkInstructions.run) { + steps.push({ + type: "instruction", + title: "Run the tests", + content: frameworkInstructions.run, + }); + } + + return { + steps, + requiresPercy: true, + missingDependencies: [], + }; +} diff --git a/src/tools/sdk-utils/percy-bstack/index.ts b/src/tools/sdk-utils/percy-bstack/index.ts new file mode 100644 index 00000000..6338ed2e --- /dev/null +++ b/src/tools/sdk-utils/percy-bstack/index.ts @@ -0,0 +1,8 @@ +// Percy + BrowserStack SDK utilities +export { runPercyWithBrowserstackSDK } from "./handler.js"; +export { + getPercyInstructions, + formatPercyInstructions, +} from "./instructions.js"; +export { PERCY_INSTRUCTIONS } from "./frameworks.js"; +export type { ConfigMapping } from "./types.js"; diff --git a/src/tools/sdk-utils/percy/instructions.ts b/src/tools/sdk-utils/percy-bstack/instructions.ts similarity index 60% rename from src/tools/sdk-utils/percy/instructions.ts rename to src/tools/sdk-utils/percy-bstack/instructions.ts index f642efa5..dab7941b 100644 --- a/src/tools/sdk-utils/percy/instructions.ts +++ b/src/tools/sdk-utils/percy-bstack/instructions.ts @@ -1,19 +1,17 @@ +// Percy + BrowserStack SDK instructions and utilities import { SDKSupportedBrowserAutomationFramework, SDKSupportedLanguage, SDKSupportedTestingFramework, -} from "../types.js"; -import { PERCY_INSTRUCTIONS } from "./constants.js"; -import { PercyInstructions } from "./types.js"; +} from "../common/types.js"; +import { PERCY_INSTRUCTIONS } from "./frameworks.js"; -/** - * Retrieves Percy-specific instructions for a given language and framework. - */ +// Retrieves Percy-specific instructions for a given language and framework export function getPercyInstructions( language: SDKSupportedLanguage, automationFramework: SDKSupportedBrowserAutomationFramework, testingFramework: SDKSupportedTestingFramework, -): PercyInstructions | null { +): { instructions: string } | null { const langConfig = PERCY_INSTRUCTIONS[language]; if (!langConfig) { return null; @@ -32,14 +30,12 @@ export function getPercyInstructions( return percyInstructions; } -/** - * Formats the retrieved Percy instructions into a user-friendly string. - */ -export function formatPercyInstructions( - instructions: PercyInstructions, -): string { - return `\n\n## Percy Visual Testing Setup +// Formats the retrieved Percy instructions into a user-friendly string +export function formatPercyInstructions(instructions: { + instructions: string; +}): string { + return `---STEP--- Percy Visual Testing Setup To enable visual testing with Percy, you need to make the following changes to your project configuration and test scripts. -${instructions.script_updates} +${instructions.instructions} `; } diff --git a/src/tools/sdk-utils/percy-bstack/types.ts b/src/tools/sdk-utils/percy-bstack/types.ts new file mode 100644 index 00000000..b45f9628 --- /dev/null +++ b/src/tools/sdk-utils/percy-bstack/types.ts @@ -0,0 +1,14 @@ +/** + * Type for Percy + BrowserStack SDK configuration mapping. + * Structure: language -> automationFramework -> testingFramework -> { instructions: (bsdkToken: string) => string } + */ + +export type ConfigMapping = { + [language: string]: { + [automationFramework: string]: { + [testingFramework: string]: { + instructions: string; + }; + }; + }; +}; diff --git a/src/tools/sdk-utils/percy-web/constants.ts b/src/tools/sdk-utils/percy-web/constants.ts new file mode 100644 index 00000000..44728a0f --- /dev/null +++ b/src/tools/sdk-utils/percy-web/constants.ts @@ -0,0 +1,923 @@ +import { PERCY_SNAPSHOT_INSTRUCTION } from "../common/constants.js"; +export const percyReviewSnapshotsStep = ` +---STEP--- +Review the snapshots + - Go to your Percy project on https://percy.io to review snapshots and approve/reject any visual changes. +`; + +export const pythonInstructionsSnapshot = ` +Example: +\`\`\`python +- Import the Percy snapshot helper: +from selenium import webdriver +from percy import percy_snapshot + +driver = webdriver.Chrome() +driver.get('http://localhost:8000') +percy_snapshot(driver, 'Home page') +# ... more test steps ... +percy_snapshot(driver, 'After login') +\`\`\` +`; + +export const nodejsInstructionsSnapshot = ` +- Import the Percy snapshot helper: + const { percySnapshot } = require('@percy/selenium-js'); + - In your test, take snapshots like this: + await percySnapshot(driver, "Your snapshot name"); + +Example: +\`\`\`javascript +const { Builder } = require('selenium-webdriver'); +const percySnapshot = require('@percy/selenium-webdriver'); + +const driver = await new Builder().forBrowser('chrome').build(); +await driver.get('http://localhost:8000'); +await percySnapshot(driver, 'Home page'); +\`\`\` +`; + +export const javaInstructionsSnapshot = ` + - Import the Percy snapshot helper: + import io.percy.selenium.Percy; + - In your test, take snapshots like this: + Percy percy = new Percy(driver); + percy.snapshot("Your snapshot name"); + Example: +\`\`\`java +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.chrome.ChromeDriver; +import io.percy.selenium.Percy; + +public class PercyExample { + public static void main(String[] args) { + WebDriver driver = new ChromeDriver(); + driver.get("http://localhost:8000"); + Percy percy = new Percy(driver); + percy.snapshot("Home page"); + driver.quit(); + } +} +\`\`\``; + +export const rubyInstructionsSnapshot = ` + - Require the Percy snapshot helper: + require 'percy' + - In your test, take snapshots like this: + Percy.snapshot(page, 'Your snapshot name') + +Example: +\`\`\`ruby +require 'selenium-webdriver' +require 'percy' + +driver = Selenium::WebDriver.for :chrome +driver.get('http://localhost:8000') +Percy.snapshot(driver, 'Your snapshot name') +driver.quit +\`\`\` +`; + +export const rubyCapybaraInstructionsSnapshot = ` + - In your test setup file, require percy/capybara: + require 'percy/capybara' + - In your test, take snapshots like this: + page.percy_snapshot('Capybara snapshot') + +Example: +\`\`\`ruby +require 'percy/capybara' + +describe 'my feature', type: :feature do + it 'renders the page' do + visit 'https://example.com' + page.percy_snapshot('Capybara snapshot') + end +end +\`\`\` + + - The snapshot method arguments are: + page.percy_snapshot(name[, options]) + name - The snapshot name; must be unique to each snapshot; defaults to the test title + options - See per-snapshot configuration options +`; + +export const csharpInstructionsSnapshot = ` + - Import the Percy snapshot helper: + using PercyIO.Selenium; + - In your test, take snapshots like this: + Percy.Snapshot(driver,"Your snapshot name"); + +Example: +\`\`\`csharp +using OpenQA.Selenium; +using OpenQA.Selenium.Chrome; +using PercyIO.Selenium; + +class PercyExample +{ + static void Main() + { + IWebDriver driver = new ChromeDriver(); + driver.Navigate().GoToUrl("http://localhost:8000"); + Percy.Snapshot(driver,"Empty Todo State"); + driver.Quit(); + } +} +\`\`\` +`; + +export const javaPlaywrightInstructionsSnapshot = ` + - Import the Percy library and use the snapshot method: + percy.snapshot("snapshot_1"); + - You can also pass options: + Map options = new HashMap<>(); + options.put("testCase", "Should add product to cart"); + percy.snapshot("snapshot_2", options); + +Example: +\`\`\`java +import com.microsoft.playwright.*; +import io.percy.playwright.*; + +public class PercyPlaywrightExample { + public static void main(String[] args) { + try (Playwright playwright = Playwright.create()) { + Browser browser = playwright.chromium().launch(); + Page page = browser.newPage(); + Percy percy = new Percy(page); + + page.navigate("http://localhost:8000"); + percy.snapshot("Home page"); + + // ... more test steps ... + percy.snapshot("After login"); + + browser.close(); + } + } +} +\`\`\` +`; + +export const nodejsPlaywrightInstructionsSnapshot = ` + - Import the Percy snapshot helper: + const percySnapshot = require('@percy/playwright'); + - In your test, take snapshots like this: + await percySnapshot(page, "Your snapshot name"); + +Example: +\`\`\`javascript +const { chromium } = require('playwright'); +const percySnapshot = require('@percy/playwright'); + +(async () => { + const browser = await chromium.launch(); + const page = await browser.newPage(); + await page.goto('http://example.com/', { waitUntil: 'networkidle' }); + await percySnapshot(page, 'Example Site'); + await browser.close(); +})(); +\`\`\` +`; + +export const nodejsWebdriverioInstructionsSnapshot = ` + - Import the Percy snapshot helper: + const percySnapshot = require('@percy/selenium-webdriver'); + - In your test, take snapshots like this: + await percySnapshot(driver, "Your snapshot name"); + +Example: +\`\`\`javascript +const { remote } = require('webdriverio'); +const percySnapshot = require('@percy/selenium-webdriver'); + +(async () => { + const browser = await remote({ + logLevel: 'error', + capabilities: { browserName: 'chrome' } + }); + + await browser.url('https://example.com'); + await percySnapshot(browser, 'WebdriverIO example'); + await browser.deleteSession(); +})(); +\`\`\` +`; + +export const nodejsEmberInstructionsSnapshot = ` + - Import the Percy snapshot helper: + import percySnapshot from '@percy/ember'; + - In your test, take snapshots like this: + await percySnapshot('My Snapshot'); + +Example: +\`\`\`javascript +import percySnapshot from '@percy/ember'; +describe('My ppp', () => { + // ...app setup + it('about page should look good', async () => { + await visit('/about'); + await percySnapshot('My Snapshot'); + }); +}); +\`\`\` + + - The snapshot method arguments are: + percySnapshot(name[, options]) + name - The snapshot name; must be unique to each snapshot; defaults to the test title + options - See per-snapshot configuration options +`; + +export const nodejsCypressInstructionsSnapshot = ` + - Import the Percy snapshot helper in your cypress/support/e2e.js file: + import '@percy/cypress'; + - If you’re using TypeScript, include "types": ["cypress", "@percy/cypress"] in your tsconfig.json file. + - In your test, take snapshots like this: + cy.percySnapshot(); + +Example: +\`\`\`javascript +import '@percy/cypress'; + +describe('Integration test with visual testing', function() { + it('Loads the homepage', function() { + // Load the page or perform any other interactions with the app. + cy.visit(''); + // Take a snapshot for visual diffing + cy.percySnapshot(); + }); +}); +\`\`\` + + - The snapshot method arguments are: + cy.percySnapshot([name][, options]) + name - The snapshot name; must be unique to each snapshot; defaults to the test title + options - See per-snapshot configuration options + + - For example: + cy.percySnapshot(); + cy.percySnapshot('Homepage test'); + cy.percySnapshot('Homepage responsive test', { widths: [768, 992, 1200] }); +`; + +export const nodejsPuppeteerInstructionsSnapshot = ` + - Import the Percy snapshot helper: + const percySnapshot = require('@percy/puppeteer'); + - In your test, take snapshots like this: + await percySnapshot(page, 'Snapshot name'); + +Example: +\`\`\`javascript +const puppeteer = require('puppeteer'); +const percySnapshot = require('@percy/puppeteer'); + +describe('Integration test with visual testing', function() { + it('Loads the homepage', async function() { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto('https://example.com'); + await percySnapshot(page, this.test.fullTitle()); + await browser.close(); + }); +}); +\`\`\` + + - The snapshot method arguments are: + percySnapshot(page, name[, options]) + page (required) - A puppeteer page instance + name (required) - The snapshot name; must be unique to each snapshot + options - See per-snapshot configuration options + + - For example: + percySnapshot(page, 'Homepage test'); + percySnapshot(page, 'Homepage responsive test', { widths: [768, 992, 1200] }); +`; + +export const nodejsNightmareInstructionsSnapshot = ` + - Import the Percy snapshot helper: + const Nightmare = require('nightmare'); + const percySnapshot = require('@percy/nightmare'); + - In your test, take snapshots like this: + .use(percySnapshot('Snapshot name')) + +Example: +\`\`\`javascript +const Nightmare = require('nightmare'); +const percySnapshot = require('@percy/nightmare'); + +Nightmare() + .goto('http://example.com') + // ... other actions ... + .use(percySnapshot('Example Snapshot')) + // ... more actions ... + .end() + .then(() => { + // ... + }); +\`\`\` + + - The snapshot method arguments are: + percySnapshot(name[, options]) + name (required) - The snapshot name; must be unique to each snapshot + options - See per-snapshot configuration options +`; + +export const nodejsNightwatchInstructionsSnapshot = ` + - Import the Percy library and add the path exported by @percy/nightwatch to your Nightwatch configuration’s custom_commands_path property: + const percy = require('@percy/nightwatch'); + module.exports = { + // ... + custom_commands_path: [percy.path], + // ... + }; + - In your test, take snapshots like this: + browser.percySnapshot('Snapshot name'); + +Example: +\`\`\`javascript +const percy = require('@percy/nightwatch'); +module.exports = { + // ... + custom_commands_path: [percy.path], + // ... +}; + +// Example test +module.exports = { + 'Snapshots pages': function(browser) { + browser + .url('http://example.com') + .assert.containsText('h1', 'Example Domain') + .percySnapshot('Example snapshot'); + browser + .url('http://google.com') + .assert.elementPresent('img[alt="Google"]') + .percySnapshot('Google homepage'); + browser.end(); + } +}; +\`\`\` + + - The snapshot method arguments are: + percySnapshot([name][, options]) + name (required) - The snapshot name; must be unique to each snapshot + options - See per-snapshot configuration options +`; + +export const nodejsProtractorInstructionsSnapshot = ` + - Import the Percy snapshot helper: + import percySnapshot from '@percy/protractor'; + - In your test, take snapshots like this: + await percySnapshot('Snapshot name'); + // or + await percySnapshot(browser, 'Snapshot name'); + +Example: +\`\`\`javascript +import percySnapshot from '@percy/protractor'; +describe('angularjs homepage', function() { + it('should greet the named user', async function() { + await browser.get('https://www.angularjs.org'); + await percySnapshot('AngularJS homepage'); + await element(by.model('yourName')).sendKeys('Percy'); + var greeting = element(by.binding('yourName')); + expect(await greeting.getText()).toEqual('Hello Percy!'); + await percySnapshot('AngularJS homepage greeting'); + }); +}); +\`\`\` + + - The snapshot method arguments are: + percySnapshot(name[, options]) + Standalone mode: + percySnapshot(browser, name[, options]) + browser (required) - The Protractor browser object + name (required) - The snapshot name; must be unique to each snapshot + options - See per-snapshot configuration options +`; + +export const nodejsTestcafeInstructionsSnapshot = ` + - Import the Percy snapshot helper: + import percySnapshot from '@percy/testcafe'; + - In your test, take snapshots like this: + await percySnapshot(t, 'Snapshot name'); + +Example: +\`\`\`javascript +import percySnapshot from '@percy/testcafe'; +fixture('MyFixture') + .page('https://devexpress.github.io/testcafe/example'); +test('Test1', async t => { + await t.typeText('#developer-name', 'John Doe'); + await percySnapshot(t, 'TestCafe Example'); +}); +\`\`\` + + - The snapshot method arguments are: + percySnapshot(t, name[, options]) + t (required) - The test controller instance passed from test + name (required) - The snapshot name; must be unique to each snapshot + options - See per-snapshot configuration options +`; + +export const nodejsGatsbyInstructionsSnapshot = ` + - Add the Percy plugin to your gatsby-config.js file: + module.exports = { + plugins: [\`gatsby-plugin-percy\`] + } + + - The plugin will take snapshots of discovered pages during the build process. + + - Example gatsby-config.js with options: +\`\`\`javascript +module.exports = { + plugins: [{ + resolve: \`gatsby-plugin-percy\`, + options: { + // gatsby specific options + query: \`{ + allSitePage { nodes { path } } + allOtherPage { nodes { path } } + }\`, + resolvePages: ({ + allSitePage: { nodes: allPages }, + allOtherPage: { nodes: otherPages } + }) => { + return [...allPages, ...otherPages] + .map(({ path }) => path); + }, + // percy static snapshot options + exclude: [ + '/dev-404-page/', + '/offline-plugin-app-shell-fallback/' + ], + overrides: [{ + include: '/foobar/', + waitForSelector: '.done-loading', + additionalSnapshots: [{ + suffix: ' - after btn click', + execute: () => document.querySelector('.btn').click() + }] + }] + } + }] +} +\`\`\` +`; + +export const nodejsStorybookInstructionsSnapshot = ` + - Add Percy parameters to your stories to customize snapshots: +\`\`\`js +MyStory.parameters = { + percy: { + name: 'My snapshot', + additionalSnapshots: [ + { prefix: '[Dark mode] ', args: { colorScheme: 'dark' } }, + { suffix: ' with globals', globals: { textDirection: 'rtl' } }, + { name: 'Search snapshot', queryParams: { search: 'foobar' } } + ] + } +}; +\`\`\` + - Use argument names and values defined in your codebase. +`; + +export const pythonPlaywrightInstructionsSnapshot = ` + - Import the Percy snapshot helper and use the snapshot method: + percy_snapshot(page, name="Your snapshot name") + - You can also use: + percy_screenshot(page, name="Your snapshot name", options={}) + +Example: +\`\`\`python +from playwright.sync_api import sync_playwright +from percy import percy_snapshot + +with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto("http://localhost:8000") + percy_snapshot(page, name="Home page") + + # ... more test steps ... + percy_snapshot(page, name="After login") + + browser.close() +\`\`\` +`; + +export const csharpPlaywrightInstructionsSnapshot = ` + - Import the Percy snapshot helper and use the snapshot method: + Percy.Snapshot(page, "Your snapshot name"); + - You can also pass options: + Percy.Snapshot(page, "Your snapshot name", options); + +Example: +\`\`\`csharp +using Microsoft.Playwright; +using PercyIO.Playwright; + +class PercyPlaywrightExample +{ + public static async Task Main() + { + using var playwright = await Playwright.CreateAsync(); + var browser = await playwright.Chromium.LaunchAsync(); + var page = await browser.NewPageAsync(); + + await page.GotoAsync("http://localhost:8000"); + Percy.Snapshot(page, "Home page"); + + // ... more test steps ... + Percy.Snapshot(page, "After login"); + + await browser.CloseAsync(); + } +} +\`\`\` +`; + +export const pythonInstructions = ` +Install Percy dependencies + - Install Percy CLI: + npm install --save-dev @percy/cli + - Install Percy Selenium Python package: + pip install percy-selenium +If faced any issue create a virtual environment and proceed. +Update your Python Selenium script +${PERCY_SNAPSHOT_INSTRUCTION} +${pythonInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- python tests.py'). +${percyReviewSnapshotsStep} +`; + +export const nodejsInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI: + npm install --save-dev @percy/cli + - Install Percy SDK for Node.js: + npm install @percy/selenium-webdriver +---STEP--- +Update your Node.js Selenium script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- node script.js'). + +${percyReviewSnapshotsStep} +`; + +export const javaInstructions = ` +---STEP--- +Add Percy dependencies to your project + - For Maven, add to your pom.xml: + + io.percy + percy-java-selenium + 1.0.0 + + - For Gradle, add to your build.gradle: + implementation 'io.percy:percy-java-selenium:1.0.0' + - For CLI usage, install Percy CLI: + npm install --save-dev @percy/cli + +---STEP--- +Update your Java Selenium test +${PERCY_SNAPSHOT_INSTRUCTION} +${javaInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- mvn test'). + +${percyReviewSnapshotsStep} +`; + +export const rubyInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI: + npm install --save-dev @percy/cli + - Install Percy Ruby Selenium gem: + gem install percy-selenium + +---STEP--- +Update your Ruby Selenium test +${PERCY_SNAPSHOT_INSTRUCTION} +${rubyInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- bundle exec rspec'). + +${percyReviewSnapshotsStep} +`; + +// Percy Capybara instructions for Ruby +export const rubyCapybaraInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI: + npm install --save-dev @percy/cli + - Install Percy Capybara gem: + gem install percy-capybara + +---STEP--- +Update your Capybara or Rails test script +${PERCY_SNAPSHOT_INSTRUCTION} +${rubyCapybaraInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- bundle exec rspec'). + +${percyReviewSnapshotsStep} +`; + +export const csharpInstructions = ` +Install Percy CLI by running the following command: +npm install --save-dev @percy/cli + +---STEP--- +Add Percy dependencies to your project + - Add the Percy .NET Selenium NuGet package: + dotnet add package PercyIO.Selenium + +---STEP--- +Update your C# Selenium test +${PERCY_SNAPSHOT_INSTRUCTION} +${csharpInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- dotnet test'). + +${percyReviewSnapshotsStep} +`; + +export const javaPlaywrightInstructions = ` +Install Percy dependencies + - For Maven, add to your pom.xml: + + io.percy + percy-playwright-java + 1.0.0 + + +---STEP--- +Update your Java Playwright test +${PERCY_SNAPSHOT_INSTRUCTION} +${javaPlaywrightInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g. npx percy exec -- ). + +${percyReviewSnapshotsStep} +`; + +export const nodejsPlaywrightInstructions = ` +Install Percy dependencies + - Install Percy Playwright SDK: + npm install @percy/playwright + +---STEP--- +Update your Playwright JavaScript test +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsPlaywrightInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., npx percy exec -- ). +${percyReviewSnapshotsStep} +`; + +// Percy WebdriverIO instructions for JavaScript +export const nodejsWebdriverioInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI: + npm install --save-dev @percy/cli + - Install Percy Selenium Webdriver package: + npm install --save-dev @percy/selenium-webdriver + +---STEP--- +Update your WebdriverIO test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsWebdriverioInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- wdio run wdio.conf.js'). + +${percyReviewSnapshotsStep} +`; + +// Percy Ember instructions for JavaScript +export const nodejsEmberInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Ember SDK: + npm install --save-dev @percy/cli @percy/ember + +---STEP--- +Update your Ember test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsEmberInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- ember test'). + +${percyReviewSnapshotsStep} +`; + +// Percy Cypress instructions for JavaScript +export const nodejsCypressInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Cypress SDK: + npm install --save-dev @percy/cli @percy/cypress + +---STEP--- +Update your Cypress test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsCypressInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- cypress run'). + +${percyReviewSnapshotsStep} +`; + +// Percy Puppeteer instructions for JavaScript +export const nodejsPuppeteerInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Puppeteer SDK: + npm install --save-dev @percy/cli @percy/puppeteer + +---STEP--- +Update your Puppeteer test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsPuppeteerInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- '). + +${percyReviewSnapshotsStep} +`; + +// Percy Nightmare instructions for JavaScript +export const nodejsNightmareInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Nightmare SDK: + npm install --save-dev @percy/cli @percy/nightmare + +---STEP--- +Update your Nightmare test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsNightmareInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- node script.js'). + +${percyReviewSnapshotsStep} +`; + +// Percy Nightwatch instructions for JavaScript +export const nodejsNightwatchInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Nightwatch SDK: + npm install --save-dev @percy/cli @percy/nightwatch + +---STEP--- +Update your Nightwatch configuration and test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsNightwatchInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- nightwatch'). + +${percyReviewSnapshotsStep} +`; + +// Percy Protractor instructions for JavaScript +export const nodejsProtractorInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Protractor SDK: + npm install --save-dev @percy/cli @percy/protractor + +---STEP--- +Update your Protractor test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsProtractorInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- protractor conf.js'). + +${percyReviewSnapshotsStep} +`; + +// Percy TestCafe instructions for JavaScript +export const nodejsTestcafeInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and TestCafe SDK: + npm install --save-dev @percy/cli @percy/testcafe + +---STEP--- +Update your TestCafe test script +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsTestcafeInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- testcafe chrome:headless tests'). +${percyReviewSnapshotsStep} +`; + +// Percy Gatsby instructions for JavaScript +export const nodejsGatsbyInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Gatsby plugin: + npm install --save @percy/cli gatsby-plugin-percy + +---STEP--- +Update your Gatsby configuration +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsGatsbyInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g., 'npx percy exec -- gatsby build'). +${percyReviewSnapshotsStep} +`; + +// Percy Storybook instructions for JavaScript +export const nodejsStorybookInstructions = ` +---STEP--- +Install Percy dependencies + - Install Percy CLI and Storybook SDK: + npm install --save-dev @percy/cli @percy/storybook + +---STEP--- +Update your Storybook stories +${PERCY_SNAPSHOT_INSTRUCTION} +${nodejsStorybookInstructionsSnapshot} + +---STEP--- +Run Percy with your Storybook + - With a static Storybook build: + percy storybook ./storybook-build + - With a local or live Storybook URL: + percy storybook http://localhost:9009 + percy storybook https://storybook.foobar.com + - Automatically run start-storybook: + Run this scan using tool runPercyScan with 'npx percy exec -- percy storybook:start --port=9009'. + +${percyReviewSnapshotsStep} +`; + +export const pythonPlaywrightInstructions = ` +---STEP--- +Create a Percy project + - Sign in to Percy and create a project of type "Web". Name the project and note the generated token. + +---STEP--- +Set the project token as an environment variable + - On macOS/Linux: + export PERCY_TOKEN="" + - On Windows PowerShell: + $env:PERCY_TOKEN="" + - On Windows CMD: + set PERCY_TOKEN= + +---STEP--- +Install Percy dependencies + - Install Percy Playwright SDK: + pip install percy-playwright + +---STEP--- +Update your Playwright Python test +${PERCY_SNAPSHOT_INSTRUCTION} +${pythonPlaywrightInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g. npx percy exec -- ). +${percyReviewSnapshotsStep} +`; + +export const csharpPlaywrightInstructions = ` +Install Percy dependencies + - Add the Percy Playwright NuGet package: + + +---STEP--- +Update your Playwright .NET test +${PERCY_SNAPSHOT_INSTRUCTION} +${csharpPlaywrightInstructionsSnapshot} + +---STEP--- +To run the Percy build, call the tool runPercyScan with the appropriate test command (e.g. npx percy exec -- ). +${percyReviewSnapshotsStep} +`; diff --git a/src/tools/sdk-utils/percy-web/fetchPercyToken.ts b/src/tools/sdk-utils/percy-web/fetchPercyToken.ts new file mode 100644 index 00000000..b6ed135f --- /dev/null +++ b/src/tools/sdk-utils/percy-web/fetchPercyToken.ts @@ -0,0 +1,55 @@ +import { PercyIntegrationTypeEnum } from "../common/types.js"; + +let globalPercyToken: string | null = null; +let globalProjectName: string | null = null; + +async function fetchTokenFromAPI( + projectName: string, + authorization: string, + options: { type?: PercyIntegrationTypeEnum } = {}, +): Promise { + const authHeader = `Basic ${Buffer.from(authorization).toString("base64")}`; + const baseUrl = + "https://api.browserstack.com/api/app_percy/get_project_token"; + const params = new URLSearchParams({ name: projectName }); + + if (options.type) { + params.append("type", options.type); + } + + const url = `${baseUrl}?${params.toString()}`; + const response = await fetch(url, { headers: { Authorization: authHeader } }); + + if (!response.ok) { + throw new Error(`Failed to fetch Percy token (status: ${response.status})`); + } + + const data = await response.json(); + + if (!data?.token || !data?.success) { + throw new Error( + "Project exists but is likely set up for Automate. Please use a different project name.", + ); + } + + return data.token; +} + +export async function fetchPercyToken( + projectName: string, + authorization: string, + options: { type?: PercyIntegrationTypeEnum } = {}, +): Promise { + if (globalProjectName !== projectName) { + globalProjectName = projectName; + globalPercyToken = null; + } + + if (globalPercyToken) { + return globalPercyToken; + } + + const token = await fetchTokenFromAPI(projectName, authorization, options); + globalPercyToken = token; + return token; +} diff --git a/src/tools/sdk-utils/percy-web/frameworks.ts b/src/tools/sdk-utils/percy-web/frameworks.ts new file mode 100644 index 00000000..4484eed9 --- /dev/null +++ b/src/tools/sdk-utils/percy-web/frameworks.ts @@ -0,0 +1,109 @@ +import { ConfigMapping } from "./types.js"; +import * as constants from "./constants.js"; + +export const SUPPORTED_CONFIGURATIONS: ConfigMapping = { + python: { + selenium: { + instructions: constants.pythonInstructions, + snapshotInstruction: constants.pythonInstructionsSnapshot, + }, + playwright: { + instructions: constants.pythonPlaywrightInstructions, + snapshotInstruction: constants.pythonPlaywrightInstructionsSnapshot, + }, + }, + nodejs: { + selenium: { + instructions: constants.nodejsInstructions, + snapshotInstruction: constants.nodejsInstructionsSnapshot, + }, + playwright: { + instructions: constants.nodejsPlaywrightInstructions, + snapshotInstruction: constants.nodejsPlaywrightInstructionsSnapshot, + }, + webdriverio: { + instructions: constants.nodejsWebdriverioInstructions, + snapshotInstruction: constants.nodejsWebdriverioInstructionsSnapshot, + }, + ember: { + instructions: constants.nodejsEmberInstructions, + snapshotInstruction: constants.nodejsEmberInstructionsSnapshot, + }, + cypress: { + instructions: constants.nodejsCypressInstructions, + snapshotInstruction: constants.nodejsCypressInstructionsSnapshot, + }, + puppeteer: { + instructions: constants.nodejsPuppeteerInstructions, + snapshotInstruction: constants.nodejsPuppeteerInstructionsSnapshot, + }, + nightmare: { + instructions: constants.nodejsNightmareInstructions, + snapshotInstruction: constants.nodejsNightmareInstructionsSnapshot, + }, + nightwatch: { + instructions: constants.nodejsNightwatchInstructions, + snapshotInstruction: constants.nodejsNightwatchInstructionsSnapshot, + }, + protractor: { + instructions: constants.nodejsProtractorInstructions, + snapshotInstruction: constants.nodejsProtractorInstructionsSnapshot, + }, + testcafe: { + instructions: constants.nodejsTestcafeInstructions, + snapshotInstruction: constants.nodejsTestcafeInstructionsSnapshot, + }, + gatsby: { + instructions: constants.nodejsGatsbyInstructions, + snapshotInstruction: constants.nodejsGatsbyInstructionsSnapshot, + }, + storybook: { + instructions: constants.nodejsStorybookInstructions, + snapshotInstruction: constants.nodejsStorybookInstructionsSnapshot, + }, + }, + java: { + selenium: { + instructions: constants.javaInstructions, + snapshotInstruction: constants.javaInstructionsSnapshot, + }, + playwright: { + instructions: constants.javaPlaywrightInstructions, + snapshotInstruction: constants.javaPlaywrightInstructionsSnapshot, + }, + }, + ruby: { + selenium: { + instructions: constants.rubyInstructions, + snapshotInstruction: constants.rubyInstructionsSnapshot, + }, + capybara: { + instructions: constants.rubyCapybaraInstructions, + snapshotInstruction: constants.rubyCapybaraInstructionsSnapshot, + }, + }, + csharp: { + selenium: { + instructions: constants.csharpInstructions, + snapshotInstruction: constants.csharpInstructionsSnapshot, + }, + playwright: { + instructions: constants.csharpPlaywrightInstructions, + snapshotInstruction: constants.csharpPlaywrightInstructionsSnapshot, + }, + }, +}; + +/** + * Utility function to check if a given language and testing framework + * are supported by Percy Web. + */ +export function isPercyWebFrameworkSupported( + language: string, + framework: string, +): boolean { + const languageConfig = + SUPPORTED_CONFIGURATIONS[language as keyof typeof SUPPORTED_CONFIGURATIONS]; + if (!languageConfig) return false; + return !!languageConfig[framework as keyof typeof languageConfig]; +} diff --git a/src/tools/sdk-utils/percy-web/handler.ts b/src/tools/sdk-utils/percy-web/handler.ts new file mode 100644 index 00000000..bca09687 --- /dev/null +++ b/src/tools/sdk-utils/percy-web/handler.ts @@ -0,0 +1,49 @@ +// Handler for Percy Web only mode - Visual testing without BrowserStack infrastructure +import { RunTestsInstructionResult, RunTestsStep } from "../common/types.js"; +import { SetUpPercyInput } from "../common/schema.js"; +import { SUPPORTED_CONFIGURATIONS } from "./frameworks.js"; + +import { + SDKSupportedBrowserAutomationFramework, + SDKSupportedLanguage, +} from "../common/types.js"; + +export let percyWebSetupInstructions = ""; + +export function runPercyWeb( + input: SetUpPercyInput, + percyToken: string, +): RunTestsInstructionResult { + const steps: RunTestsStep[] = []; + + // Assume configuration is supported due to guardrails at orchestration layer + const languageConfig = + SUPPORTED_CONFIGURATIONS[input.detectedLanguage as SDKSupportedLanguage]; + const frameworkConfig = + languageConfig[ + input.detectedBrowserAutomationFramework as SDKSupportedBrowserAutomationFramework + ]; + + // Generate instructions for the supported configuration + const instructions = frameworkConfig.instructions; + percyWebSetupInstructions = frameworkConfig.snapshotInstruction; + + // Prepend a step to set the Percy token in the environment + steps.push({ + type: "instruction", + title: "Set Percy Token in Environment", + content: `Here is percy token if required {${percyToken}}`, + }); + + steps.push({ + type: "instruction", + title: `Percy Web Setup Instructions`, + content: instructions, + }); + + return { + steps, + requiresPercy: true, + missingDependencies: [], + }; +} diff --git a/src/tools/sdk-utils/percy-web/index.ts b/src/tools/sdk-utils/percy-web/index.ts new file mode 100644 index 00000000..6dd10c6b --- /dev/null +++ b/src/tools/sdk-utils/percy-web/index.ts @@ -0,0 +1,5 @@ +// Percy Web utilities +export { runPercyWeb } from "./handler.js"; +export { SUPPORTED_CONFIGURATIONS } from "./frameworks.js"; +export * as constants from "./constants.js"; +export type { ConfigMapping } from "./types.js"; diff --git a/src/tools/sdk-utils/percy-web/types.ts b/src/tools/sdk-utils/percy-web/types.ts new file mode 100644 index 00000000..0445800a --- /dev/null +++ b/src/tools/sdk-utils/percy-web/types.ts @@ -0,0 +1,12 @@ +/** + * Type for Percy Web configuration mapping. + * Structure: language -> automationFramework -> { instructions: string } + */ +export type ConfigMapping = { + [language: string]: { + [automationFramework: string]: { + instructions: string; + snapshotInstruction: string; + }; + }; +}; diff --git a/src/tools/sdk-utils/percy/types.ts b/src/tools/sdk-utils/percy/types.ts deleted file mode 100644 index 1ddd464f..00000000 --- a/src/tools/sdk-utils/percy/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - SDKSupportedBrowserAutomationFramework, - SDKSupportedLanguage, - SDKSupportedTestingFramework, -} from "../types.js"; - -export interface PercyInstructions { - script_updates: string; -} - -export type PercyConfigMapping = Partial< - Record< - SDKSupportedLanguage, - Partial< - Record< - SDKSupportedBrowserAutomationFramework, - Partial> - > - > - > ->; diff --git a/src/tools/sdk-utils/types.ts b/src/tools/sdk-utils/types.ts deleted file mode 100644 index caba6eaa..00000000 --- a/src/tools/sdk-utils/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -export enum SDKSupportedLanguageEnum { - nodejs = "nodejs", - python = "python", - java = "java", - csharp = "csharp", -} -export type SDKSupportedLanguage = keyof typeof SDKSupportedLanguageEnum; - -export enum SDKSupportedBrowserAutomationFrameworkEnum { - playwright = "playwright", - selenium = "selenium", - cypress = "cypress", - webdriverio = "webdriverio", -} -export type SDKSupportedBrowserAutomationFramework = - keyof typeof SDKSupportedBrowserAutomationFrameworkEnum; - -export enum SDKSupportedTestingFrameworkEnum { - jest = "jest", - codeceptjs = "codeceptjs", - playwright = "playwright", - pytest = "pytest", - robot = "robot", - behave = "behave", - cucumber = "cucumber", - nightwatch = "nightwatch", - webdriverio = "webdriverio", - mocha = "mocha", - junit4 = "junit4", - junit5 = "junit5", - testng = "testng", - serenity = "serenity", - cypress = "cypress", - nunit = "nunit", - mstest = "mstest", - xunit = "xunit", - specflow = "specflow", - reqnroll = "reqnroll", -} -export type SDKSupportedTestingFramework = - keyof typeof SDKSupportedTestingFrameworkEnum; - -export type ConfigMapping = Record< - SDKSupportedLanguageEnum, - Partial< - Record< - SDKSupportedBrowserAutomationFrameworkEnum, - Partial< - Record< - SDKSupportedTestingFrameworkEnum, - { instructions: (username: string, accessKey: string) => string } - > - > - > - > ->;