mindheal 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +48 -0
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +481 -0
- package/dist/cjs/ai/ai-provider.js +46 -0
- package/dist/cjs/ai/ai-provider.js.map +1 -0
- package/dist/cjs/ai/anthropic-provider.js +106 -0
- package/dist/cjs/ai/anthropic-provider.js.map +1 -0
- package/dist/cjs/ai/azure-openai-provider.js +130 -0
- package/dist/cjs/ai/azure-openai-provider.js.map +1 -0
- package/dist/cjs/ai/bedrock-provider.js +183 -0
- package/dist/cjs/ai/bedrock-provider.js.map +1 -0
- package/dist/cjs/ai/deepseek-provider.js +118 -0
- package/dist/cjs/ai/deepseek-provider.js.map +1 -0
- package/dist/cjs/ai/gemini-provider.js +129 -0
- package/dist/cjs/ai/gemini-provider.js.map +1 -0
- package/dist/cjs/ai/groq-provider.js +118 -0
- package/dist/cjs/ai/groq-provider.js.map +1 -0
- package/dist/cjs/ai/meta-provider.js +118 -0
- package/dist/cjs/ai/meta-provider.js.map +1 -0
- package/dist/cjs/ai/ollama-provider.js +127 -0
- package/dist/cjs/ai/ollama-provider.js.map +1 -0
- package/dist/cjs/ai/openai-provider.js +117 -0
- package/dist/cjs/ai/openai-provider.js.map +1 -0
- package/dist/cjs/ai/perplexity-provider.js +118 -0
- package/dist/cjs/ai/perplexity-provider.js.map +1 -0
- package/dist/cjs/ai/prompt-templates.js +174 -0
- package/dist/cjs/ai/prompt-templates.js.map +1 -0
- package/dist/cjs/ai/qwen-provider.js +118 -0
- package/dist/cjs/ai/qwen-provider.js.map +1 -0
- package/dist/cjs/analytics/healing-analytics.js +263 -0
- package/dist/cjs/analytics/healing-analytics.js.map +1 -0
- package/dist/cjs/cli/init.js +517 -0
- package/dist/cjs/cli/init.js.map +1 -0
- package/dist/cjs/config/config-loader.js +135 -0
- package/dist/cjs/config/config-loader.js.map +1 -0
- package/dist/cjs/config/defaults.js +109 -0
- package/dist/cjs/config/defaults.js.map +1 -0
- package/dist/cjs/core/dom-snapshot.js +280 -0
- package/dist/cjs/core/dom-snapshot.js.map +1 -0
- package/dist/cjs/core/enterprise-strategy.js +702 -0
- package/dist/cjs/core/enterprise-strategy.js.map +1 -0
- package/dist/cjs/core/healer.js +283 -0
- package/dist/cjs/core/healer.js.map +1 -0
- package/dist/cjs/core/interceptor.js +945 -0
- package/dist/cjs/core/interceptor.js.map +1 -0
- package/dist/cjs/core/locator-analyzer.js +172 -0
- package/dist/cjs/core/locator-analyzer.js.map +1 -0
- package/dist/cjs/core/locator-strategies.js +891 -0
- package/dist/cjs/core/locator-strategies.js.map +1 -0
- package/dist/cjs/core/self-heal-cache.js +178 -0
- package/dist/cjs/core/self-heal-cache.js.map +1 -0
- package/dist/cjs/core/smart-retry.js +248 -0
- package/dist/cjs/core/smart-retry.js.map +1 -0
- package/dist/cjs/core/visual-verification.js +262 -0
- package/dist/cjs/core/visual-verification.js.map +1 -0
- package/dist/cjs/git/code-modifier.js +184 -0
- package/dist/cjs/git/code-modifier.js.map +1 -0
- package/dist/cjs/git/git-operations.js +145 -0
- package/dist/cjs/git/git-operations.js.map +1 -0
- package/dist/cjs/git/pr-creator.js +190 -0
- package/dist/cjs/git/pr-creator.js.map +1 -0
- package/dist/cjs/index.js +97 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/rag/context-retriever.js +289 -0
- package/dist/cjs/rag/context-retriever.js.map +1 -0
- package/dist/cjs/rag/embeddings.js +82 -0
- package/dist/cjs/rag/embeddings.js.map +1 -0
- package/dist/cjs/rag/knowledge-store.js +159 -0
- package/dist/cjs/rag/knowledge-store.js.map +1 -0
- package/dist/cjs/reporters/heal-report.js +279 -0
- package/dist/cjs/reporters/heal-report.js.map +1 -0
- package/dist/cjs/reporters/heal-reporter.js +294 -0
- package/dist/cjs/reporters/heal-reporter.js.map +1 -0
- package/dist/cjs/server/review-server.js +166 -0
- package/dist/cjs/server/review-server.js.map +1 -0
- package/dist/cjs/server/routes.js +92 -0
- package/dist/cjs/server/routes.js.map +1 -0
- package/dist/cjs/utils/environment.js +57 -0
- package/dist/cjs/utils/environment.js.map +1 -0
- package/dist/cjs/utils/file-lock.js +136 -0
- package/dist/cjs/utils/file-lock.js.map +1 -0
- package/dist/cjs/utils/file-utils.js +49 -0
- package/dist/cjs/utils/file-utils.js.map +1 -0
- package/dist/cjs/utils/logger.js +78 -0
- package/dist/cjs/utils/logger.js.map +1 -0
- package/dist/esm/ai/ai-provider.js +44 -0
- package/dist/esm/ai/ai-provider.js.map +1 -0
- package/dist/esm/ai/anthropic-provider.js +104 -0
- package/dist/esm/ai/anthropic-provider.js.map +1 -0
- package/dist/esm/ai/azure-openai-provider.js +128 -0
- package/dist/esm/ai/azure-openai-provider.js.map +1 -0
- package/dist/esm/ai/bedrock-provider.js +181 -0
- package/dist/esm/ai/bedrock-provider.js.map +1 -0
- package/dist/esm/ai/deepseek-provider.js +116 -0
- package/dist/esm/ai/deepseek-provider.js.map +1 -0
- package/dist/esm/ai/gemini-provider.js +127 -0
- package/dist/esm/ai/gemini-provider.js.map +1 -0
- package/dist/esm/ai/groq-provider.js +116 -0
- package/dist/esm/ai/groq-provider.js.map +1 -0
- package/dist/esm/ai/meta-provider.js +116 -0
- package/dist/esm/ai/meta-provider.js.map +1 -0
- package/dist/esm/ai/ollama-provider.js +125 -0
- package/dist/esm/ai/ollama-provider.js.map +1 -0
- package/dist/esm/ai/openai-provider.js +115 -0
- package/dist/esm/ai/openai-provider.js.map +1 -0
- package/dist/esm/ai/perplexity-provider.js +116 -0
- package/dist/esm/ai/perplexity-provider.js.map +1 -0
- package/dist/esm/ai/prompt-templates.js +171 -0
- package/dist/esm/ai/prompt-templates.js.map +1 -0
- package/dist/esm/ai/qwen-provider.js +116 -0
- package/dist/esm/ai/qwen-provider.js.map +1 -0
- package/dist/esm/analytics/healing-analytics.js +261 -0
- package/dist/esm/analytics/healing-analytics.js.map +1 -0
- package/dist/esm/cli/init.js +495 -0
- package/dist/esm/cli/init.js.map +1 -0
- package/dist/esm/config/config-loader.js +132 -0
- package/dist/esm/config/config-loader.js.map +1 -0
- package/dist/esm/config/defaults.js +107 -0
- package/dist/esm/config/defaults.js.map +1 -0
- package/dist/esm/core/dom-snapshot.js +278 -0
- package/dist/esm/core/dom-snapshot.js.map +1 -0
- package/dist/esm/core/enterprise-strategy.js +695 -0
- package/dist/esm/core/enterprise-strategy.js.map +1 -0
- package/dist/esm/core/healer.js +281 -0
- package/dist/esm/core/healer.js.map +1 -0
- package/dist/esm/core/interceptor.js +940 -0
- package/dist/esm/core/interceptor.js.map +1 -0
- package/dist/esm/core/locator-analyzer.js +169 -0
- package/dist/esm/core/locator-analyzer.js.map +1 -0
- package/dist/esm/core/locator-strategies.js +882 -0
- package/dist/esm/core/locator-strategies.js.map +1 -0
- package/dist/esm/core/self-heal-cache.js +176 -0
- package/dist/esm/core/self-heal-cache.js.map +1 -0
- package/dist/esm/core/smart-retry.js +246 -0
- package/dist/esm/core/smart-retry.js.map +1 -0
- package/dist/esm/core/visual-verification.js +260 -0
- package/dist/esm/core/visual-verification.js.map +1 -0
- package/dist/esm/git/code-modifier.js +182 -0
- package/dist/esm/git/code-modifier.js.map +1 -0
- package/dist/esm/git/git-operations.js +143 -0
- package/dist/esm/git/git-operations.js.map +1 -0
- package/dist/esm/git/pr-creator.js +188 -0
- package/dist/esm/git/pr-creator.js.map +1 -0
- package/dist/esm/index.js +37 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/rag/context-retriever.js +287 -0
- package/dist/esm/rag/context-retriever.js.map +1 -0
- package/dist/esm/rag/embeddings.js +77 -0
- package/dist/esm/rag/embeddings.js.map +1 -0
- package/dist/esm/rag/knowledge-store.js +157 -0
- package/dist/esm/rag/knowledge-store.js.map +1 -0
- package/dist/esm/reporters/heal-report.js +277 -0
- package/dist/esm/reporters/heal-report.js.map +1 -0
- package/dist/esm/reporters/heal-reporter.js +290 -0
- package/dist/esm/reporters/heal-reporter.js.map +1 -0
- package/dist/esm/server/review-server.js +164 -0
- package/dist/esm/server/review-server.js.map +1 -0
- package/dist/esm/server/routes.js +90 -0
- package/dist/esm/server/routes.js.map +1 -0
- package/dist/esm/utils/environment.js +53 -0
- package/dist/esm/utils/environment.js.map +1 -0
- package/dist/esm/utils/file-lock.js +134 -0
- package/dist/esm/utils/file-lock.js.map +1 -0
- package/dist/esm/utils/file-utils.js +43 -0
- package/dist/esm/utils/file-utils.js.map +1 -0
- package/dist/esm/utils/logger.js +75 -0
- package/dist/esm/utils/logger.js.map +1 -0
- package/dist/types/ai/ai-provider.d.ts +4 -0
- package/dist/types/ai/ai-provider.d.ts.map +1 -0
- package/dist/types/ai/anthropic-provider.d.ts +11 -0
- package/dist/types/ai/anthropic-provider.d.ts.map +1 -0
- package/dist/types/ai/azure-openai-provider.d.ts +13 -0
- package/dist/types/ai/azure-openai-provider.d.ts.map +1 -0
- package/dist/types/ai/bedrock-provider.d.ts +14 -0
- package/dist/types/ai/bedrock-provider.d.ts.map +1 -0
- package/dist/types/ai/deepseek-provider.d.ts +12 -0
- package/dist/types/ai/deepseek-provider.d.ts.map +1 -0
- package/dist/types/ai/gemini-provider.d.ts +12 -0
- package/dist/types/ai/gemini-provider.d.ts.map +1 -0
- package/dist/types/ai/groq-provider.d.ts +12 -0
- package/dist/types/ai/groq-provider.d.ts.map +1 -0
- package/dist/types/ai/meta-provider.d.ts +12 -0
- package/dist/types/ai/meta-provider.d.ts.map +1 -0
- package/dist/types/ai/ollama-provider.d.ts +10 -0
- package/dist/types/ai/ollama-provider.d.ts.map +1 -0
- package/dist/types/ai/openai-provider.d.ts +11 -0
- package/dist/types/ai/openai-provider.d.ts.map +1 -0
- package/dist/types/ai/perplexity-provider.d.ts +12 -0
- package/dist/types/ai/perplexity-provider.d.ts.map +1 -0
- package/dist/types/ai/prompt-templates.d.ts +11 -0
- package/dist/types/ai/prompt-templates.d.ts.map +1 -0
- package/dist/types/ai/qwen-provider.d.ts +12 -0
- package/dist/types/ai/qwen-provider.d.ts.map +1 -0
- package/dist/types/analytics/healing-analytics.d.ts +36 -0
- package/dist/types/analytics/healing-analytics.d.ts.map +1 -0
- package/dist/types/cli/init.d.ts +15 -0
- package/dist/types/cli/init.d.ts.map +1 -0
- package/dist/types/config/config-loader.d.ts +4 -0
- package/dist/types/config/config-loader.d.ts.map +1 -0
- package/dist/types/config/defaults.d.ts +3 -0
- package/dist/types/config/defaults.d.ts.map +1 -0
- package/dist/types/core/dom-snapshot.d.ts +12 -0
- package/dist/types/core/dom-snapshot.d.ts.map +1 -0
- package/dist/types/core/enterprise-strategy.d.ts +56 -0
- package/dist/types/core/enterprise-strategy.d.ts.map +1 -0
- package/dist/types/core/healer.d.ts +52 -0
- package/dist/types/core/healer.d.ts.map +1 -0
- package/dist/types/core/interceptor.d.ts +64 -0
- package/dist/types/core/interceptor.d.ts.map +1 -0
- package/dist/types/core/locator-analyzer.d.ts +31 -0
- package/dist/types/core/locator-analyzer.d.ts.map +1 -0
- package/dist/types/core/locator-strategies.d.ts +45 -0
- package/dist/types/core/locator-strategies.d.ts.map +1 -0
- package/dist/types/core/self-heal-cache.d.ts +51 -0
- package/dist/types/core/self-heal-cache.d.ts.map +1 -0
- package/dist/types/core/smart-retry.d.ts +64 -0
- package/dist/types/core/smart-retry.d.ts.map +1 -0
- package/dist/types/core/visual-verification.d.ts +46 -0
- package/dist/types/core/visual-verification.d.ts.map +1 -0
- package/dist/types/git/code-modifier.d.ts +51 -0
- package/dist/types/git/code-modifier.d.ts.map +1 -0
- package/dist/types/git/git-operations.d.ts +40 -0
- package/dist/types/git/git-operations.d.ts.map +1 -0
- package/dist/types/git/pr-creator.d.ts +27 -0
- package/dist/types/git/pr-creator.d.ts.map +1 -0
- package/dist/types/index.d.ts +40 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/rag/context-retriever.d.ts +69 -0
- package/dist/types/rag/context-retriever.d.ts.map +1 -0
- package/dist/types/rag/embeddings.d.ts +32 -0
- package/dist/types/rag/embeddings.d.ts.map +1 -0
- package/dist/types/rag/index.d.ts +12 -0
- package/dist/types/rag/index.d.ts.map +1 -0
- package/dist/types/rag/knowledge-store.d.ts +38 -0
- package/dist/types/rag/knowledge-store.d.ts.map +1 -0
- package/dist/types/reporters/heal-report.d.ts +29 -0
- package/dist/types/reporters/heal-report.d.ts.map +1 -0
- package/dist/types/reporters/heal-reporter.d.ts +49 -0
- package/dist/types/reporters/heal-reporter.d.ts.map +1 -0
- package/dist/types/server/review-server.d.ts +20 -0
- package/dist/types/server/review-server.d.ts.map +1 -0
- package/dist/types/server/routes.d.ts +4 -0
- package/dist/types/server/routes.d.ts.map +1 -0
- package/dist/types/types/index.d.ts +433 -0
- package/dist/types/types/index.d.ts.map +1 -0
- package/dist/types/utils/environment.d.ts +10 -0
- package/dist/types/utils/environment.d.ts.map +1 -0
- package/dist/types/utils/file-lock.d.ts +37 -0
- package/dist/types/utils/file-lock.d.ts.map +1 -0
- package/dist/types/utils/file-utils.d.ts +7 -0
- package/dist/types/utils/file-utils.d.ts.map +1 -0
- package/dist/types/utils/logger.d.ts +9 -0
- package/dist/types/utils/logger.d.ts.map +1 -0
- package/package.json +106 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
import { logger } from '../utils/logger.js';
|
|
2
|
+
import { enterpriseStrategy } from './enterprise-strategy.js';
|
|
3
|
+
|
|
4
|
+
// ─── String Similarity ─────────────────────────────────────────────────────────
|
|
5
|
+
/**
|
|
6
|
+
* Computes the Levenshtein edit distance between two strings.
|
|
7
|
+
*/
|
|
8
|
+
function levenshteinDistance(a, b) {
|
|
9
|
+
const la = a.length;
|
|
10
|
+
const lb = b.length;
|
|
11
|
+
if (la === 0)
|
|
12
|
+
return lb;
|
|
13
|
+
if (lb === 0)
|
|
14
|
+
return la;
|
|
15
|
+
// Use a single-row DP approach for space efficiency
|
|
16
|
+
const prev = new Array(lb + 1);
|
|
17
|
+
for (let j = 0; j <= lb; j++)
|
|
18
|
+
prev[j] = j;
|
|
19
|
+
for (let i = 1; i <= la; i++) {
|
|
20
|
+
let prevDiag = prev[0];
|
|
21
|
+
prev[0] = i;
|
|
22
|
+
for (let j = 1; j <= lb; j++) {
|
|
23
|
+
const temp = prev[j];
|
|
24
|
+
if (a[i - 1] === b[j - 1]) {
|
|
25
|
+
prev[j] = prevDiag;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
prev[j] = 1 + Math.min(prevDiag, prev[j], prev[j - 1]);
|
|
29
|
+
}
|
|
30
|
+
prevDiag = temp;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return prev[lb];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Returns a similarity score between 0 and 1 (1 = identical).
|
|
37
|
+
*/
|
|
38
|
+
function stringSimilarity(a, b) {
|
|
39
|
+
if (a === b)
|
|
40
|
+
return 1;
|
|
41
|
+
if (a.length === 0 || b.length === 0)
|
|
42
|
+
return 0;
|
|
43
|
+
const maxLen = Math.max(a.length, b.length);
|
|
44
|
+
const distance = levenshteinDistance(a.toLowerCase(), b.toLowerCase());
|
|
45
|
+
return 1 - distance / maxLen;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Extracts candidate elements from the DOM snapshot HTML using page evaluation.
|
|
49
|
+
* This gathers all interactive or visible elements as potential healing targets.
|
|
50
|
+
*/
|
|
51
|
+
async function extractCandidates(page) {
|
|
52
|
+
try {
|
|
53
|
+
return await page.evaluate(() => {
|
|
54
|
+
const selectors = [
|
|
55
|
+
'a', 'button', 'input', 'select', 'textarea',
|
|
56
|
+
'[role]', '[data-testid]', '[data-test-id]', '[data-test]',
|
|
57
|
+
'[id]', '[aria-label]', 'label', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
58
|
+
'li', 'td', 'th', 'span', 'div', 'p',
|
|
59
|
+
// Modal/dialog elements
|
|
60
|
+
'dialog', '[role="dialog"]', '[role="alertdialog"]',
|
|
61
|
+
'[aria-modal="true"]', '.modal', '.popup', '.overlay',
|
|
62
|
+
// Table elements
|
|
63
|
+
'table', 'thead', 'tbody', 'tr', 'caption',
|
|
64
|
+
];
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
const results = [];
|
|
67
|
+
function processElement(el) {
|
|
68
|
+
if (seen.has(el) || results.length >= 500)
|
|
69
|
+
return;
|
|
70
|
+
seen.add(el);
|
|
71
|
+
// Skip invisible elements
|
|
72
|
+
try {
|
|
73
|
+
const style = window.getComputedStyle(el);
|
|
74
|
+
if (style.display === 'none' || style.visibility === 'hidden')
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// getComputedStyle may throw for disconnected elements
|
|
79
|
+
}
|
|
80
|
+
const attrs = {};
|
|
81
|
+
for (let ai = 0; ai < el.attributes.length; ai++) {
|
|
82
|
+
const attr = el.attributes[ai];
|
|
83
|
+
attrs[attr.name] = attr.value;
|
|
84
|
+
}
|
|
85
|
+
// Direct text content (excluding child element text)
|
|
86
|
+
let directText = '';
|
|
87
|
+
for (let ni = 0; ni < el.childNodes.length; ni++) {
|
|
88
|
+
const node = el.childNodes[ni];
|
|
89
|
+
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
|
|
90
|
+
const trimmed = node.textContent.trim();
|
|
91
|
+
if (trimmed) {
|
|
92
|
+
directText += (directText ? ' ' : '') + trimmed;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
directText = directText.substring(0, 200);
|
|
97
|
+
// Detect modal/dialog context
|
|
98
|
+
let isInModal = false;
|
|
99
|
+
let modalRole = '';
|
|
100
|
+
let ancestor = el;
|
|
101
|
+
while (ancestor) {
|
|
102
|
+
const tag = ancestor.tagName.toLowerCase();
|
|
103
|
+
const role = ancestor.getAttribute('role') || '';
|
|
104
|
+
const ariaModal = ancestor.getAttribute('aria-modal');
|
|
105
|
+
if (tag === 'dialog' ||
|
|
106
|
+
role === 'dialog' ||
|
|
107
|
+
role === 'alertdialog' ||
|
|
108
|
+
ariaModal === 'true') {
|
|
109
|
+
isInModal = true;
|
|
110
|
+
modalRole = role || (tag === 'dialog' ? 'dialog' : '');
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
// Common modal class patterns
|
|
114
|
+
const cls = ancestor.className && typeof ancestor.className === 'string'
|
|
115
|
+
? ancestor.className.toLowerCase()
|
|
116
|
+
: '';
|
|
117
|
+
if (cls.includes('modal') || cls.includes('popup') || cls.includes('overlay') || cls.includes('dialog')) {
|
|
118
|
+
isInModal = true;
|
|
119
|
+
modalRole = role || 'dialog';
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
ancestor = ancestor.parentElement;
|
|
123
|
+
}
|
|
124
|
+
// Detect table context
|
|
125
|
+
let isInTable = false;
|
|
126
|
+
let tableRow = -1;
|
|
127
|
+
let tableCol = -1;
|
|
128
|
+
let tableHeaderText = '';
|
|
129
|
+
let tableCellSelector = '';
|
|
130
|
+
const elTag = el.tagName.toLowerCase();
|
|
131
|
+
if (elTag === 'td' || elTag === 'th') {
|
|
132
|
+
isInTable = true;
|
|
133
|
+
const cell = el;
|
|
134
|
+
tableCol = cell.cellIndex;
|
|
135
|
+
const row = cell.parentElement;
|
|
136
|
+
if (row) {
|
|
137
|
+
tableRow = row.rowIndex;
|
|
138
|
+
// Try to find the corresponding header for this column
|
|
139
|
+
const table = row.closest('table');
|
|
140
|
+
if (table) {
|
|
141
|
+
const headerRow = table.querySelector('thead tr') || table.querySelector('tr');
|
|
142
|
+
if (headerRow) {
|
|
143
|
+
const headers = headerRow.querySelectorAll('th');
|
|
144
|
+
if (headers.length > tableCol && tableCol >= 0) {
|
|
145
|
+
tableHeaderText = headers[tableCol].textContent?.trim().substring(0, 100) || '';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Build a table cell selector
|
|
149
|
+
tableCellSelector = `table >> tr:nth-child(${tableRow + 1}) >> td:nth-child(${tableCol + 1})`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// Check if the element is inside a table cell
|
|
155
|
+
const closestCell = el.closest('td, th');
|
|
156
|
+
if (closestCell) {
|
|
157
|
+
isInTable = true;
|
|
158
|
+
const cell = closestCell;
|
|
159
|
+
tableCol = cell.cellIndex;
|
|
160
|
+
const row = cell.parentElement;
|
|
161
|
+
if (row) {
|
|
162
|
+
tableRow = row.rowIndex;
|
|
163
|
+
const table = row.closest('table');
|
|
164
|
+
if (table) {
|
|
165
|
+
const headerRow = table.querySelector('thead tr') || table.querySelector('tr');
|
|
166
|
+
if (headerRow) {
|
|
167
|
+
const headers = headerRow.querySelectorAll('th');
|
|
168
|
+
if (headers.length > tableCol && tableCol >= 0) {
|
|
169
|
+
tableHeaderText = headers[tableCol].textContent?.trim().substring(0, 100) || '';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
results.push({
|
|
177
|
+
tag: elTag,
|
|
178
|
+
id: el.id || '',
|
|
179
|
+
classes: el.className && typeof el.className === 'string'
|
|
180
|
+
? el.className.split(/\s+/).filter(Boolean)
|
|
181
|
+
: [],
|
|
182
|
+
text: directText || el.textContent?.trim().substring(0, 200) || '',
|
|
183
|
+
attributes: attrs,
|
|
184
|
+
role: el.getAttribute('role') || '',
|
|
185
|
+
ariaLabel: el.getAttribute('aria-label') || '',
|
|
186
|
+
testId: el.getAttribute('data-testid') ??
|
|
187
|
+
el.getAttribute('data-test-id') ??
|
|
188
|
+
el.getAttribute('data-test') ??
|
|
189
|
+
'',
|
|
190
|
+
placeholder: el.getAttribute('placeholder') || '',
|
|
191
|
+
name: el.getAttribute('name') || '',
|
|
192
|
+
isInModal,
|
|
193
|
+
modalRole,
|
|
194
|
+
isInTable,
|
|
195
|
+
tableRow,
|
|
196
|
+
tableCol,
|
|
197
|
+
tableHeaderText,
|
|
198
|
+
tableCellSelector,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Recursively collects all shadow roots in the document tree.
|
|
203
|
+
*/
|
|
204
|
+
function collectRoots(root, roots) {
|
|
205
|
+
if (root instanceof Document || root instanceof ShadowRoot) {
|
|
206
|
+
roots.push(root);
|
|
207
|
+
}
|
|
208
|
+
const children = root instanceof Document || root instanceof ShadowRoot
|
|
209
|
+
? root.querySelectorAll('*')
|
|
210
|
+
: [root];
|
|
211
|
+
for (let i = 0; i < children.length; i++) {
|
|
212
|
+
const child = children[i];
|
|
213
|
+
if (child.shadowRoot) {
|
|
214
|
+
roots.push(child.shadowRoot);
|
|
215
|
+
collectRoots(child.shadowRoot, roots);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Collect all DOM roots (document + all shadow roots)
|
|
220
|
+
const allRoots = [];
|
|
221
|
+
collectRoots(document, allRoots);
|
|
222
|
+
// Query selectors across all roots (piercing shadow DOM)
|
|
223
|
+
for (const root of allRoots) {
|
|
224
|
+
if (results.length >= 500)
|
|
225
|
+
break;
|
|
226
|
+
for (const sel of selectors) {
|
|
227
|
+
if (results.length >= 500)
|
|
228
|
+
break;
|
|
229
|
+
const nodeList = root.querySelectorAll(sel);
|
|
230
|
+
for (let ei = 0; ei < nodeList.length; ei++) {
|
|
231
|
+
if (results.length >= 500)
|
|
232
|
+
break;
|
|
233
|
+
processElement(nodeList[ei]);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return results;
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
242
|
+
logger.error(`Failed to extract candidates from page: ${message}`);
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Builds a LocatorInfo from a candidate element, choosing the best locator approach.
|
|
248
|
+
*/
|
|
249
|
+
function buildLocatorInfo(candidate, preferredType) {
|
|
250
|
+
switch (preferredType) {
|
|
251
|
+
case 'testid':
|
|
252
|
+
if (candidate.testId) {
|
|
253
|
+
return {
|
|
254
|
+
type: 'testid',
|
|
255
|
+
selector: candidate.testId,
|
|
256
|
+
playwrightExpression: `page.getByTestId('${escapeString(candidate.testId)}')`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
break;
|
|
260
|
+
case 'role':
|
|
261
|
+
if (candidate.role || candidate.tag === 'button' || candidate.tag === 'a') {
|
|
262
|
+
const role = candidate.role || inferRole(candidate.tag);
|
|
263
|
+
const options = {};
|
|
264
|
+
if (candidate.ariaLabel)
|
|
265
|
+
options['name'] = candidate.ariaLabel;
|
|
266
|
+
else if (candidate.text)
|
|
267
|
+
options['name'] = candidate.text.substring(0, 80);
|
|
268
|
+
const optStr = Object.keys(options).length > 0
|
|
269
|
+
? `, { name: '${escapeString(String(options['name']))}' }`
|
|
270
|
+
: '';
|
|
271
|
+
return {
|
|
272
|
+
type: 'role',
|
|
273
|
+
selector: role,
|
|
274
|
+
options: Object.keys(options).length > 0 ? options : undefined,
|
|
275
|
+
playwrightExpression: `page.getByRole('${role}'${optStr})`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
break;
|
|
279
|
+
case 'text':
|
|
280
|
+
if (candidate.text) {
|
|
281
|
+
return {
|
|
282
|
+
type: 'text',
|
|
283
|
+
selector: candidate.text.substring(0, 80),
|
|
284
|
+
playwrightExpression: `page.getByText('${escapeString(candidate.text.substring(0, 80))}')`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
break;
|
|
288
|
+
case 'label':
|
|
289
|
+
if (candidate.ariaLabel) {
|
|
290
|
+
return {
|
|
291
|
+
type: 'label',
|
|
292
|
+
selector: candidate.ariaLabel,
|
|
293
|
+
playwrightExpression: `page.getByLabel('${escapeString(candidate.ariaLabel)}')`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
case 'placeholder':
|
|
298
|
+
if (candidate.placeholder) {
|
|
299
|
+
return {
|
|
300
|
+
type: 'placeholder',
|
|
301
|
+
selector: candidate.placeholder,
|
|
302
|
+
playwrightExpression: `page.getByPlaceholder('${escapeString(candidate.placeholder)}')`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
break;
|
|
306
|
+
case 'css':
|
|
307
|
+
return buildCssLocator(candidate);
|
|
308
|
+
case 'xpath':
|
|
309
|
+
return buildXpathLocator(candidate);
|
|
310
|
+
}
|
|
311
|
+
// Fallback: use the best available identifier
|
|
312
|
+
if (candidate.testId) {
|
|
313
|
+
return {
|
|
314
|
+
type: 'testid',
|
|
315
|
+
selector: candidate.testId,
|
|
316
|
+
playwrightExpression: `page.getByTestId('${escapeString(candidate.testId)}')`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
if (candidate.id) {
|
|
320
|
+
return {
|
|
321
|
+
type: 'css',
|
|
322
|
+
selector: `#${candidate.id}`,
|
|
323
|
+
playwrightExpression: `page.locator('#${escapeString(candidate.id)}')`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
return buildCssLocator(candidate);
|
|
327
|
+
}
|
|
328
|
+
function buildCssLocator(candidate) {
|
|
329
|
+
let selector;
|
|
330
|
+
if (candidate.id) {
|
|
331
|
+
selector = `#${candidate.id}`;
|
|
332
|
+
}
|
|
333
|
+
else if (candidate.classes.length > 0) {
|
|
334
|
+
selector = `${candidate.tag}.${candidate.classes.slice(0, 3).join('.')}`;
|
|
335
|
+
}
|
|
336
|
+
else if (candidate.name) {
|
|
337
|
+
selector = `${candidate.tag}[name="${candidate.name}"]`;
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
selector = candidate.tag;
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
type: 'css',
|
|
344
|
+
selector,
|
|
345
|
+
playwrightExpression: `page.locator('${escapeString(selector)}')`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function buildXpathLocator(candidate) {
|
|
349
|
+
let xpath;
|
|
350
|
+
if (candidate.id) {
|
|
351
|
+
xpath = `//${candidate.tag}[@id="${candidate.id}"]`;
|
|
352
|
+
}
|
|
353
|
+
else if (candidate.text) {
|
|
354
|
+
const text = candidate.text.substring(0, 50);
|
|
355
|
+
xpath = `//${candidate.tag}[contains(text(),"${text}")]`;
|
|
356
|
+
}
|
|
357
|
+
else if (candidate.classes.length > 0) {
|
|
358
|
+
xpath = `//${candidate.tag}[contains(@class,"${candidate.classes[0]}")]`;
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
xpath = `//${candidate.tag}`;
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
type: 'xpath',
|
|
365
|
+
selector: xpath,
|
|
366
|
+
playwrightExpression: `page.locator('${escapeString(xpath)}')`,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function inferRole(tag) {
|
|
370
|
+
const tagRoleMap = {
|
|
371
|
+
a: 'link',
|
|
372
|
+
button: 'button',
|
|
373
|
+
input: 'textbox',
|
|
374
|
+
select: 'combobox',
|
|
375
|
+
textarea: 'textbox',
|
|
376
|
+
img: 'img',
|
|
377
|
+
nav: 'navigation',
|
|
378
|
+
main: 'main',
|
|
379
|
+
header: 'banner',
|
|
380
|
+
footer: 'contentinfo',
|
|
381
|
+
form: 'form',
|
|
382
|
+
table: 'table',
|
|
383
|
+
h1: 'heading',
|
|
384
|
+
h2: 'heading',
|
|
385
|
+
h3: 'heading',
|
|
386
|
+
h4: 'heading',
|
|
387
|
+
h5: 'heading',
|
|
388
|
+
h6: 'heading',
|
|
389
|
+
};
|
|
390
|
+
return tagRoleMap[tag] ?? 'generic';
|
|
391
|
+
}
|
|
392
|
+
function escapeString(str) {
|
|
393
|
+
return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
394
|
+
}
|
|
395
|
+
// ─── Strategy Implementations ───────────────────────────────────────────────────
|
|
396
|
+
/**
|
|
397
|
+
* Finds elements with similar attributes (id, name, class, data-testid, aria-label).
|
|
398
|
+
* Uses Levenshtein-based similarity on attribute values.
|
|
399
|
+
*/
|
|
400
|
+
async function attributeStrategy(page, originalLocator, _domSnapshot) {
|
|
401
|
+
const start = Date.now();
|
|
402
|
+
try {
|
|
403
|
+
const candidates = await extractCandidates(page);
|
|
404
|
+
if (candidates.length === 0) {
|
|
405
|
+
return makeAttempt('attribute', null, 0, start);
|
|
406
|
+
}
|
|
407
|
+
const selector = originalLocator.selector;
|
|
408
|
+
let bestCandidate = null;
|
|
409
|
+
let bestScore = 0;
|
|
410
|
+
for (const candidate of candidates) {
|
|
411
|
+
let score = 0;
|
|
412
|
+
// Compare id
|
|
413
|
+
if (candidate.id) {
|
|
414
|
+
score = Math.max(score, stringSimilarity(selector, candidate.id) * 0.9);
|
|
415
|
+
}
|
|
416
|
+
// Compare data-testid
|
|
417
|
+
if (candidate.testId) {
|
|
418
|
+
score = Math.max(score, stringSimilarity(selector, candidate.testId) * 0.95);
|
|
419
|
+
}
|
|
420
|
+
// Compare name attribute
|
|
421
|
+
if (candidate.name) {
|
|
422
|
+
score = Math.max(score, stringSimilarity(selector, candidate.name) * 0.8);
|
|
423
|
+
}
|
|
424
|
+
// Compare aria-label
|
|
425
|
+
if (candidate.ariaLabel) {
|
|
426
|
+
score = Math.max(score, stringSimilarity(selector, candidate.ariaLabel) * 0.75);
|
|
427
|
+
}
|
|
428
|
+
// Compare class names individually
|
|
429
|
+
for (const cls of candidate.classes) {
|
|
430
|
+
score = Math.max(score, stringSimilarity(selector, cls) * 0.6);
|
|
431
|
+
}
|
|
432
|
+
if (score > bestScore) {
|
|
433
|
+
bestScore = score;
|
|
434
|
+
bestCandidate = candidate;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (bestCandidate && bestScore >= 0.4) {
|
|
438
|
+
const locator = buildLocatorInfo(bestCandidate, originalLocator.type);
|
|
439
|
+
return makeAttempt('attribute', locator, bestScore, start);
|
|
440
|
+
}
|
|
441
|
+
return makeAttempt('attribute', null, 0, start);
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
return makeAttemptError('attribute', error, start);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Matches elements by visible text content or placeholder text.
|
|
449
|
+
*/
|
|
450
|
+
async function textStrategy(page, originalLocator, _domSnapshot) {
|
|
451
|
+
const start = Date.now();
|
|
452
|
+
try {
|
|
453
|
+
const candidates = await extractCandidates(page);
|
|
454
|
+
if (candidates.length === 0) {
|
|
455
|
+
return makeAttempt('text', null, 0, start);
|
|
456
|
+
}
|
|
457
|
+
// Extract the text we are searching for from the original locator
|
|
458
|
+
let searchText = originalLocator.selector;
|
|
459
|
+
if (originalLocator.options && typeof originalLocator.options['name'] === 'string') {
|
|
460
|
+
searchText = originalLocator.options['name'];
|
|
461
|
+
}
|
|
462
|
+
let bestCandidate = null;
|
|
463
|
+
let bestScore = 0;
|
|
464
|
+
for (const candidate of candidates) {
|
|
465
|
+
let score = 0;
|
|
466
|
+
// Compare visible text
|
|
467
|
+
if (candidate.text) {
|
|
468
|
+
score = Math.max(score, stringSimilarity(searchText, candidate.text));
|
|
469
|
+
}
|
|
470
|
+
// Compare placeholder
|
|
471
|
+
if (candidate.placeholder) {
|
|
472
|
+
score = Math.max(score, stringSimilarity(searchText, candidate.placeholder) * 0.85);
|
|
473
|
+
}
|
|
474
|
+
// Compare aria-label
|
|
475
|
+
if (candidate.ariaLabel) {
|
|
476
|
+
score = Math.max(score, stringSimilarity(searchText, candidate.ariaLabel) * 0.8);
|
|
477
|
+
}
|
|
478
|
+
if (score > bestScore) {
|
|
479
|
+
bestScore = score;
|
|
480
|
+
bestCandidate = candidate;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (bestCandidate && bestScore >= 0.5) {
|
|
484
|
+
const locator = buildLocatorInfo(bestCandidate, 'text');
|
|
485
|
+
return makeAttempt('text', locator, bestScore, start);
|
|
486
|
+
}
|
|
487
|
+
return makeAttempt('text', null, 0, start);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
return makeAttemptError('text', error, start);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Matches elements by ARIA role and accessible name.
|
|
495
|
+
*/
|
|
496
|
+
async function roleStrategy(page, originalLocator, _domSnapshot) {
|
|
497
|
+
const start = Date.now();
|
|
498
|
+
try {
|
|
499
|
+
const candidates = await extractCandidates(page);
|
|
500
|
+
if (candidates.length === 0) {
|
|
501
|
+
return makeAttempt('role', null, 0, start);
|
|
502
|
+
}
|
|
503
|
+
// Determine what role/name to look for
|
|
504
|
+
let targetRole = '';
|
|
505
|
+
let targetName = '';
|
|
506
|
+
if (originalLocator.type === 'role') {
|
|
507
|
+
targetRole = originalLocator.selector;
|
|
508
|
+
if (originalLocator.options && typeof originalLocator.options['name'] === 'string') {
|
|
509
|
+
targetName = originalLocator.options['name'];
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
// Try to infer from the selector
|
|
514
|
+
targetName = originalLocator.selector;
|
|
515
|
+
}
|
|
516
|
+
let bestCandidate = null;
|
|
517
|
+
let bestScore = 0;
|
|
518
|
+
for (const candidate of candidates) {
|
|
519
|
+
const candidateRole = candidate.role || inferRole(candidate.tag);
|
|
520
|
+
let score = 0;
|
|
521
|
+
// Role matching
|
|
522
|
+
if (targetRole) {
|
|
523
|
+
if (candidateRole === targetRole) {
|
|
524
|
+
score += 0.4;
|
|
525
|
+
}
|
|
526
|
+
else if (stringSimilarity(candidateRole, targetRole) > 0.7) {
|
|
527
|
+
score += 0.2;
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
continue; // Skip candidates with completely wrong role
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// Name matching
|
|
534
|
+
const candidateName = candidate.ariaLabel || candidate.text;
|
|
535
|
+
if (targetName && candidateName) {
|
|
536
|
+
score += stringSimilarity(targetName, candidateName) * 0.6;
|
|
537
|
+
}
|
|
538
|
+
else if (!targetName && candidateRole === targetRole) {
|
|
539
|
+
// Role matched but no name to compare
|
|
540
|
+
score += 0.1;
|
|
541
|
+
}
|
|
542
|
+
if (score > bestScore) {
|
|
543
|
+
bestScore = score;
|
|
544
|
+
bestCandidate = candidate;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (bestCandidate && bestScore >= 0.4) {
|
|
548
|
+
const locator = buildLocatorInfo(bestCandidate, 'role');
|
|
549
|
+
return makeAttempt('role', locator, Math.min(bestScore, 1), start);
|
|
550
|
+
}
|
|
551
|
+
return makeAttempt('role', null, 0, start);
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
return makeAttemptError('role', error, start);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Finds elements with similar CSS selectors using edit distance comparison.
|
|
559
|
+
*/
|
|
560
|
+
async function cssProximityStrategy(page, originalLocator, _domSnapshot) {
|
|
561
|
+
const start = Date.now();
|
|
562
|
+
try {
|
|
563
|
+
const candidates = await extractCandidates(page);
|
|
564
|
+
if (candidates.length === 0) {
|
|
565
|
+
return makeAttempt('css', null, 0, start);
|
|
566
|
+
}
|
|
567
|
+
const originalSelector = originalLocator.selector;
|
|
568
|
+
let bestCandidate = null;
|
|
569
|
+
let bestScore = 0;
|
|
570
|
+
for (const candidate of candidates) {
|
|
571
|
+
// Build a CSS selector for this candidate and compare
|
|
572
|
+
const candidateCss = buildCssSelector(candidate);
|
|
573
|
+
const similarity = stringSimilarity(originalSelector, candidateCss);
|
|
574
|
+
// Also check structural similarity (same tag, similar classes)
|
|
575
|
+
let structuralBonus = 0;
|
|
576
|
+
const originalTag = extractTagFromSelector(originalSelector);
|
|
577
|
+
if (originalTag && candidate.tag === originalTag) {
|
|
578
|
+
structuralBonus += 0.1;
|
|
579
|
+
}
|
|
580
|
+
const originalClasses = extractClassesFromSelector(originalSelector);
|
|
581
|
+
if (originalClasses.length > 0 && candidate.classes.length > 0) {
|
|
582
|
+
const overlap = originalClasses.filter((c) => candidate.classes.includes(c)).length;
|
|
583
|
+
structuralBonus += (overlap / Math.max(originalClasses.length, candidate.classes.length)) * 0.3;
|
|
584
|
+
}
|
|
585
|
+
const totalScore = Math.min(similarity + structuralBonus, 1);
|
|
586
|
+
if (totalScore > bestScore) {
|
|
587
|
+
bestScore = totalScore;
|
|
588
|
+
bestCandidate = candidate;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (bestCandidate && bestScore >= 0.35) {
|
|
592
|
+
const locator = buildLocatorInfo(bestCandidate, 'css');
|
|
593
|
+
return makeAttempt('css', locator, bestScore, start);
|
|
594
|
+
}
|
|
595
|
+
return makeAttempt('css', null, 0, start);
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
return makeAttemptError('css', error, start);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Generates XPath-based locators using element context from the DOM snapshot.
|
|
603
|
+
*/
|
|
604
|
+
async function xpathStrategy(page, originalLocator, _domSnapshot) {
|
|
605
|
+
const start = Date.now();
|
|
606
|
+
try {
|
|
607
|
+
const candidates = await extractCandidates(page);
|
|
608
|
+
if (candidates.length === 0) {
|
|
609
|
+
return makeAttempt('xpath', null, 0, start);
|
|
610
|
+
}
|
|
611
|
+
const selector = originalLocator.selector;
|
|
612
|
+
let bestCandidate = null;
|
|
613
|
+
let bestScore = 0;
|
|
614
|
+
for (const candidate of candidates) {
|
|
615
|
+
let score = 0;
|
|
616
|
+
// Match by id (strongest signal)
|
|
617
|
+
if (candidate.id && selector.includes(candidate.id)) {
|
|
618
|
+
score = Math.max(score, 0.9);
|
|
619
|
+
}
|
|
620
|
+
// Match by text content
|
|
621
|
+
if (candidate.text) {
|
|
622
|
+
const textSim = stringSimilarity(selector, candidate.text);
|
|
623
|
+
score = Math.max(score, textSim * 0.7);
|
|
624
|
+
}
|
|
625
|
+
// Match by any attribute value similarity
|
|
626
|
+
for (const [, value] of Object.entries(candidate.attributes)) {
|
|
627
|
+
if (value) {
|
|
628
|
+
const attrSim = stringSimilarity(selector, value);
|
|
629
|
+
score = Math.max(score, attrSim * 0.6);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (score > bestScore) {
|
|
633
|
+
bestScore = score;
|
|
634
|
+
bestCandidate = candidate;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (bestCandidate && bestScore >= 0.35) {
|
|
638
|
+
const locator = buildXpathLocator(bestCandidate);
|
|
639
|
+
return makeAttempt('xpath', locator, bestScore, start);
|
|
640
|
+
}
|
|
641
|
+
return makeAttempt('xpath', null, 0, start);
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
return makeAttemptError('xpath', error, start);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// ─── Table Strategy ─────────────────────────────────────────────────────────────
|
|
648
|
+
/**
|
|
649
|
+
* Finds elements within HTML tables using row/column indexing,
|
|
650
|
+
* header-to-cell correlation, and structural matching.
|
|
651
|
+
*/
|
|
652
|
+
async function tableStrategy(page, originalLocator, _domSnapshot) {
|
|
653
|
+
const start = Date.now();
|
|
654
|
+
try {
|
|
655
|
+
const candidates = await extractCandidates(page);
|
|
656
|
+
const tableCandidates = candidates.filter((c) => c.isInTable);
|
|
657
|
+
if (tableCandidates.length === 0) {
|
|
658
|
+
return makeAttempt('table', null, 0, start);
|
|
659
|
+
}
|
|
660
|
+
const selector = originalLocator.selector;
|
|
661
|
+
let bestCandidate = null;
|
|
662
|
+
let bestScore = 0;
|
|
663
|
+
for (const candidate of tableCandidates) {
|
|
664
|
+
let score = 0;
|
|
665
|
+
// Match by text content within the cell
|
|
666
|
+
if (candidate.text) {
|
|
667
|
+
score = Math.max(score, stringSimilarity(selector, candidate.text) * 0.85);
|
|
668
|
+
}
|
|
669
|
+
// Match by data-testid
|
|
670
|
+
if (candidate.testId) {
|
|
671
|
+
score = Math.max(score, stringSimilarity(selector, candidate.testId) * 0.95);
|
|
672
|
+
}
|
|
673
|
+
// Match by column header text correlation
|
|
674
|
+
if (candidate.tableHeaderText) {
|
|
675
|
+
const headerSim = stringSimilarity(selector, candidate.tableHeaderText);
|
|
676
|
+
score = Math.max(score, headerSim * 0.7);
|
|
677
|
+
}
|
|
678
|
+
// Match by id
|
|
679
|
+
if (candidate.id) {
|
|
680
|
+
score = Math.max(score, stringSimilarity(selector, candidate.id) * 0.9);
|
|
681
|
+
}
|
|
682
|
+
// Match by aria-label
|
|
683
|
+
if (candidate.ariaLabel) {
|
|
684
|
+
score = Math.max(score, stringSimilarity(selector, candidate.ariaLabel) * 0.8);
|
|
685
|
+
}
|
|
686
|
+
// Bonus for matching row/column patterns in the selector (e.g., "row-2", "col-3")
|
|
687
|
+
const rowMatch = selector.match(/row[_-]?(\d+)/i);
|
|
688
|
+
const colMatch = selector.match(/col(?:umn)?[_-]?(\d+)/i);
|
|
689
|
+
if (rowMatch && candidate.tableRow === parseInt(rowMatch[1], 10)) {
|
|
690
|
+
score += 0.1;
|
|
691
|
+
}
|
|
692
|
+
if (colMatch && candidate.tableCol === parseInt(colMatch[1], 10)) {
|
|
693
|
+
score += 0.1;
|
|
694
|
+
}
|
|
695
|
+
if (score > bestScore) {
|
|
696
|
+
bestScore = score;
|
|
697
|
+
bestCandidate = candidate;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (bestCandidate && bestScore >= 0.35) {
|
|
701
|
+
// Build a table-aware locator
|
|
702
|
+
const locator = buildTableLocator(bestCandidate);
|
|
703
|
+
return makeAttempt('table', locator, Math.min(bestScore, 1), start);
|
|
704
|
+
}
|
|
705
|
+
return makeAttempt('table', null, 0, start);
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
return makeAttemptError('table', error, start);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
function buildTableLocator(candidate) {
|
|
712
|
+
// Prefer testid if available
|
|
713
|
+
if (candidate.testId) {
|
|
714
|
+
return {
|
|
715
|
+
type: 'testid',
|
|
716
|
+
selector: candidate.testId,
|
|
717
|
+
playwrightExpression: `page.getByTestId('${escapeString(candidate.testId)}')`,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
// Use role-based locator for cells with header context
|
|
721
|
+
if (candidate.tag === 'td' && candidate.tableHeaderText && candidate.text) {
|
|
722
|
+
return {
|
|
723
|
+
type: 'role',
|
|
724
|
+
selector: 'cell',
|
|
725
|
+
options: { name: candidate.text.substring(0, 80) },
|
|
726
|
+
playwrightExpression: `page.getByRole('cell', { name: '${escapeString(candidate.text.substring(0, 80))}' })`,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
// Use nth-child table cell selector
|
|
730
|
+
if (candidate.tableCellSelector) {
|
|
731
|
+
return {
|
|
732
|
+
type: 'css',
|
|
733
|
+
selector: candidate.tableCellSelector,
|
|
734
|
+
playwrightExpression: `page.locator('${escapeString(candidate.tableCellSelector)}')`,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
// Fallback to generic locator
|
|
738
|
+
return buildLocatorInfo(candidate, candidate.tag === 'td' || candidate.tag === 'th' ? 'css' : 'text');
|
|
739
|
+
}
|
|
740
|
+
// ─── Modal Strategy ─────────────────────────────────────────────────────────────
|
|
741
|
+
/**
|
|
742
|
+
* Finds elements within modal dialogs, popups, and overlays.
|
|
743
|
+
* Prioritises candidates that are inside currently visible modals/dialogs.
|
|
744
|
+
*/
|
|
745
|
+
async function modalStrategy(page, originalLocator, _domSnapshot) {
|
|
746
|
+
const start = Date.now();
|
|
747
|
+
try {
|
|
748
|
+
const candidates = await extractCandidates(page);
|
|
749
|
+
// First try candidates inside modals (most likely context for the failure)
|
|
750
|
+
const modalCandidates = candidates.filter((c) => c.isInModal);
|
|
751
|
+
// If original locator hints at modal context, prioritize modal candidates
|
|
752
|
+
const isModalContext = originalLocator.selector.match(/modal|dialog|popup|overlay|alert/i);
|
|
753
|
+
const searchPool = isModalContext && modalCandidates.length > 0
|
|
754
|
+
? modalCandidates
|
|
755
|
+
: candidates;
|
|
756
|
+
if (searchPool.length === 0) {
|
|
757
|
+
return makeAttempt('modal', null, 0, start);
|
|
758
|
+
}
|
|
759
|
+
const selector = originalLocator.selector;
|
|
760
|
+
let bestCandidate = null;
|
|
761
|
+
let bestScore = 0;
|
|
762
|
+
for (const candidate of searchPool) {
|
|
763
|
+
let score = 0;
|
|
764
|
+
// Match by data-testid
|
|
765
|
+
if (candidate.testId) {
|
|
766
|
+
score = Math.max(score, stringSimilarity(selector, candidate.testId) * 0.95);
|
|
767
|
+
}
|
|
768
|
+
// Match by aria-label (important for modal buttons)
|
|
769
|
+
if (candidate.ariaLabel) {
|
|
770
|
+
score = Math.max(score, stringSimilarity(selector, candidate.ariaLabel) * 0.9);
|
|
771
|
+
}
|
|
772
|
+
// Match by text (modal buttons/links often matched by text)
|
|
773
|
+
if (candidate.text) {
|
|
774
|
+
score = Math.max(score, stringSimilarity(selector, candidate.text) * 0.85);
|
|
775
|
+
}
|
|
776
|
+
// Match by id
|
|
777
|
+
if (candidate.id) {
|
|
778
|
+
score = Math.max(score, stringSimilarity(selector, candidate.id) * 0.9);
|
|
779
|
+
}
|
|
780
|
+
// Match by role
|
|
781
|
+
if (candidate.role) {
|
|
782
|
+
score = Math.max(score, stringSimilarity(selector, candidate.role) * 0.5);
|
|
783
|
+
}
|
|
784
|
+
// Bonus for being inside a modal when the original selector suggests modal context
|
|
785
|
+
if (candidate.isInModal && isModalContext) {
|
|
786
|
+
score += 0.1;
|
|
787
|
+
}
|
|
788
|
+
if (score > bestScore) {
|
|
789
|
+
bestScore = score;
|
|
790
|
+
bestCandidate = candidate;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (bestCandidate && bestScore >= 0.4) {
|
|
794
|
+
const locator = buildLocatorInfo(bestCandidate, originalLocator.type);
|
|
795
|
+
return makeAttempt('modal', locator, Math.min(bestScore, 1), start);
|
|
796
|
+
}
|
|
797
|
+
return makeAttempt('modal', null, 0, start);
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
return makeAttemptError('modal', error, start);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// ─── CSS Selector Utilities ─────────────────────────────────────────────────────
|
|
804
|
+
function buildCssSelector(candidate) {
|
|
805
|
+
if (candidate.id)
|
|
806
|
+
return `#${candidate.id}`;
|
|
807
|
+
if (candidate.classes.length > 0) {
|
|
808
|
+
return `${candidate.tag}.${candidate.classes.slice(0, 3).join('.')}`;
|
|
809
|
+
}
|
|
810
|
+
if (candidate.name)
|
|
811
|
+
return `${candidate.tag}[name="${candidate.name}"]`;
|
|
812
|
+
return candidate.tag;
|
|
813
|
+
}
|
|
814
|
+
function extractTagFromSelector(selector) {
|
|
815
|
+
const match = selector.match(/^([a-zA-Z][a-zA-Z0-9]*)/);
|
|
816
|
+
return match ? match[1].toLowerCase() : null;
|
|
817
|
+
}
|
|
818
|
+
function extractClassesFromSelector(selector) {
|
|
819
|
+
const matches = selector.match(/\.([a-zA-Z_-][\w-]*)/g);
|
|
820
|
+
if (!matches)
|
|
821
|
+
return [];
|
|
822
|
+
return matches.map((m) => m.substring(1));
|
|
823
|
+
}
|
|
824
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────────
|
|
825
|
+
function makeAttempt(strategy, locator, confidence, startTime) {
|
|
826
|
+
return {
|
|
827
|
+
strategy,
|
|
828
|
+
locator,
|
|
829
|
+
confidence: Math.round(confidence * 100) / 100,
|
|
830
|
+
duration: Date.now() - startTime,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
function makeAttemptError(strategy, error, startTime) {
|
|
834
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
835
|
+
logger.error(`Strategy "${strategy}" failed: ${message}`);
|
|
836
|
+
return {
|
|
837
|
+
strategy,
|
|
838
|
+
locator: null,
|
|
839
|
+
confidence: 0,
|
|
840
|
+
duration: Date.now() - startTime,
|
|
841
|
+
error: message,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
const STRATEGY_MAP = {
|
|
845
|
+
attribute: attributeStrategy,
|
|
846
|
+
text: textStrategy,
|
|
847
|
+
role: roleStrategy,
|
|
848
|
+
css: cssProximityStrategy,
|
|
849
|
+
xpath: xpathStrategy,
|
|
850
|
+
table: tableStrategy,
|
|
851
|
+
modal: modalStrategy,
|
|
852
|
+
enterprise: enterpriseStrategy,
|
|
853
|
+
};
|
|
854
|
+
/**
|
|
855
|
+
* Dispatches a named healing strategy and returns the result.
|
|
856
|
+
*
|
|
857
|
+
* @param name - The strategy name (attribute, text, role, css, xpath).
|
|
858
|
+
* @param page - Playwright Page object.
|
|
859
|
+
* @param originalLocator - The original failed locator information.
|
|
860
|
+
* @param domSnapshot - A DOM snapshot of the page.
|
|
861
|
+
* @returns The strategy attempt result.
|
|
862
|
+
*/
|
|
863
|
+
async function runStrategy(name, page, originalLocator, domSnapshot) {
|
|
864
|
+
const strategyFn = STRATEGY_MAP[name];
|
|
865
|
+
if (!strategyFn) {
|
|
866
|
+
logger.warn(`Unknown strategy: ${name}`);
|
|
867
|
+
return {
|
|
868
|
+
strategy: name,
|
|
869
|
+
locator: null,
|
|
870
|
+
confidence: 0,
|
|
871
|
+
duration: 0,
|
|
872
|
+
error: `Unknown strategy: ${name}`,
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
logger.debug(`Running healing strategy: ${name}`);
|
|
876
|
+
const result = await strategyFn(page, originalLocator, domSnapshot);
|
|
877
|
+
logger.debug(`Strategy "${name}" completed: confidence=${result.confidence}, found=${result.locator !== null}`);
|
|
878
|
+
return result;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
export { attributeStrategy, cssProximityStrategy, modalStrategy, roleStrategy, runStrategy, tableStrategy, textStrategy, xpathStrategy };
|
|
882
|
+
//# sourceMappingURL=locator-strategies.js.map
|