UNPKG

playwright-self-healer

Version:

A powerful self-healing automation tool for Playwright that automatically finds alternative selectors when original ones fail, including CSS and XPath support, similar to Healenium but built for modern web applications. Now with comprehensive XPath healin

825 lines 41.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.HealingPage = void 0; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const STORE_PATH = path_1.default.join(__dirname, '..', 'healed-selectors.json'); const CANDIDATE_TAGS = ['button', 'a', 'input', 'span', 'div', 'label', 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th']; const SIMILARITY_THRESHOLD = 0.6; const MAX_HEALING_ATTEMPTS = 5; class HealingPage { constructor(page) { this.page = page; this.selectorMap = this.loadHealedSelectors(); this.healingAttempts = new Map(); this.initializeHealingStrategies(); } initializeHealingStrategies() { this.healingStrategies = [ { name: 'ID-based healing', priority: 1, heal: this.healById.bind(this) }, { name: 'Text-based healing', priority: 2, heal: this.healByText.bind(this) }, { name: 'Attribute-based healing', priority: 3, heal: this.healByAttributes.bind(this) }, { name: 'Position-based healing', priority: 4, heal: this.healByPosition.bind(this) }, { name: 'Similarity-based healing', priority: 5, heal: this.healBySimilarity.bind(this) }, { name: 'XPath-based healing', priority: 6, heal: this.healByXPath.bind(this) } ].sort((a, b) => a.priority - b.priority); } async goto(url) { await this.page.goto(url); this.healingAttempts.clear(); // Clear attempts on page navigation } async click(selector, options) { return this.performAction('click', selector, undefined, options); } async fill(selector, value, options) { return this.performAction('fill', selector, value, options); } async check(selector, options) { return this.performAction('check', selector, undefined, options); } async uncheck(selector, options) { return this.performAction('uncheck', selector, undefined, options); } async hover(selector, options) { return this.performAction('hover', selector, undefined, options); } async type(selector, text, options) { return this.performAction('type', selector, text, options); } async selectOption(selector, value, options) { return this.performAction('selectOption', selector, value, options); } async getText(selector, options) { return this.performAction('getText', selector, undefined, options); } async getAttribute(selector, name, options) { return this.performAction('getAttribute', selector, name, options); } async isVisible(selector, options) { return this.performAction('isVisible', selector, undefined, options); } async waitForSelector(selector, options) { return this.performAction('waitForSelector', selector, undefined, options); } async performAction(action, selector, value, options) { const attempts = this.healingAttempts.get(selector) || 0; try { // First try the original selector return await this.executeAction(action, selector, value, options); } catch (error) { // If original selector fails, try to heal if (this.isSelectorError(error) && attempts < MAX_HEALING_ATTEMPTS) { console.log(`Selector failed: ${selector}, attempting to heal...`); this.healingAttempts.set(selector, attempts + 1); const newHealedSelector = await this.healSelector(selector, { action, value, options }); if (newHealedSelector) { this.selectorMap[selector] = newHealedSelector; this.healingAttempts.delete(selector); // Reset attempts on successful healing this.saveHealedSelectors(); console.log(`Successfully healed selector: ${selector} -> ${newHealedSelector}`); return await this.executeAction(action, newHealedSelector, value, options); } else { throw new Error(`Failed to heal selector after ${attempts + 1} attempts: ${selector}`); } } else { throw error; } } } async executeAction(action, selector, value, options) { switch (action) { case 'click': return await this.page.click(selector, options); case 'fill': return await this.page.fill(selector, value, options); case 'check': return await this.page.check(selector, options); case 'uncheck': return await this.page.uncheck(selector, options); case 'hover': return await this.page.hover(selector, options); case 'type': return await this.page.type(selector, value, options); case 'selectOption': return await this.page.selectOption(selector, value, options); case 'getText': return await this.page.textContent(selector, options); case 'getAttribute': return await this.page.getAttribute(selector, value, options); case 'isVisible': return await this.page.isVisible(selector, options); case 'waitForSelector': return await this.page.waitForSelector(selector, options); default: throw new Error(`Unknown action: ${action}`); } } isSelectorError(error) { const errorMessage = error.message || ''; return errorMessage.includes('No node found') || errorMessage.includes('Timeout') || errorMessage.includes('Element not found') || errorMessage.includes('Selector not found') || errorMessage.includes('Element is not attached to DOM'); } async healSelector(originalSelector, context) { // Reorder strategies based on context let strategies = [...this.healingStrategies]; if (context?.action) { // For form actions, prioritize input elements if (['fill', 'type', 'selectOption'].includes(context.action)) { strategies = strategies.sort((a, b) => { if (a.name === 'ID-based healing') return -1; if (b.name === 'ID-based healing') return 1; if (a.name === 'Attribute-based healing') return -1; if (b.name === 'Attribute-based healing') return 1; return a.priority - b.priority; }); } // For click actions, prioritize button and link elements else if (['click', 'hover'].includes(context.action)) { strategies = strategies.sort((a, b) => { if (a.name === 'ID-based healing') return -1; if (b.name === 'ID-based healing') return 1; if (a.name === 'Text-based healing') return -1; if (b.name === 'Text-based healing') return 1; return a.priority - b.priority; }); } // For text retrieval, prioritize text elements else if (['getText'].includes(context.action)) { strategies = strategies.sort((a, b) => { if (a.name === 'ID-based healing') return -1; if (b.name === 'ID-based healing') return 1; if (a.name === 'Text-based healing') return -1; if (b.name === 'Text-based healing') return 1; return a.priority - b.priority; }); } } for (const strategy of strategies) { try { const healedSelector = await strategy.heal(this.page, originalSelector, context); if (healedSelector) { console.log(`Healed using strategy: ${strategy.name}`); return healedSelector; } } catch (error) { console.log(`Strategy ${strategy.name} failed:`, error.message); } } return null; } async healById(page, originalSelector, context) { return await page.evaluate(({ selector, context }) => { // Extract ID from original selector const idMatch = selector.match(/#([a-zA-Z0-9_-]+)/); if (!idMatch) return null; const originalId = idMatch[1]; const element = document.getElementById(originalId); if (element) return `#${element.id}`; // Try to find elements with similar IDs const allElements = Array.from(document.querySelectorAll('[id]')); const candidates = []; for (const el of allElements) { const currentId = el.id; let score = 0; // Exact match if (currentId === originalId) { score = 100; } // Contains match else if (currentId.includes(originalId) || originalId.includes(currentId)) { score = 50; } // Similar match (more flexible) else if (currentId.replace(/[-_]/g, '') === originalId.replace(/[-_]/g, '')) { score = 30; } // Partial match (for cases like message -> new-message, or similar ID patterns) else if (originalId.length >= 2 && (currentId.includes(originalId.substring(0, Math.max(2, originalId.length - 1))) || currentId.includes(originalId) || originalId.includes(currentId.substring(0, Math.max(2, currentId.length - 1))) || (currentId.length > originalId.length && currentId.includes(originalId)) || (originalId.length > currentId.length && originalId.includes(currentId)))) { score = 20; } // Reverse partial match else if (currentId.length >= 2 && originalId.includes(currentId.substring(0, Math.max(2, currentId.length - 1)))) { score = 20; } if (score > 0) { // Consider context for better matching if (context?.action) { if (['fill', 'type', 'selectOption'].includes(context.action) && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) { score += 20; } else if (['click', 'hover'].includes(context.action) && (el.tagName === 'BUTTON' || el.tagName === 'A')) { score += 20; } else if (['getText'].includes(context.action) && (el.tagName === 'DIV' || el.tagName === 'SPAN' || el.tagName === 'P')) { score += 20; } } candidates.push({ element: el, score }); } } // Try to find elements with similar names or data attributes for (const el of allElements) { const name = el.getAttribute('name'); const dataTestId = el.getAttribute('data-testid'); const dataId = el.getAttribute('data-id'); if ((name && name.includes(originalId)) || (dataTestId && dataTestId.includes(originalId)) || (dataId && dataId.includes(originalId))) { candidates.push({ element: el, score: 25 }); } } // Sort by score and return the best match candidates.sort((a, b) => b.score - a.score); if (candidates.length > 0) { const best = candidates[0]; return best.element.tagName.toLowerCase() + `#${best.element.id}`; } return null; }, { selector: originalSelector, context }); } async healByText(page, originalSelector, context) { return await page.evaluate(({ selector, tags, context }) => { // Extract text from original selector or use context let targetText = ''; const textMatch = selector.match(/text=["']([^"']+)["']/); if (textMatch) { targetText = textMatch[1]; } // If no text selector, try to find elements by their text content for getText actions if (!targetText && context?.action === 'getText') { // For getText actions, look for elements with text content const allElements = Array.from(document.querySelectorAll('*')); const candidates = []; for (const el of allElements) { const text = el.textContent?.trim() || ''; if (text.length > 0 && text.length < 100) { // Reasonable text length let score = 1; // Prioritize common text containers if (el.tagName === 'DIV' || el.tagName === 'SPAN' || el.tagName === 'P') { score = 10; } else if (el.tagName === 'H1' || el.tagName === 'H2' || el.tagName === 'H3') { score = 8; } else if (el.tagName === 'LABEL') { score = 6; } // Skip body, html, and head elements if (el.tagName === 'BODY' || el.tagName === 'HTML' || el.tagName === 'HEAD') { continue; } // Skip invisible elements const style = el.style; if (style.display === 'none' || style.visibility === 'hidden') { continue; } // Skip elements that are too generic (like body or container divs) if (el.tagName === 'BODY' || (el.tagName === 'DIV' && !el.id && !el.className)) { continue; } candidates.push({ element: el, score, text }); } } // Sort by score and return the first candidate candidates.sort((a, b) => b.score - a.score); if (candidates.length > 0) { const best = candidates[0]; // Only heal if the score is reasonably high and the element has some specificity if (best.score >= 3 && (best.element.id || best.element.className)) { return best.element.tagName.toLowerCase() + (best.element.id ? `#${best.element.id}` : '') + (best.element.className ? `.${best.element.className.split(' ').join('.')}` : ''); } } } // Original text selector logic if (!targetText) return null; const candidates = Array.from(document.querySelectorAll(tags.join(','))); for (const el of candidates) { const text = el.textContent?.trim() || ''; if (text.toLowerCase().includes(targetText.toLowerCase()) || targetText.toLowerCase().includes(text.toLowerCase())) { return el.tagName.toLowerCase() + (el.id ? `#${el.id}` : '') + (el.className ? `.${el.className.split(' ').join('.')}` : ''); } } return null; }, { selector: originalSelector, tags: CANDIDATE_TAGS, context }); } async healByAttributes(page, originalSelector) { return await page.evaluate((selector) => { // Extract attributes from original selector const attrMatches = selector.match(/\[([^\]]+)\]/g); if (!attrMatches) return null; const attributes = attrMatches.map(attr => { const match = attr.match(/\[([^=]+)(?:="([^"]+)")?\]/); return { name: match?.[1], value: match?.[2] }; }).filter(attr => attr.name); if (attributes.length === 0) return null; const allElements = Array.from(document.querySelectorAll('*')); for (const el of allElements) { let matches = 0; for (const attr of attributes) { if (attr.value && attr.name) { if (el.getAttribute(attr.name) === attr.value) matches++; } else if (attr.name) { if (el.hasAttribute(attr.name)) matches++; } } if (matches === attributes.length) { return el.tagName.toLowerCase() + (el.id ? `#${el.id}` : '') + (el.className ? `.${el.className.split(' ').join('.')}` : ''); } } return null; }, originalSelector); } async healByPosition(page, originalSelector, context) { return await page.evaluate(({ selector, context }) => { // Try to find elements by their position in the DOM const allElements = Array.from(document.querySelectorAll('*')); const candidates = []; for (const el of allElements) { const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { // Prioritize interactive elements let priority = 1; if (context?.action === 'getText') { // For getText actions, prioritize text containers if (el.tagName === 'DIV' || el.tagName === 'SPAN' || el.tagName === 'P') { priority = 10; } else if (el.tagName === 'H1' || el.tagName === 'H2' || el.tagName === 'H3') { priority = 8; } else if (el.tagName === 'LABEL') { priority = 6; } else if (el.textContent?.trim()) { priority = 5; } } else { // For other actions, prioritize interactive elements if (el.tagName === 'BUTTON' || el.tagName === 'A' || el.tagName === 'INPUT') { priority = 10; } else if (el.tagName === 'DIV' || el.tagName === 'SPAN') { priority = 1; } } // Skip body, html, and head elements if (el.tagName === 'BODY' || el.tagName === 'HTML' || el.tagName === 'HEAD') { continue; } // Skip elements that are not visible or interactive const style = el.style; if (style.display === 'none' || style.visibility === 'hidden') { continue; } // Skip elements that are not in the main content area if (rect.top < 0 || rect.left < 0) { continue; } // Consider context for better matching if (context?.action) { if (['fill', 'type', 'selectOption'].includes(context.action) && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) { priority += 20; // Additional context for input types - prioritize by input type relevance if (el.tagName === 'INPUT') { const inputType = el.type; // Prioritize inputs that are not already used by other elements const allInputs = Array.from(document.querySelectorAll('input')); const visibleInputs = allInputs.filter(input => { const inputRect = input.getBoundingClientRect(); return inputRect.width > 0 && inputRect.height > 0; }); // Count how many inputs of this type we've seen const inputsOfSameType = visibleInputs.filter(input => input.type === inputType); // Give higher priority to less common input types if (inputsOfSameType.length === 1) { priority += 25; // Higher priority for unique input types } else if (inputsOfSameType.length === 2) { priority += 10; // Medium priority for second occurrence } else { priority -= 10; // Lower priority for common input types } // Additional priority based on input type diversity // If we have multiple input types, prioritize the one that's less common const allVisibleInputs = Array.from(document.querySelectorAll('input')).filter(input => { const rect = input.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }); const inputTypeCounts = {}; allVisibleInputs.forEach(input => { const type = input.type; inputTypeCounts[type] = (inputTypeCounts[type] || 0) + 1; }); // If this input type is less common, give it higher priority const currentTypeCount = inputTypeCounts[inputType] || 0; if (currentTypeCount === 1) { priority += 35; // Much higher priority for unique input types } else if (currentTypeCount === 2) { priority += 15; // Medium priority for second occurrence } } } else if (['click', 'hover'].includes(context.action) && (el.tagName === 'BUTTON' || el.tagName === 'A')) { priority += 20; } else if (['getText'].includes(context.action) && (el.tagName === 'DIV' || el.tagName === 'SPAN' || el.tagName === 'P')) { priority += 20; } } candidates.push({ element: el, area: rect.width * rect.height, tagName: el.tagName.toLowerCase(), priority }); } } // Sort by priority first, then by area candidates.sort((a, b) => { if (a.priority !== b.priority) { return b.priority - a.priority; } return b.area - a.area; }); if (candidates.length > 0) { const best = candidates[0]; const classes = best.element.className ? best.element.className.split(' ') .filter(c => c.length > 0) .filter(c => /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(c)) // Only valid CSS class names .slice(0, 3) // Limit to 3 classes .join('.') : ''; return best.tagName + (best.element.id ? `#${best.element.id}` : '') + (classes ? `.${classes}` : ''); } return null; }, { selector: originalSelector, context }); } async healBySimilarity(page, originalSelector) { return await page.evaluate(({ selector, tags, threshold }) => { function getSelector(el) { if (el.id) return `#${el.id}`; if (el.className) { const classes = el.className.trim().split(/\s+/).filter(c => c.length > 0); return classes.length > 0 ? `${el.tagName.toLowerCase()}.${classes.join('.')}` : el.tagName.toLowerCase(); } return el.tagName.toLowerCase(); } function getSimilarity(a, b) { if (!a || !b) return 0; const la = a.toLowerCase(); const lb = b.toLowerCase(); if (la === lb) return 1; if (la.includes(lb) || lb.includes(la)) return 0.8; // Simple similarity calculation const longer = la.length > lb.length ? la : lb; const shorter = la.length <= lb.length ? la : lb; return (longer.includes(shorter)) ? (shorter.length / longer.length) : 0; } // Extract target text from selector let targetText = ''; const textMatch = selector.match(/text=["']([^"']+)["']/); if (textMatch) { targetText = textMatch[1]; } else { // Try to extract from ID or class const idMatch = selector.match(/#([a-zA-Z0-9_-]+)/); if (idMatch) { targetText = idMatch[1].replace(/[-_]/g, ' '); } } // If no target text found, try to find elements with similar IDs if (!targetText) { const idMatch = selector.match(/#([a-zA-Z0-9_-]+)/); if (idMatch) { const originalId = idMatch[1]; const allElements = Array.from(document.querySelectorAll('[id]')); for (const el of allElements) { const currentId = el.id; if (currentId.includes(originalId) || originalId.includes(currentId)) { return el.tagName.toLowerCase() + `#${el.id}`; } } } } const candidates = Array.from(document.querySelectorAll(tags.join(','))).map(el => { const text = el.textContent?.trim() || ''; const title = el.getAttribute('title') || ''; const alt = el.getAttribute('alt') || ''; const ariaLabel = el.getAttribute('aria-label') || ''; const placeholder = el.getAttribute('placeholder') || ''; const name = el.getAttribute('name') || ''; const score = Math.max(getSimilarity(text, targetText), getSimilarity(title, targetText), getSimilarity(alt, targetText), getSimilarity(ariaLabel, targetText), getSimilarity(placeholder, targetText), getSimilarity(name, targetText)); return { el, score }; }).filter(c => c.score > threshold).sort((a, b) => b.score - a.score); return candidates.length ? getSelector(candidates[0].el) : null; }, { selector: originalSelector, tags: CANDIDATE_TAGS, threshold: SIMILARITY_THRESHOLD }); } async healByXPath(page, originalSelector, context) { return await page.evaluate(({ selector, context }) => { // Check if the selector is already an XPath const isXPath = selector.startsWith('//') || selector.startsWith('./') || selector.startsWith('/'); if (!isXPath) { // If it's not an XPath, try to convert CSS selector to XPath return null; } function evaluateXPath(xpath) { try { const result = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); const elements = []; for (let i = 0; i < result.snapshotLength; i++) { const node = result.snapshotItem(i); if (node && node.nodeType === Node.ELEMENT_NODE) { elements.push(node); } } return elements; } catch (error) { return []; } } function generateXPathVariations(originalXPath) { const variations = []; // Original XPath variations.push(originalXPath); // Extract components from XPath const tagMatch = originalXPath.match(/\/([a-zA-Z][a-zA-Z0-9]*)/); const textMatch = originalXPath.match(/text\(\)\s*=\s*["']([^"']+)["']/); const containsTextMatch = originalXPath.match(/contains\(text\(\),\s*["']([^"']+)["']\)/); const attributeMatch = originalXPath.match(/@([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*["']([^"']+)["']/); const containsAttributeMatch = originalXPath.match(/contains\(@([a-zA-Z][a-zA-Z0-9-]*),\s*["']([^"']+)["']\)/); const tag = tagMatch ? tagMatch[1] : '*'; const text = textMatch ? textMatch[1] : (containsTextMatch ? containsTextMatch[1] : ''); const attribute = attributeMatch ? attributeMatch[1] : (containsAttributeMatch ? containsAttributeMatch[1] : ''); const attributeValue = attributeMatch ? attributeMatch[2] : (containsAttributeMatch ? containsAttributeMatch[2] : ''); // Generate variations based on components if (text) { // Text-based variations variations.push(`//${tag}[contains(text(), "${text}")]`); variations.push(`//${tag}[text() = "${text}"]`); variations.push(`//*[contains(text(), "${text}")]`); variations.push(`//${tag}[contains(., "${text}")]`); // More flexible text matching variations.push(`//${tag}[contains(translate(text(), "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), "${text.toLowerCase()}")]`); variations.push(`//*[contains(translate(text(), "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), "${text.toLowerCase()}")]`); } if (attribute && attributeValue) { // Attribute-based variations variations.push(`//${tag}[@${attribute} = "${attributeValue}"]`); variations.push(`//${tag}[contains(@${attribute}, "${attributeValue}")]`); variations.push(`//*[@${attribute} = "${attributeValue}"]`); } if (attribute) { // Attribute existence variations variations.push(`//${tag}[@${attribute}]`); variations.push(`//*[@${attribute}]`); } // Position-based variations variations.push(`//${tag}[1]`); variations.push(`//${tag}[position() = 1]`); // Parent-child variations variations.push(`//*//${tag}`); variations.push(`//${tag}[1]//*`); // Context-aware variations based on action if (context?.action) { if (['fill', 'type', 'selectOption'].includes(context.action)) { variations.push(`//input[contains(text(), "${text}")]`); variations.push(`//textarea[contains(text(), "${text}")]`); variations.push(`//select[contains(text(), "${text}")]`); } else if (['click', 'hover'].includes(context.action)) { variations.push(`//button[contains(text(), "${text}")]`); variations.push(`//a[contains(text(), "${text}")]`); variations.push(`//input[@type="button" and contains(@value, "${text}")]`); // For click actions, also try to find by ID or class if (tag === 'a') { variations.push(`//a[@id]`); variations.push(`//a[@class]`); variations.push(`//a[@href]`); } else if (tag === 'button') { variations.push(`//button[@id]`); variations.push(`//button[@class]`); variations.push(`//button[@type]`); } } else if (['getText'].includes(context.action)) { variations.push(`//div[contains(text(), "${text}")]`); variations.push(`//span[contains(text(), "${text}")]`); variations.push(`//p[contains(text(), "${text}")]`); variations.push(`//h1[contains(text(), "${text}")]`); variations.push(`//h2[contains(text(), "${text}")]`); variations.push(`//h3[contains(text(), "${text}")]`); } } return variations; } function xpathToCSS(xpath) { try { const elements = evaluateXPath(xpath); if (elements.length > 0) { const element = elements[0]; if (element.id) { return `#${element.id}`; } else if (element.className) { const classes = element.className.trim().split(/\s+/) .filter(c => c.length > 0) .filter(c => /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(c)) // Only valid CSS class names .slice(0, 3); // Limit to 3 classes to avoid invalid selectors return classes.length > 0 ? `${element.tagName.toLowerCase()}.${classes.join('.')}` : element.tagName.toLowerCase(); } else { return element.tagName.toLowerCase(); } } } catch (error) { // Ignore conversion errors } return ''; } // Try original XPath first let elements = evaluateXPath(selector); if (elements.length > 0) { return xpathToCSS(selector); } // Generate and try variations const variations = generateXPathVariations(selector); for (const variation of variations) { elements = evaluateXPath(variation); if (elements.length > 0) { const cssSelector = xpathToCSS(variation); if (cssSelector) { return cssSelector; } } } // If no XPath variations work, try to find similar elements by text content if (context?.action === 'getText') { const allElements = Array.from(document.querySelectorAll('*')); for (const el of allElements) { const text = el.textContent?.trim() || ''; if (text.length > 0 && text.length < 100) { // Check if this element might be what we're looking for if (el.tagName === 'DIV' || el.tagName === 'SPAN' || el.tagName === 'P' || el.tagName === 'H1' || el.tagName === 'H2' || el.tagName === 'H3') { if (el.id) { return `#${el.id}`; } else if (el.className) { const classes = el.className.trim().split(/\s+/) .filter((c) => c.length > 0) .filter((c) => /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(c)) // Only valid CSS class names .slice(0, 3); // Limit to 3 classes return classes.length > 0 ? `${el.tagName.toLowerCase()}.${classes.join('.')}` : el.tagName.toLowerCase(); } } } } } // Fallback: Try to find elements by tag and attributes for click actions if (context?.action === 'click') { const tagMatch = selector.match(/\/([a-zA-Z][a-zA-Z0-9]*)/); const tag = tagMatch ? tagMatch[1] : 'a'; const allElements = Array.from(document.querySelectorAll(tag)); for (const el of allElements) { if (el.id) { return `#${el.id}`; } else if (el.className) { const classes = el.className.trim().split(/\s+/) .filter((c) => c.length > 0) .filter((c) => /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(c)) // Only valid CSS class names .slice(0, 3); // Limit to 3 classes return classes.length > 0 ? `${el.tagName.toLowerCase()}.${classes.join('.')}` : el.tagName.toLowerCase(); } } } return null; }, { selector: originalSelector, context }); } // Utility methods async getHealedSelectors() { return { ...this.selectorMap }; } async clearHealedSelectors() { this.selectorMap = {}; this.healingAttempts.clear(); this.saveHealedSelectors(); } async clearHealingAttempts() { this.healingAttempts.clear(); } async exportHealedSelectors(filePath) { const path = filePath || STORE_PATH; fs_1.default.writeFileSync(path, JSON.stringify(this.selectorMap, null, 2)); } async importHealedSelectors(filePath) { const data = fs_1.default.readFileSync(filePath, 'utf-8'); this.selectorMap = { ...this.selectorMap, ...JSON.parse(data) }; this.saveHealedSelectors(); } loadHealedSelectors() { try { if (fs_1.default.existsSync(STORE_PATH)) { return JSON.parse(fs_1.default.readFileSync(STORE_PATH, 'utf-8')); } } catch (error) { console.warn('Failed to load healed selectors:', error.message); } return {}; } saveHealedSelectors() { try { const dir = path_1.default.dirname(STORE_PATH); if (!fs_1.default.existsSync(dir)) { fs_1.default.mkdirSync(dir, { recursive: true }); } fs_1.default.writeFileSync(STORE_PATH, JSON.stringify(this.selectorMap, null, 2)); } catch (error) { console.warn('Failed to save healed selectors:', error.message); } } } exports.HealingPage = HealingPage; //# sourceMappingURL=healingPage.js.map