UNPKG

locatai-ts

Version:

Enterprise-grade AI-powered element locator for Selenium WebDriver - TypeScript implementation

453 lines (452 loc) 21.2 kB
"use strict"; 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