"use strict";
/**
* Locator Engine - Core locator generation logic for LocatorLabs MCP
*
* @author Naveen AutomationLabs
* @license MIT
* @date 2025
* @see https://github.com/naveenanimation20/locatorlabs-mcp
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.LocatorEngine = void 0;
class LocatorEngine {
browserManager;
constructor(browserManager) {
this.browserManager = browserManager;
}
async getLocators(url, elementDescription) {
const page = await this.browserManager.navigateTo(url);
const elements = await this.findMatchingElements(page, elementDescription);
if (elements.length === 0) {
return {
element: elementDescription,
locators: [],
recommended: "No matching elements found",
};
}
const element = elements[0];
const locators = this.generateLocators(element);
const ranked = this.rankLocators(locators);
return {
element: elementDescription,
locators: ranked,
recommended: ranked[0]?.locator || "No locator found",
};
}
async analyzePage(url, elementTypes) {
const page = await this.browserManager.navigateTo(url);
const allElements = await this.getAllInteractiveElements(page);
let filtered = allElements;
if (elementTypes && elementTypes.length > 0) {
filtered = allElements.filter((el) => elementTypes.some((t) => el.tagName.toLowerCase().includes(t) || el.type?.includes(t)));
}
const results = filtered.map((el) => {
const locators = this.generateLocators(el);
const ranked = this.rankLocators(locators);
return {
element: this.describeElement(el),
type: el.tagName.toLowerCase(),
locators: ranked.slice(0, 3),
recommended: ranked[0]?.locator || "N/A",
};
});
return {
url,
totalElements: results.length,
elements: results,
};
}
async generatePageObject(url, className, language) {
const page = await this.browserManager.navigateTo(url);
const elements = await this.getAllInteractiveElements(page);
const pageElements = elements.map((el) => {
const locators = this.generateLocators(el);
const ranked = this.rankLocators(locators);
return {
name: this.generatePropertyName(el),
locator: ranked[0]?.locator || `locator('${el.selector}')`,
element: el,
};
});
switch (language) {
case "python":
return this.generatePythonPOM(className, pageElements, url);
case "javascript":
return this.generateJavaScriptPOM(className, pageElements, url);
default:
return this.generateTypeScriptPOM(className, pageElements, url);
}
}
async validateLocator(url, locatorStr) {
const page = await this.browserManager.navigateTo(url);
try {
const locator = this.parseLocatorString(page, locatorStr);
const count = await locator.count();
const suggestions = [];
if (count === 0) {
suggestions.push("Element not found. Check if the page has loaded completely.");
suggestions.push("Verify the locator syntax is correct.");
}
else if (count > 1) {
suggestions.push(`Found ${count} elements. Consider adding more specificity.`);
suggestions.push("Try using getByRole with name option for uniqueness.");
}
return {
isValid: count > 0,
matchCount: count,
isUnique: count === 1,
suggestions,
};
}
catch (e) {
return {
isValid: false,
matchCount: 0,
isUnique: false,
suggestions: [`Invalid locator syntax: ${e instanceof Error ? e.message : String(e)}`],
};
}
}
async findMatchingElements(page, description) {
const keywords = description.toLowerCase().split(/\s+/);
const allElements = await this.getAllInteractiveElements(page);
return allElements.filter((el) => {
const searchText = [
el.id, el.name, el.className, el.text, el.placeholder,
el.ariaLabel, el.type, el.title, el.tagName
].filter(Boolean).join(" ").toLowerCase();
return keywords.some((kw) => searchText.includes(kw));
});
}
async getAllInteractiveElements(page) {
return await page.evaluate(() => {
const interactiveSelectors = [
"button", "a", "input", "select", "textarea",
"[role='button']", "[role='link']", "[role='textbox']",
"[role='checkbox']", "[role='radio']", "[role='combobox']",
"[onclick]", "[tabindex]"
];
const elements = [];
const seen = new Set();
interactiveSelectors.forEach((sel) => {
document.querySelectorAll(sel).forEach((el) => {
if (seen.has(el))
return;
seen.add(el);
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0)
return;
const htmlEl = el;
const inputEl = el;
elements.push({
tagName: el.tagName.toLowerCase(),
id: el.id || undefined,
name: inputEl.name || undefined,
className: el.className || undefined,
type: inputEl.type || undefined,
placeholder: inputEl.placeholder || undefined,
text: htmlEl.innerText?.trim().substring(0, 50) || undefined,
ariaLabel: el.getAttribute("aria-label") || undefined,
role: el.getAttribute("role") || undefined,
testId: el.getAttribute("data-testid") || el.getAttribute("data-test-id") || undefined,
href: el.href || undefined,
title: el.getAttribute("title") || undefined,
value: inputEl.value || undefined,
xpath: getXPath(el),
selector: getUniqueSelector(el),
});
});
});
function getXPath(el) {
if (el.id)
return `//*[@id="${el.id}"]`;
const parts = [];
let current = el;
while (current && current.nodeType === Node.ELEMENT_NODE) {
let index = 1;
let sibling = current.previousElementSibling;
while (sibling) {
if (sibling.tagName === current.tagName)
index++;
sibling = sibling.previousElementSibling;
}
parts.unshift(`${current.tagName.toLowerCase()}[${index}]`);
current = current.parentElement;
}
return "/" + parts.join("/");
}
function getUniqueSelector(el) {
if (el.id)
return `#${el.id}`;
const tag = el.tagName.toLowerCase();
const classes = Array.from(el.classList).slice(0, 2).join(".");
return classes ? `${tag}.${classes}` : tag;
}
return elements;
});
}
generateLocators(el) {
const locators = [];
// Role-based (highest priority for Playwright)
if (el.role || this.inferRole(el)) {
const role = el.role || this.inferRole(el);
const name = el.text || el.ariaLabel || el.title;
if (name) {
locators.push({
type: "role",
locator: `getByRole('${role}', { name: '${this.escape(name)}' })`,
reliability: 95,
description: "Playwright recommended - accessible and stable",
});
}
}
// Test ID (if exists)
if (el.testId) {
locators.push({
type: "testId",
locator: `getByTestId('${el.testId}')`,
reliability: 98,
description: "Best choice - explicitly set for testing",
});
}
// Placeholder (for inputs)
if (el.placeholder) {
locators.push({
type: "placeholder",
locator: `getByPlaceholder('${this.escape(el.placeholder)}')`,
reliability: 85,
description: "Good for form inputs",
});
}
// Label (for form elements)
if (el.ariaLabel) {
locators.push({
type: "label",
locator: `getByLabel('${this.escape(el.ariaLabel)}')`,
reliability: 88,
description: "Accessible label-based locator",
});
}
// Text-based
if (el.text && el.tagName !== "input") {
locators.push({
type: "text",
locator: `getByText('${this.escape(el.text)}')`,
reliability: 75,
description: "Text content based - may break if text changes",
});
}
// ID-based
if (el.id) {
locators.push({
type: "id",
locator: `locator('#${el.id}')`,
reliability: 90,
description: "ID selector - stable if ID is meaningful",
});
}
// CSS selector
locators.push({
type: "css",
locator: `locator('${el.selector}')`,
reliability: 60,
description: "CSS selector - may be brittle",
});
// XPath (lowest priority)
locators.push({
type: "xpath",
locator: `locator('${el.xpath}')`,
reliability: 40,
description: "XPath - avoid unless necessary",
});
return locators;
}
inferRole(el) {
const tag = el.tagName.toLowerCase();
const type = el.type?.toLowerCase();
if (tag === "button" || type === "submit" || type === "button")
return "button";
if (tag === "a")
return "link";
if (tag === "input" && type === "checkbox")
return "checkbox";
if (tag === "input" && type === "radio")
return "radio";
if (tag === "input" || tag === "textarea")
return "textbox";
if (tag === "select")
return "combobox";
if (tag === "img")
return "img";
return null;
}
rankLocators(locators) {
return locators.sort((a, b) => b.reliability - a.reliability);
}
describeElement(el) {
const parts = [];
if (el.text)
parts.push(`"${el.text}"`);
if (el.placeholder)
parts.push(`placeholder: "${el.placeholder}"`);
if (el.type)
parts.push(`type: ${el.type}`);
parts.push(`<${el.tagName}>`);
return parts.join(" ") || el.tagName;
}
generatePropertyName(el) {
let name = el.id || el.name || el.text || el.placeholder || el.ariaLabel || el.tagName;
name = name.replace(/[^a-zA-Z0-9]/g, " ").trim();
const words = name.split(/\s+/).slice(0, 3);
return words.map((w, i) => i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join("");
}
escape(str) {
return str.replace(/'/g, "\\'").substring(0, 50);
}
parseLocatorString(page, str) {
if (str.startsWith("getByRole")) {
const match = str.match(/getByRole\('(\w+)'(?:,\s*\{\s*name:\s*'([^']+)'\s*\})?\)/);
if (match)
return page.getByRole(match[1], match[2] ? { name: match[2] } : undefined);
}
if (str.startsWith("getByText")) {
const match = str.match(/getByText\('([^']+)'\)/);
if (match)
return page.getByText(match[1]);
}
if (str.startsWith("getByTestId")) {
const match = str.match(/getByTestId\('([^']+)'\)/);
if (match)
return page.getByTestId(match[1]);
}
if (str.startsWith("getByPlaceholder")) {
const match = str.match(/getByPlaceholder\('([^']+)'\)/);
if (match)
return page.getByPlaceholder(match[1]);
}
if (str.startsWith("locator")) {
const match = str.match(/locator\('([^']+)'\)/);
if (match)
return page.locator(match[1]);
}
return page.locator(str);
}
generateTypeScriptPOM(className, elements, url) {
const props = elements.map((e) => ` readonly ${e.name}: Locator;`).join("\n");
const inits = elements.map((e) => ` this.${e.name} = page.${e.locator};`).join("\n");
return `import { Page, Locator } from '@playwright/test';
/**
* Page Object Model for: ${url}
* Generated by LocatorLabs MCP
*/
export class ${className} {
readonly page: Page;
${props}
constructor(page: Page) {
this.page = page;
${inits}
}
async navigate() {
await this.page.goto('${url}');
}
}
`;
}
generateJavaScriptPOM(className, elements, url) {
const inits = elements.map((e) => ` this.${e.name} = page.${e.locator};`).join("\n");
return `/**
* Page Object Model for: ${url}
* Generated by LocatorLabs MCP
*/
class ${className} {
constructor(page) {
this.page = page;
${inits}
}
async navigate() {
await this.page.goto('${url}');
}
}
module.exports = { ${className} };
`;
}
generatePythonPOM(className, elements, url) {
const props = elements.map((e) => {
const pyLocator = e.locator
.replace(/getByRole/g, "get_by_role")
.replace(/getByText/g, "get_by_text")
.replace(/getByTestId/g, "get_by_test_id")
.replace(/getByPlaceholder/g, "get_by_placeholder");
return ` self.${this.toSnakeCase(e.name)} = page.${pyLocator}`;
}).join("\n");
return `"""
Page Object Model for: ${url}
Generated by LocatorLabs MCP
"""
from playwright.sync_api import Page
class ${className}:
def __init__(self, page: Page):
self.page = page
${props}
def navigate(self):
self.page.goto('${url}')
`;
}
toSnakeCase(str) {
return str.replace(/([A-Z])/g, "_$1").toLowerCase().replace(/^_/, "");
}
}
exports.LocatorEngine = LocatorEngine;
//# sourceMappingURL=locator-engine.js.map