UNPKG

@progress/kendo-e2e

Version:

Kendo UI end-to-end test utilities.

445 lines (434 loc) 17.4 kB
#!/usr/bin/env node // src/mcp/standalone.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { createRequire } from "module"; import { z } from "zod"; var require2 = createRequire(import.meta.url); var { Browser } = require2("../selenium/browser.js"); function mcpResult(payload, summary) { const parts = []; if (summary) parts.push({ type: "text", text: summary }); parts.push({ type: "text", text: JSON.stringify(payload) }); return { content: parts }; } var browsers = /* @__PURE__ */ new Map(); var sessionCounter = 0; var server = new McpServer({ name: "kendo-e2e-mcp", version: "1.0.0", description: "Kendo E2E MCP: Browser automation for AI-powered test generation. Provides: (1) Browser lifecycle, (2) DOM snapshot and selector testing, (3) Element interactions with automatic waiting." }); server.tool( "browser-navigate", "Navigate browser to URL. Auto-starts new browser if no session exists. LLM: Use this as first step to interact with a page.", { url: z.string().url().describe("URL to navigate to"), sessionId: z.string().optional().describe("Existing session (optional)"), mobileEmulation: z.object({ deviceName: z.string().optional() }).optional() }, async ({ url, sessionId, mobileEmulation }) => { let browser; let isNewSession = false; if (sessionId && browsers.has(sessionId)) { browser = browsers.get(sessionId); } else { sessionId = `session-${++sessionCounter}`; browser = new Browser(mobileEmulation ? { mobileEmulation } : void 0); browsers.set(sessionId, browser); isNewSession = true; } await browser.navigateTo(url); return mcpResult( { sessionId, url, newSession: isNewSession }, isNewSession ? `Started session ${sessionId} and navigated to ${url}` : `Navigated ${sessionId} to ${url}` ); } ); server.tool( "browser-close", "Close browser session. LLM: Call when done.", { sessionId: z.string().optional() }, async ({ sessionId }) => { if (sessionId) { const browser = browsers.get(sessionId); if (!browser) throw new Error(`Session not found: ${sessionId}`); await browser.close(); browsers.delete(sessionId); return mcpResult({ sessionId, closed: true }, `Closed ${sessionId}`); } else { for (const [id, browser] of browsers) { await browser.close().catch(() => { }); browsers.delete(id); } return mcpResult({ closedAll: true }, "Closed all sessions"); } } ); server.tool( "browser-execute-script", "Execute JavaScript in browser. Use for complex operations.", { sessionId: z.string().optional(), script: z.string().describe("JavaScript code with 'return' statement"), args: z.array(z.any()).optional().default([]) }, async ({ sessionId, script, args = [] }) => { const browser = sessionId ? browsers.get(sessionId) : Array.from(browsers.values())[0]; if (!browser) throw new Error("No browser session"); const result = await browser.driver.executeScript(script, ...args); return mcpResult({ sessionId, result }, `Executed script`); } ); var domSnapshotScript = ` const opts = arguments[0] || {}; const rootSelector = opts.rootSelector; const filteredTags = new Set(["script", "style", "link", "meta", "noscript"]); const norm = (s) => (s ?? "").replace(/\\s+/g, " ").trim(); function snapElement(el) { const tag = el.tagName ? el.tagName.toLowerCase() : ""; if (filteredTags.has(tag)) return null; const id = el.id || undefined; const classes = el.classList ? Array.from(el.classList) : []; const role = el.getAttribute?.("role") || undefined; const dataRole = el.getAttribute?.("data-role") || undefined; const type = (tag === "input" || tag === "button") && el.type ? String(el.type) : undefined; const aria = {}; if (el.hasAttributes?.()) { for (const attr of Array.from(el.attributes)) { if (attr.name?.startsWith("aria-")) aria[attr.name] = attr.value; } } let hidden = false; let frame; try { const style = window.getComputedStyle?.(el); if (style) { hidden = style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity || "1") === 0 || el.offsetParent === null; } if (!hidden) { const rect = el.getBoundingClientRect(); if (rect && (rect.width > 0 || rect.height > 0)) { frame = [ Math.round(rect.left + window.scrollX), Math.round(rect.top + window.scrollY), Math.round(rect.width), Math.round(rect.height) ]; } } } catch (e) {} const content = []; if (el.childNodes) { for (const child of Array.from(el.childNodes)) { if (child.nodeType === 3) { const text = norm(child.textContent); if (text) content.push(text); } else if (child.nodeType === 1) { const childEl = snapElement(child); if (childEl) content.push(childEl); } } } return { element: tag, ...(id ? { id } : {}), ...(classes.length ? { classes } : {}), ...(role ? { role } : {}), ...(dataRole ? { dataRole } : {}), ...(type ? { type } : {}), ...(Object.keys(aria).length ? { aria } : {}), ...(frame ? { frame } : {}), ...(hidden ? { hidden } : {}), ...(content.length ? { content } : {}) }; } const root = rootSelector ? document.querySelector(rootSelector) : document.body; if (!root) return { error: "root-not-found" }; return snapElement(root); `; function treeToHtml(node, indent = 0) { if (!node) return ""; if (typeof node === "string") return node; const spaces = " ".repeat(indent); const tag = node.element || "unknown"; const attrs = []; if (node.id) attrs.push(`id="${node.id}"`); if (node.classes?.length) attrs.push(`class="${node.classes.join(" ")}"`); if (node.role) attrs.push(`role="${node.role}"`); if (node.dataRole) attrs.push(`data-role="${node.dataRole}"`); if (node.type) attrs.push(`type="${node.type}"`); if (node.frame) attrs.push(`frame="${node.frame.join(" ")}"`); if (node.hidden) attrs.push(`hidden`); if (node.aria) { for (const [key, value] of Object.entries(node.aria)) { attrs.push(`${key}="${value}"`); } } const attrStr = attrs.length > 0 ? " " + attrs.join(" ") : ""; const voidTags = /* @__PURE__ */ new Set(["br", "hr", "img", "input"]); if (voidTags.has(tag)) return `${spaces}<${tag}${attrStr}>`; if (!node.content?.length) return `${spaces}<${tag}${attrStr}/>`; let result = `${spaces}<${tag}${attrStr}>`; const hasElements = node.content.some((c) => typeof c !== "string"); if (hasElements) { result += "\n"; for (const item of node.content) result += treeToHtml(item, indent + 1) + "\n"; result += spaces; } else { result += node.content.join(""); } return result + `</${tag}>`; } server.tool( "dom-snapshot", "Get filtered DOM tree with positioning, visibility, semantic info. Elements include: tag, id, classes, role, data-role (Kendo), aria, frame [x,y,w,h], hidden. LLM: Use to understand page structure and identify selectors.", { sessionId: z.string().optional(), rootSelector: z.string().optional(), format: z.enum(["html", "json"]).default("html"), includeScreenshot: z.boolean().default(false) }, async ({ sessionId, rootSelector, format = "html", includeScreenshot = false }) => { const browser = sessionId ? browsers.get(sessionId) : Array.from(browsers.values())[0]; if (!browser) throw new Error("No browser session"); const tree = await browser.driver.executeScript(domSnapshotScript, { rootSelector }); let screenshot; if (includeScreenshot) { try { screenshot = await browser.driver.takeScreenshot(); } catch { } } if (tree.error) return { content: [{ type: "text", text: `Error: ${tree.error}` }] }; if (format === "html") { const html = treeToHtml(tree); const content = [{ type: "text", text: "DOM snapshot:" }, { type: "text", text: html }]; if (screenshot) content.push({ type: "image", data: screenshot, mimeType: "image/png" }); return { content }; } else { const result = mcpResult({ tree, rootSelector }, "DOM snapshot (JSON)"); if (screenshot) result.content.push({ type: "image", data: screenshot, mimeType: "image/png" }); return result; } } ); var domSelectorScript = ` const selector = arguments[0]; const selectorType = arguments[1]; const isXPath = selectorType === "xpath" || (!selectorType && ( selector.includes("/") || selector.startsWith("//") || selector.includes("[@") )); let matched = []; try { if (isXPath) { const result = document.evaluate(selector, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); for (let i = 0; i < result.snapshotLength; i++) { const node = result.snapshotItem(i); if (node?.nodeType === 1) matched.push(node); } } else { matched = Array.from(document.querySelectorAll(selector)); } } catch (e) { return { error: String(e), selector }; } return { selector, selectorType: isXPath ? "xpath" : "css", matchCount: matched.length, matches: matched.slice(0, 10).map((el) => ({ tag: el.tagName.toLowerCase(), id: el.id || undefined, classes: el.classList ? Array.from(el.classList) : [], text: el.textContent?.replace(/\\s+/g, " ").trim().substring(0, 100) })) }; `; server.tool( "dom-test-selector", "Test CSS/XPath selector, return match count and details. Auto-detects type. LLM: Use to validate selectors before generating tests.", { sessionId: z.string().optional(), selector: z.string(), selectorType: z.enum(["css", "xpath"]).optional() }, async ({ sessionId, selector, selectorType }) => { const browser = sessionId ? browsers.get(sessionId) : Array.from(browsers.values())[0]; if (!browser) throw new Error("No browser session"); const result = await browser.driver.executeScript(domSelectorScript, selector, selectorType); if (result.error) return mcpResult({ error: result.error, selector }, `Error: ${result.error}`); return mcpResult(result, `${result.selectorType.toUpperCase()} selector "${selector}" matched ${result.matchCount} elements`); } ); server.tool( "dom-page-info", "Get page context: title, URL, viewport, readyState, element counts. LLM: Quick page context without full DOM.", { sessionId: z.string().optional() }, async ({ sessionId }) => { const browser = sessionId ? browsers.get(sessionId) : Array.from(browsers.values())[0]; if (!browser) throw new Error("No browser session"); const info = await browser.driver.executeScript(` return { title: document.title, url: window.location.href, readyState: document.readyState, viewport: { width: window.innerWidth, height: window.innerHeight }, counts: { forms: document.forms?.length || 0, links: document.links?.length || 0, images: document.images?.length || 0 } }; `); return mcpResult({ info }, `Page: "${info.title}"`); } ); server.tool( "element-interact", "Interact with element: click, type, clear, hover, scrollIntoView. Uses automatic waiting. LLM: Use for all element interactions.", { sessionId: z.string().optional(), selector: z.string(), action: z.enum(["click", "type", "clear", "hover", "scrollIntoView"]), value: z.string().optional(), timeout: z.number().optional().default(1e4) }, async ({ sessionId, selector, action, value, timeout = 1e4 }) => { const browser = sessionId ? browsers.get(sessionId) : Array.from(browsers.values())[0]; if (!browser) throw new Error("No browser session"); try { switch (action) { case "click": await browser.click(selector, { timeout }); return mcpResult({ selector, action }, `Clicked ${selector}`); case "type": if (!value) throw new Error("'value' required for type"); await browser.type(selector, value, { timeout }); return mcpResult({ selector, action, value }, `Typed "${value}" into ${selector}`); case "clear": await browser.clear(selector, { timeout }); return mcpResult({ selector, action }, `Cleared ${selector}`); case "hover": await browser.hover(selector, { timeout }); return mcpResult({ selector, action }, `Hovered ${selector}`); case "scrollIntoView": const element = await browser.find(selector, { timeout }); await browser.driver.executeScript("arguments[0].scrollIntoView(true);", element); return mcpResult({ selector, action }, `Scrolled ${selector} into view`); } } catch (error) { return mcpResult({ selector, action, error: error.message }, `Failed: ${error.message}`); } } ); server.tool( "element-find", "Find element(s) and get properties: text, attributes, visibility, enabled. LLM: Query element state before interaction.", { sessionId: z.string().optional(), selector: z.string(), multiple: z.boolean().optional().default(false), properties: z.array(z.enum(["text", "enabled", "visible", "tag", "id", "classes"])).optional().default(["text", "visible"]), attributes: z.array(z.string()).optional().default([]), timeout: z.number().optional().default(1e4) }, async ({ sessionId, selector, multiple = false, properties = ["text", "visible"], attributes = [], timeout = 1e4 }) => { const browser = sessionId ? browsers.get(sessionId) : Array.from(browsers.values())[0]; if (!browser) throw new Error("No browser session"); try { if (multiple) { const elements = await browser.findAll(selector); const results = []; for (const el of elements.slice(0, 20)) { const info = {}; for (const prop of properties) { switch (prop) { case "text": info.text = await el.getText(); break; case "enabled": info.enabled = await el.isEnabled(); break; case "visible": info.visible = await el.isDisplayed(); break; case "tag": info.tag = await el.getTagName(); break; case "id": info.id = await el.getAttribute("id"); break; case "classes": const cls = await el.getAttribute("class"); info.classes = cls ? cls.split(/\s+/) : []; break; } } for (const attr of attributes) info[attr] = await el.getAttribute(attr); results.push(info); } return mcpResult( { selector, count: elements.length, elements: results }, `Found ${elements.length} elements` ); } else { const el = await browser.find(selector, { timeout }); const info = {}; for (const prop of properties) { switch (prop) { case "text": info.text = await el.getText(); break; case "enabled": info.enabled = await el.isEnabled(); break; case "visible": info.visible = await el.isDisplayed(); break; case "tag": info.tag = await el.getTagName(); break; case "id": info.id = await el.getAttribute("id"); break; case "classes": const cls = await el.getAttribute("class"); info.classes = cls ? cls.split(/\s+/) : []; break; } } for (const attr of attributes) info[attr] = await el.getAttribute(attr); return mcpResult({ selector, element: info }, `Found ${selector}`); } } catch (error) { return mcpResult({ selector, error: error.message }, `Failed: ${error.message}`); } } ); var transport = new StdioServerTransport(); var cleanUp = async () => { for (const [id, browser] of browsers) { try { await browser.close(); } catch { } browsers.delete(id); } }; transport.onclose = cleanUp; process.on("SIGINT", async () => { await cleanUp(); process.exit(0); }); process.on("SIGTERM", async () => { await cleanUp(); process.exit(0); }); await server.connect(transport);