UNPKG

@anguske/mcp-playwright-visual-test

Version:

UI Visual Comparison Testing tool - visual regression testing tool based on Playwright

322 lines (321 loc) 13.1 kB
import { McpServer, ResourceTemplate, } from "@modelcontextprotocol/sdk/server/mcp.js"; import { chromium } from "@playwright/test"; import { PNG } from "pngjs"; import pixelmatch from "pixelmatch"; import fs from "fs"; import path from "path"; import { z } from "zod"; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Create screenshots directory const getProjectRoot = () => { // Try to get project root from environment variable const projectRoot = process.env.PROJECT_ROOT; if (projectRoot) { return projectRoot; } // If environment variable is not set, try to derive from current file path const currentDir = __dirname; // If in node_modules, search upwards until project root is found if (currentDir.includes('node_modules')) { const parts = currentDir.split(path.sep); const nodeModulesIndex = parts.indexOf('node_modules'); return parts.slice(0, nodeModulesIndex).join(path.sep); } // If in dist directory, return parent directory return path.dirname(currentDir); }; const projectRoot = getProjectRoot(); const screenshotsDir = path.join(projectRoot, "screenshots"); if (!fs.existsSync(screenshotsDir)) { fs.mkdirSync(screenshotsDir, { recursive: true }); } // Login function async function login(page, loginConfig) { if (!loginConfig) return; await page.goto(loginConfig.url); await page.waitForSelector(loginConfig.usernameSelector); await page.waitForSelector(loginConfig.passwordSelector); await page.waitForSelector(loginConfig.submitSelector); await page.fill(loginConfig.usernameSelector, loginConfig.username); await page.fill(loginConfig.passwordSelector, loginConfig.password); await page.click(loginConfig.submitSelector); if (loginConfig.successSelector) { await page.waitForSelector(loginConfig.successSelector); } else { await page.waitForNavigation(); } } // Auto login function async function autoLogin(page, config) { if (!config) return false; try { // Wait for login form elements to appear await page.waitForSelector(config.usernameSelector); await page.waitForSelector(config.passwordSelector); await page.waitForSelector(config.submitSelector); // Enter username and password await page.fill(config.usernameSelector, config.username); await page.fill(config.passwordSelector, config.password); // Click login button await page.click(config.submitSelector); // Wait for login success if (config.successSelector) { await page.waitForSelector(config.successSelector); } else { await page.waitForNavigation(); } return true; } catch (error) { console.error("Auto login failed:", error); return false; } } // Check if redirected to login page async function checkLoginRedirect(page, config) { if (!config.autoLogin) return false; const currentUrl = page.url(); const loginUrlPattern = new RegExp(config.autoLogin.loginUrlPattern || /login|signin|auth/i); if (loginUrlPattern.test(currentUrl)) { console.log("Detected login page redirect, attempting auto login..."); const loginSuccess = await autoLogin(page, config.autoLogin); if (loginSuccess) { // Login successful, revisiting target page console.log("Login successful, revisiting target page"); await page.goto(config.url); await page.waitForLoadState("networkidle"); return true; } } return false; } // Perform visual comparison test async function runVisualTest(config) { const browser = await chromium.launch(); const page = await browser.newPage(); try { // Set viewport size const defaultViewport = { width: 1280, height: 720 }; await page.setViewportSize({ width: config.viewport?.width ?? defaultViewport.width, height: config.viewport?.height ?? defaultViewport.height }); // If need to login, do login first if (config.login) { await login(page, config.login); } // Access target page await page.goto(config.url); await page.waitForLoadState("networkidle"); // Check if redirected to login page await checkLoginRedirect(page, config); // Wait for specified element to appear if (config.waitForSelector) { await page.waitForSelector(config.waitForSelector); } // Wait for specified time if (config.waitForTimeout) { await page.waitForTimeout(config.waitForTimeout); } // Hide elements to ignore if (config.ignoreSelectors?.length) { await page.evaluate((selectors) => { selectors.forEach((selector) => { const elements = document.querySelectorAll(selector); elements.forEach((el) => { el.style.visibility = "hidden"; }); }); }, config.ignoreSelectors); } // Get page screenshot const screenshot = await page.screenshot({ fullPage: !config.selector, type: "png", ...(config.selector ? { selector: config.selector } : {}), }); console.log("Successfully captured page screenshot"); // Save current screenshot const currentScreenshotPath = path.join(screenshotsDir, "current.png"); fs.writeFileSync(currentScreenshotPath, screenshot); // Baseline screenshot path const baselineScreenshotPath = path.join(screenshotsDir, "baseline.png"); // Prioritize checking if baseline image already exists if (fs.existsSync(baselineScreenshotPath)) { console.log("Using existing baseline image", baselineScreenshotPath); } // If provided baseline image path, read from that path else if (config.baselineImagePath) { try { const baselineBuffer = fs.readFileSync(config.baselineImagePath); fs.writeFileSync(baselineScreenshotPath, baselineBuffer); console.log("Successfully read baseline image from specified path", baselineScreenshotPath); } catch (error) { console.error("Failed to read baseline image from specified path:", error); return { success: false, error: `Failed to read baseline image: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } // If provided baseline image data else if (config.baselineImage) { let baselineBuffer; if (Buffer.isBuffer(config.baselineImage)) { baselineBuffer = config.baselineImage; } else { baselineBuffer = Buffer.from(config.baselineImage, "base64"); } fs.writeFileSync(baselineScreenshotPath, baselineBuffer); console.log("Successfully saved baseline image data"); } // If baseline screenshot does not exist, use current screenshot as baseline else { fs.copyFileSync(currentScreenshotPath, baselineScreenshotPath); console.log("Created new baseline image"); return { success: true, message: "New baseline screenshot created", baselineCreated: true, }; } // Read baseline screenshot and current screenshot const baseline = PNG.sync.read(fs.readFileSync(baselineScreenshotPath)); const current = PNG.sync.read(screenshot); // Check if image dimensions match if (baseline.width !== current.width || baseline.height !== current.height) { fs.copyFileSync(currentScreenshotPath, baselineScreenshotPath); return { success: true, message: "Baseline screenshot updated", baselineUpdated: true, }; } // Create difference image const { width, height } = baseline; const diff = new PNG({ width, height }); const numDiffPixels = pixelmatch(baseline.data, current.data, diff.data, width, height, { threshold: config.threshold ? config.threshold / 100 : 0.1 }); // Save difference image fs.writeFileSync(path.join(screenshotsDir, "diff.png"), PNG.sync.write(diff)); console.log("Created difference image successfully"); return { success: true, message: "Created difference image successfully", diffPixels: numDiffPixels, threshold: config.threshold || 100, passed: numDiffPixels < (config.threshold || 100), }; } catch (error) { console.error("Visual comparison test failed:", error); return { success: false, error: error instanceof Error ? error.message : "Unknown error", }; } finally { await browser.close(); } } // Create MCP server const server = new McpServer({ name: "visual-test", description: "UI visual comparison test tool", version: "1.0.0", }); // Add visual test tool server.tool("playwright-ui-test", { url: z.string(), selector: z.string().optional(), waitForSelector: z.string().optional(), waitForTimeout: z.number().optional(), threshold: z.number().optional(), ignoreSelectors: z.array(z.string()).optional(), viewport: z.object({ width: z.number(), height: z.number() }).optional(), baselineImagePath: z.string().optional(), baselineImage: z.string().optional() }, async (params) => { // Get auto login configuration from environment variable or configuration file const autoLoginConfig = { username: process.env.AUTO_LOGIN_USERNAME, password: process.env.AUTO_LOGIN_PASSWORD, usernameSelector: process.env.AUTO_LOGIN_USERNAME_SELECTOR || "#username", passwordSelector: process.env.AUTO_LOGIN_PASSWORD_SELECTOR || "#password", submitSelector: process.env.AUTO_LOGIN_SUBMIT_SELECTOR || 'button[type="submit"]', successSelector: process.env.AUTO_LOGIN_SUCCESS_SELECTOR, loginUrlPattern: process.env.AUTO_LOGIN_URL_PATTERN || "login|signin|auth", }; // Get visual test parameters from environment variable or configuration file const testConfig = { selector: process.env.TEST_SELECTOR || params.selector, waitForSelector: process.env.TEST_WAIT_FOR_SELECTOR || params.waitForSelector, waitForTimeout: process.env.TEST_WAIT_TIMEOUT ? parseInt(process.env.TEST_WAIT_TIMEOUT) : params.waitForTimeout, threshold: process.env.TEST_THRESHOLD ? parseInt(process.env.TEST_THRESHOLD) : params.threshold, ignoreSelectors: process.env.TEST_IGNORE_SELECTORS ? process.env.TEST_IGNORE_SELECTORS.split(',') : params.ignoreSelectors, viewport: { width: process.env.TEST_VIEWPORT_WIDTH ? parseInt(process.env.TEST_VIEWPORT_WIDTH) : (params.viewport?.width || 1280), height: process.env.TEST_VIEWPORT_HEIGHT ? parseInt(process.env.TEST_VIEWPORT_HEIGHT) : (params.viewport?.height || 720) } }; const result = await runVisualTest({ url: params.url, ...testConfig, baselineImagePath: params.baselineImagePath, baselineImage: params.baselineImage, autoLogin: autoLoginConfig, }); if (result.success) { if (result.baselineCreated || result.baselineUpdated) { return { content: [{ type: "text", text: result.message || "Baseline image updated" }] }; } return { content: [{ type: "text", text: `Difference pixels: ${result.diffPixels}, Threshold: ${result.threshold}, Test ${result.passed ? 'Passed' : 'Failed'}` }, { type: "image", data: result.screenshots?.current || "", mimeType: "image/png" }, { type: "image", data: result.screenshots?.diff || "", mimeType: "image/png" }] }; } return { content: [{ type: "text", text: result.error || "Unknown error" }] }; }); // Add test result resource server.resource("testResult", new ResourceTemplate("test://{id}", { list: undefined }), async (uri, { id }) => ({ contents: [ { uri: uri.href, text: `Test result ID: ${id}`, }, ], })); export { server };