locatai-ts
Version:
Enterprise-grade AI-powered element locator for Selenium WebDriver - TypeScript implementation
453 lines (452 loc) • 21.2 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ElementFinder = void 0;
const selenium_webdriver_1 = require("selenium-webdriver");
const SeleniumPrompt_1 = require("./SeleniumPrompt");
const JsonFileLocatorCache_1 = require("../Caching/JsonFileLocatorCache");
const JsonFileLocatorStats_1 = require("../Caching/JsonFileLocatorStats");
const JsonFileLocatorTiming_1 = require("../Caching/JsonFileLocatorTiming");
const crypto_1 = __importDefault(require("crypto"));
class ElementFinder {
/**
* Initialize the ElementFinder with an AI provider and optional configuration
* @param provider The AI provider to use for generating locators
* @param options Optional configuration options
*/
static initialize(provider, options) {
if (!provider) {
throw new Error('Provider cannot be null');
}
ElementFinder.provider = provider;
// Update options with any provided values
if (options) {
ElementFinder.configureOptions(options);
}
}
/**
* Configure the cache implementation
* @param cache The cache implementation to use
*/
static configureCache(cache) {
ElementFinder._cache = cache;
}
/**
* Configure the statistics implementation
* @param stats The statistics implementation to use
*/
static configureStats(stats) {
ElementFinder._stats = stats;
}
/**
* Configure the timing implementation
* @param timing The timing implementation to use
*/
static configureTiming(timing) {
ElementFinder._timing = timing;
}
/**
* Configure LocatAI options
* @param options The options to apply
*/
static configureOptions(options) {
ElementFinder._options = { ...ElementFinder._options, ...options };
}
/**
* Generate a hash of the DOM content
* @param domContent The DOM content to hash
* @returns A hash string
*/
hashDom(domContent) {
return crypto_1.default.createHash('md5').update(domContent).digest('hex');
}
/**
* Find an element using AI-generated locators
* @param driver The WebDriver instance
* @param elementDescription Natural language description of the element
* @returns A WebElement if found
*/
async findElementByLocatAI(driver, elementDescription) {
if (!driver) {
throw new Error('Driver cannot be null');
}
if (!elementDescription?.trim()) {
throw new Error('Element description cannot be empty');
}
if (!ElementFinder.provider) {
throw new Error('ElementFinder has not been initialized. Call initialize() first.');
}
// Get the page URL and DOM content for cache key
const pageUrl = await driver.getCurrentUrl();
const pageSource = await driver.getPageSource();
const domHash = this.hashDom(pageSource);
// Check cache first
const cachedLocators = await ElementFinder._cache.tryGet(pageUrl, elementDescription, domHash);
// Track timing for smart waits
const startTime = Date.now();
// Try to get locators from cache or generate new ones
let locators = cachedLocators;
if (locators.length === 0) {
const generatedLocators = await this.generateLocators(driver, elementDescription);
locators = generatedLocators.map(loc => ({
strategy: loc.type,
value: loc.value,
confidence: loc.confidence || 0.7
}));
// Don't save to cache yet - only save successful locators
}
let attempt = 0;
let lastError = null;
// Main retry loop
while (true) {
attempt++;
if (locators.length === 0) {
throw new Error(`No locators met the minimum confidence threshold of ${ElementFinder._options.minimumConfidence}`);
}
// Convert to ElementLocator format
const elementLocators = locators
.filter(loc => (loc.confidence || 0) >= ElementFinder._options.minimumConfidence)
.sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
.slice(0, ElementFinder._options.maxLocatorsToTry)
.map(loc => ({
type: loc.strategy,
value: loc.value,
confidence: loc.confidence
}));
// Log the locators if detailed logging is enabled
if (ElementFinder._options.enableDetailedLog) {
console.log(`AI-generated locators (attempt ${attempt}):`);
elementLocators.forEach(loc => {
console.log(`- ${loc.type}=${loc.value} (confidence: ${loc.confidence?.toFixed(2) || 'unknown'})`);
});
}
// Try each locator until one works
for (const locator of elementLocators) {
try {
const element = await this.findElementByLocator(driver, locator);
// Success! Record stats and cache the locators
await ElementFinder._stats.recordAttempt(pageUrl, elementDescription, domHash, locator.type, locator.value, true);
// Save to cache
await ElementFinder._cache.save(pageUrl, elementDescription, domHash, locators);
// Record timing for smart waits (convert ms to seconds)
const elapsedMs = Date.now() - startTime;
const elapsedSeconds = elapsedMs / 1000;
await ElementFinder._timing.record(pageUrl, elementDescription, domHash, elapsedSeconds);
if (ElementFinder._options.enableDetailedLog) {
console.log(`Successfully found element using: ${locator.type}=${locator.value} (${elapsedMs}ms)`);
}
return element;
}
catch (error) {
lastError = error;
// Record failure
await ElementFinder._stats.recordAttempt(pageUrl, elementDescription, domHash, locator.type, locator.value, false);
if (ElementFinder._options.enableDetailedLog) {
console.log(`Failed with locator ${locator.type}=${locator.value}: ${error.message}`);
}
}
}
// All locators failed for this attempt
// If retry is disabled or we've reached the max attempts, give up
if (!ElementFinder._options.enableLocatorRetry || attempt > ElementFinder._options.maxRetryAttempts) {
break;
}
// Generate new locators for the retry
if (ElementFinder._options.enableDetailedLog) {
console.log(`All locators failed. Retrying (attempt ${attempt + 1} of ${ElementFinder._options.maxRetryAttempts + 1})...`);
}
const generatedLocators = await this.generateLocators(driver, elementDescription, attempt);
locators = generatedLocators.map(loc => ({
strategy: loc.type,
value: loc.value,
confidence: loc.confidence || 0.7
}));
}
// If we get here, all attempts failed
throw new Error(`All locators failed for '${elementDescription}' after ${attempt} attempt(s)${lastError ? `: ${lastError.message}` : ''}`);
}
/**
* Generate locators for an element description
* @param driver The WebDriver instance
* @param elementDescription Natural language description of the element
* @param retryAttempt Optional retry attempt number (for modifying the prompt)
* @returns Array of element locators
*/
async generateLocators(driver, elementDescription, retryAttempt = 0) {
// Get the page source
const pageSource = await driver.getPageSource();
// Create the user prompt
let userPrompt = `DOM Content:
${pageSource}
Generate a locator for: ${elementDescription}`;
// If this is a retry, modify the prompt
if (retryAttempt > 0) {
userPrompt += `\n\nThis is retry attempt ${retryAttempt}. Previous locators failed to find the element. Please try different approaches.`;
}
userPrompt += `\n\nRemember to return locators with confidence scores (e.g., 'type=value|confidence=0.9').`;
// We should already have verified provider is not null in findElementByLocatAI
// but add the check here to satisfy the TypeScript compiler
if (!ElementFinder.provider) {
throw new Error('ElementFinder has not been initialized. Call initialize() first.');
}
// Get locator from AI provider
const response = await ElementFinder.provider.generateResponse(userPrompt, SeleniumPrompt_1.SeleniumPrompt.systemPrompt);
if (!response || response === 'Element Not Found') {
throw new Error(`Could not find element matching description: ${elementDescription}`);
}
// Parse the response into locators
return this.parseLocators(response, ElementFinder.provider.constructor.name);
}
/**
* Parse a single locator string
* @param locatorString Raw locator string
* @returns Parsed ElementLocator object
*/
parseLocatorString(locatorString) {
// Check for confidence score format: type=value|confidence=0.9
const confidenceMatch = locatorString.match(/^(.+?)\|confidence=([0-9.]+)$/);
if (confidenceMatch) {
const [_, locatorPart, confidenceStr] = confidenceMatch;
const confidence = parseFloat(confidenceStr);
const parts = locatorPart.split('=');
if (parts.length !== 2) {
throw new Error(`Invalid locator format: ${locatorString}. Expected 'type=value|confidence=0.9'.`);
}
const [type, value] = parts.map(part => part.trim());
if (!SeleniumPrompt_1.SeleniumPrompt.isValidLocatorType(type)) {
throw new Error(`Invalid locator type: ${type}`);
}
if (!value) {
throw new Error('Locator value cannot be empty');
}
return { type: type, value, confidence };
}
// Legacy format without confidence
const parts = locatorString.split('=');
if (parts.length !== 2) {
throw new Error(`Invalid locator format: ${locatorString}. Expected 'type=value'.`);
}
const [type, value] = parts.map(part => part.trim());
if (!SeleniumPrompt_1.SeleniumPrompt.isValidLocatorType(type)) {
throw new Error(`Invalid locator type: ${type}`);
}
if (!value) {
throw new Error('Locator value cannot be empty');
}
return { type: type, value };
}
/**
* Parses a collection of locator strings into structured locator objects
* @param locatorStrings Array of raw locator strings from the AI
* @param providerType The provider type name for provider-specific parsing
* @returns Array of parsed ElementLocator objects
*/
parseLocators(response, providerType) {
const results = [];
if (!response)
return results;
// Pre-process response for Ollama
if (providerType === 'OllamaProvider') {
response = this.cleanOllamaResponse(response);
}
const lines = response.split(/[\n\r]+/).filter(line => line.trim() !== '');
// Standard patterns for parsing locators
const patterns = [
/([a-z-]+)=(.+)\|confidence=([0-9.]+)/i, // Standard format
/([a-z-]+)=(.+?)\|\s*\(?confidence:?\s*([0-9.]+)/i, // Format with pipe
/([a-z-]+)=(.+?)\|\s*\(([A-Za-z]+)\)/i, // Format with pipe and text confidence
/([a-z-]+)=(.+)\s+confidence:?\s*([0-9.]+)/i, // Alternative with spaces
/confidence:?\s*([0-9.]+).*?([a-z-]+)=(.+)/i, // Reversed order
/([a-z-]+)[=:]\s*["']?(.+?)["']?\s*\|\s*([0-9.]+)/i // With quotes
];
// Add Ollama-specific patterns if needed
if (providerType === 'OllamaProvider') {
patterns.push(/\*\*([a-z-]+):\*\*\s*`?([^`(]+)`?/i, // **xpath:** `//div`
/([a-z-]+):\s+([^\s(]+)/i, // xpath: //div
/(\d+)\.\s*`([^`]+)`\s*\(([A-Za-z]+)\)/i, // 1. `//div` (High)
/([a-z-]+)\s*selector:\s*[`"]?([^`"]+)[`"]?/i // CSS selector: ".class"
);
}
for (const line of lines) {
let matched = false;
for (const pattern of patterns) {
const match = line.match(pattern);
if (match) {
let locatorType, locatorValue, confidence = 0.7; // Default confidence
if (pattern.toString().includes('confidence=')) {
// Standard format with confidence
locatorType = match[1];
locatorValue = match[2];
confidence = parseFloat(match[3] || '0.7');
}
else if (pattern.toString().includes('\\*\\*')) {
// Ollama markdown format: **xpath:** `//div`
locatorType = match[1];
locatorValue = match[2];
confidence = 0.7; // Default confidence for Ollama markdown format
}
else if (pattern.toString().includes('\\d+')) {
// Ollama numbered list: 1. `//div` (High)
locatorType = this.determineLocatorTypeFromValue(match[2]);
locatorValue = match[2];
// Map confidence text to numeric value
const confidenceText = match[3].toLowerCase();
confidence = this.mapConfidenceTextToValue(confidenceText);
}
else {
// Other formats
locatorType = match[1];
locatorValue = match[2];
confidence = 0.7; // Default confidence
}
// Clean up locator value
locatorValue = locatorValue.trim().replace(/^[`'"]+|[`'"]+$/g, '').replace(/\|$/, '');
locatorType = this.normalizeLocatorType(locatorType);
if (SeleniumPrompt_1.SeleniumPrompt.isValidLocatorType(locatorType)) {
results.push({
type: locatorType, // Type assertion needed because we validated with isValidLocatorType
value: locatorValue.trim(),
confidence
});
matched = true;
break;
}
}
}
// Fallback for single locator without confidence
if (!matched && line.includes('=')) {
const parts = line.split('=', 2);
if (parts.length === 2) {
const locatorType = this.normalizeLocatorType(parts[0].trim());
// Remove trailing pipe character if present
const locatorValue = parts[1].trim().replace(/\|$/, '');
if (SeleniumPrompt_1.SeleniumPrompt.isValidLocatorType(locatorType)) {
results.push({
type: locatorType, // Type assertion needed because we validated with isValidLocatorType
value: locatorValue,
confidence: 0.5 // Default confidence
});
}
}
}
}
return results;
}
mapConfidenceTextToValue(confidenceText) {
switch (confidenceText.toLowerCase()) {
case 'high': return 0.9;
case 'medium': return 0.7;
case 'low': return 0.5;
case 'very low': return 0.3;
default: return 0.6; // Default
}
}
determineLocatorTypeFromValue(locatorValue) {
locatorValue = locatorValue.trim();
if (locatorValue.startsWith('//'))
return 'xpath';
if (locatorValue.startsWith('.') || locatorValue.startsWith('#') || locatorValue.includes('['))
return 'css';
if (/^[a-zA-Z0-9_-]+$/.test(locatorValue))
return 'id';
return 'xpath'; // Default to xpath for complex expressions
}
normalizeLocatorType(locatorType) {
locatorType = locatorType.toLowerCase().trim();
switch (locatorType) {
case 'xpath':
case 'x-path':
case 'x_path': return 'xpath';
case 'css':
case 'cssselector':
case 'css-selector':
case 'css_selector': return 'css';
case 'id': return 'id';
case 'name': return 'name';
case 'class':
case 'classname':
case 'class-name':
case 'class_name': return 'class';
case 'tag':
case 'tagname':
case 'tag-name':
case 'tag_name': return 'tag';
case 'link':
case 'linktext':
case 'link-text':
case 'link_text': return 'link';
case 'partiallink':
case 'partiallinktext':
case 'partial-link-text':
case 'partial_link_text': return 'partial-link';
default: return locatorType;
}
}
cleanOllamaResponse(response) {
if (!response)
return '';
// Remove markdown bold formatting
let cleanedResponse = response.replace(/\*\*([^*]+)\*\*/g, '$1');
// Remove backticks and code blocks
cleanedResponse = cleanedResponse.replace(/```[\s\S]*?```/g, '');
cleanedResponse = cleanedResponse.replace(/`/g, '');
// Convert "xpath: //div" format to "xpath=//div"
cleanedResponse = cleanedResponse.replace(/([a-z-]+):\s+/gi, '$1=');
// Convert confidence descriptions to numeric values
cleanedResponse = cleanedResponse.replace(/\(Low\)/gi, '|confidence=0.5');
cleanedResponse = cleanedResponse.replace(/\(Medium\)/gi, '|confidence=0.7');
cleanedResponse = cleanedResponse.replace(/\(High\)/gi, '|confidence=0.9');
// Remove numbering (like 1., 2., etc.)
cleanedResponse = cleanedResponse.replace(/^\d+\.\s+/gm, '');
// Filter out explanation lines that don't contain = or :
const lines = cleanedResponse.split('\n').filter(line => line.includes('=') ||
line.includes(':') ||
/^[a-z]+ selector/i.test(line));
return lines.join('\n').trim();
}
async findElementByLocator(driver, locator) {
const { type, value } = locator;
try {
switch (type) {
case 'id':
return await driver.findElement(selenium_webdriver_1.By.id(value));
case 'name':
return await driver.findElement(selenium_webdriver_1.By.name(value));
case 'css':
return await driver.findElement(selenium_webdriver_1.By.css(value));
case 'xpath':
return await driver.findElement(selenium_webdriver_1.By.xpath(value));
case 'class':
return await driver.findElement(selenium_webdriver_1.By.className(value));
case 'tag':
return await driver.findElement(selenium_webdriver_1.By.tagName(value));
case 'link':
return await driver.findElement(selenium_webdriver_1.By.linkText(value));
case 'partial-link':
return await driver.findElement(selenium_webdriver_1.By.partialLinkText(value));
default:
// This should never happen due to type checking
throw new Error(`Unsupported locator type: ${type}`);
}
}
catch (error) {
throw new Error(`Failed to find element using ${type}=${value}: ${error.message}`);
}
}
}
exports.ElementFinder = ElementFinder;
ElementFinder.provider = null;
ElementFinder._cache = new JsonFileLocatorCache_1.JsonFileLocatorCache();
ElementFinder._stats = new JsonFileLocatorStats_1.JsonFileLocatorStats();
ElementFinder._timing = new JsonFileLocatorTiming_1.JsonFileLocatorTiming();
ElementFinder._options = {
minimumConfidence: 0.6,
maxLocatorsToTry: 8,
timeoutMilliseconds: 12000,
enableDetailedLog: false,
enableLocatorRetry: true,
maxRetryAttempts: 2
};
//# sourceMappingURL=ElementFinder.js.map