@progress/kendo-e2e
Version:
Kendo UI end-to-end test utilities.
445 lines (434 loc) • 17.4 kB
JavaScript
#!/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);