@qa-gentic/stlc-agents 1.0.0 → 1.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qa-gentic/stlc-agents",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "QA STLC Agents — MCP servers + skills for AI-powered test case, Gherkin, and Playwright generation against Azure DevOps. Works with Claude Code, GitHub Copilot, Cursor, Windsurf.",
5
5
  "keywords": [
6
6
  "playwright",
@@ -44,7 +44,7 @@ const BUNDLE = {
44
44
  "src/utils/locators/ElementContextHelper.ts": "import { Page } from '@playwright/test';\n\nexport interface ElementContext {\n id: string;\n name: string;\n page: string;\n metadata: {\n nearText?: string;\n role?: string;\n ariaLabel?: string;\n placeholder?: string;\n text?: string;\n };\n}\n\n/**\n * ElementContextHelper\n *\n * Gathers contextual metadata about a named element on the current page\n * to provide richer hints to AI self-healing and locator generation.\n *\n * Used by LocatorManager when building a heal context payload.\n */\nexport class ElementContextHelper {\n constructor(private readonly page: Page) {}\n\n async buildContext(name: string, overrides?: Partial<ElementContext['metadata']>): Promise<ElementContext> {\n const url = this.page.url();\n const nearText = await this.findNearbyText(name);\n const role = overrides?.role ?? await this.inferRole(name);\n const ariaLabel = overrides?.ariaLabel ?? await this.inferAriaLabel(name);\n const placeholder = overrides?.placeholder ?? await this.inferPlaceholder(name);\n\n return {\n id: name, name, page: url,\n metadata: {\n nearText: overrides?.nearText ?? (nearText || undefined),\n role: role || undefined,\n ariaLabel: ariaLabel || undefined,\n placeholder: placeholder || undefined,\n text: name,\n },\n };\n }\n\n private async inferRole(name: string): Promise<string | null> {\n const candidates = ['button', 'link', 'textbox', 'heading'];\n for (const role of candidates) {\n try {\n const count = await this.page.getByRole(role as Parameters<Page['getByRole']>[0], { name: new RegExp(name, 'i') }).count();\n if (count > 0) return role;\n } catch { /* continue */ }\n }\n return null;\n }\n\n private async inferAriaLabel(name: string): Promise<string | null> {\n const loc = this.page.locator('[aria-label]');\n const count = await loc.count();\n for (let i = 0; i < count; i++) {\n const label = await loc.nth(i).getAttribute('aria-label');\n if (label?.toLowerCase().includes(name.toLowerCase())) return label;\n }\n return null;\n }\n\n private async inferPlaceholder(name: string): Promise<string | null> {\n const loc = this.page.locator('[placeholder]');\n const count = await loc.count();\n for (let i = 0; i < count; i++) {\n const ph = await loc.nth(i).getAttribute('placeholder');\n if (ph?.toLowerCase().includes(name.toLowerCase())) return ph;\n }\n return null;\n }\n\n private async findNearbyText(name: string): Promise<string | null> {\n try {\n const count = await this.page.getByText(new RegExp(name, 'i')).count();\n return count > 0 ? name : null;\n } catch { return null; }\n }\n}\n",
45
45
  "src/utils/locators/HealApplicator.ts": "import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\n\n/**\n * HealApplicator\n *\n * Applies approved heals back to the source TypeScript files so the next run\n * uses the fixed selector natively (zero re-healing overhead).\n *\n * Workflow:\n * 1. Read healed-locators.json — find entries where `approved === true`\n * 2. Search `searchRoots` (.ts files) for the originalSelector string literal\n * 3. Replace the first occurrence with healedSelector\n * 4. Optionally create a Git branch + commit + PR via the `gh` CLI\n *\n * Configuration (env vars):\n * HEAL_SEARCH_ROOTS Comma-separated dirs to search (default: src/)\n * HEAL_TARGET_REPO Repo root for git operations (default: cwd)\n * HEAL_PR_TITLE PR title prefix\n * GH_TOKEN / GITHUB_TOKEN Required by `gh pr create` in CI\n */\n\nexport interface HealRecord {\n healedSelector: string;\n originalSelector: string;\n strategy: string;\n provider?: string;\n intent: string;\n healCount: number;\n lastHealedAt: string;\n approved?: boolean;\n}\n\nexport interface AppliedHeal {\n key: string;\n originalSelector: string;\n healedSelector: string;\n file: string;\n line: number;\n}\n\nexport interface ApplyResult {\n applied: AppliedHeal[];\n skipped: string[];\n errors: Array<{ key: string; error: string }>;\n changedFiles: string[];\n prUrl?: string;\n}\n\nexport class HealApplicator {\n private readonly searchRoots: string[];\n private readonly targetRepo: string;\n\n constructor(options?: { searchRoots?: string[]; targetRepo?: string }) {\n const envRoots = process.env.HEAL_SEARCH_ROOTS?.split(',').map(r => r.trim()) ?? [];\n this.searchRoots = options?.searchRoots?.length\n ? options.searchRoots\n : envRoots.length ? envRoots : [path.resolve(process.cwd(), 'src')];\n this.targetRepo = options?.targetRepo ?? process.env.HEAL_TARGET_REPO ?? process.cwd();\n }\n\n apply(store: Record<string, HealRecord>): ApplyResult {\n const result: ApplyResult = { applied: [], skipped: [], errors: [], changedFiles: [] };\n const changed = new Set<string>();\n\n for (const [key, record] of Object.entries(store)) {\n if (!record.approved) continue;\n if (!record.originalSelector || !record.healedSelector || record.originalSelector === record.healedSelector) {\n result.skipped.push(key); continue;\n }\n try {\n const hit = this.replaceInFiles(record.originalSelector, record.healedSelector);\n if (hit) { result.applied.push({ key, ...hit }); changed.add(hit.file); }\n else { result.skipped.push(key); }\n } catch (err) {\n result.errors.push({ key, error: String(err) });\n }\n }\n\n result.changedFiles = [...changed];\n return result;\n }\n\n createPR(changedFiles: string[], summary: AppliedHeal[]): string {\n if (!changedFiles.length) return '';\n const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);\n const branch = `heal/locator-fixes-${timestamp}`;\n const prTitle = process.env.HEAL_PR_TITLE ?? 'fix: apply AI-healed locator fixes';\n const run = (cmd: string) => execSync(cmd, { cwd: this.targetRepo, stdio: 'pipe' }).toString().trim();\n\n run(`git checkout -b ${branch}`);\n for (const file of changedFiles) { run(`git add \"${path.relative(this.targetRepo, file)}\"`); }\n const body = [\n '## AI-Healed Locator Fixes', '',\n '| Key | Original → Healed |', '|-----|-------------------|',\n ...summary.map(h => `| \\`${h.key}\\` | \\`${h.originalSelector}\\` → \\`${h.healedSelector}\\` |`),\n '', '_Applied by Healix self-healing dashboard_',\n ].join('\\n');\n run(`git commit -m \"${prTitle}\" --message \"${body.replace(/\"/g, '\\\\\"')}\"`);\n run(`git push origin ${branch}`);\n return run(`gh pr create --title \"${prTitle}\" --body \"${body.replace(/\"/g, '\\\\\"')}\" --head ${branch} --base main 2>/dev/null || echo \"\"`);\n }\n\n private replaceInFiles(original: string, healed: string): (Omit<AppliedHeal, 'key'>) | null {\n for (const root of this.searchRoots) {\n if (!fs.existsSync(root)) continue;\n for (const file of this.collectTsFiles(root)) {\n const result = this.replaceInFile(file, original, healed);\n if (result) return { file, line: result.lineNumber, originalSelector: original, healedSelector: result.normHealed };\n }\n }\n return null;\n }\n\n private replaceInFile(file: string, original: string, healed: string): { lineNumber: number; normHealed: string } | null {\n let content: string;\n try { content = fs.readFileSync(file, 'utf8'); } catch { return null; }\n\n const escaped = original.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`(['\"\\`])${escaped}\\\\1`);\n const match = content.match(pattern);\n if (!match || match.index === undefined) return null;\n\n const normHealed = healed.replace(/\\[([^\\]='\"\\s]+)='([^']*)'\\]/g, '[$1=\"$2\"]');\n const quote = match[1];\n const newContent = content.replace(pattern, `${quote}${normHealed}${quote}`);\n const lineNumber = (content.slice(0, match.index).match(/\\n/g)?.length ?? 0) + 1;\n\n fs.writeFileSync(file, newContent, 'utf8');\n return { lineNumber, normHealed };\n }\n\n private collectTsFiles(dir: string): string[] {\n const results: string[] = [];\n let entries: fs.Dirent[];\n try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }\n for (const entry of entries) {\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n if (!['node_modules', 'dist', '.git'].includes(entry.name)) {\n results.push(...this.collectTsFiles(full));\n }\n } else if (entry.isFile() && entry.name.endsWith('.ts')) {\n results.push(full);\n }\n }\n return results;\n }\n}\n",
46
46
  "src/utils/locators/HealingDashboard.ts": "/**\n * HealingDashboard — real-time self-healing observability server\n *\n * Starts a lightweight HTTP server (default port 7890) that:\n * • Accepts healing events pushed by LocatorHealer during test runs\n * • Serves a live HTML dashboard at http://localhost:<port>\n * • Exposes JSON APIs at /api/events, /api/summary, /api/registry\n * • Auto-refreshes via Server-Sent Events (SSE)\n *\n * Usage:\n * BeforeAll: await HealingDashboard.getInstance().start();\n * AfterAll: await HealingDashboard.getInstance().stop();\n */\n\nimport * as http from 'http';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nconst HEAL_STORE_PATH = path.resolve(\n process.env.HEAL_STORE_PATH ?? 'storage-state/healed-locators.json',\n);\n\nfunction readHealStore(): Record<string, unknown> {\n try {\n if (!fs.existsSync(HEAL_STORE_PATH)) return {};\n return JSON.parse(fs.readFileSync(HEAL_STORE_PATH, 'utf8'));\n } catch { return {}; }\n}\n\nexport interface HealEvent {\n key: string;\n originalSelector: string;\n strategy: string;\n provider?: string;\n healedSelector?: string;\n intent: string;\n scenario?: string;\n timestamp: string;\n}\n\nconst cors = { 'Access-Control-Allow-Origin': '*' };\n\nfunction esc(str: string): string {\n return str\n .replace(/&/g, '&amp;').replace(/</g, '&lt;')\n .replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n}\n\nexport class HealingDashboard {\n private static _instance: HealingDashboard | null = null;\n\n static getInstance(): HealingDashboard {\n if (!HealingDashboard._instance) {\n HealingDashboard._instance = new HealingDashboard();\n }\n return HealingDashboard._instance;\n }\n\n static reset(): void { HealingDashboard._instance = null; }\n\n private readonly port: number;\n private server: http.Server | null = null;\n private events: HealEvent[] = [];\n private sseClients: http.ServerResponse[] = [];\n\n private constructor() {\n this.port = parseInt(process.env.HEALING_DASHBOARD_PORT ?? '7890', 10);\n }\n\n async start(): Promise<void> {\n if (this.server) return;\n this.server = http.createServer((req, res) => this.handleRequest(req, res));\n await new Promise<void>((resolve) => {\n this.server!.listen(this.port, '127.0.0.1', () => {\n console.log(` 🩺 HealingDashboard → http://localhost:${this.port}`);\n resolve();\n });\n this.server!.on('error', (err: NodeJS.ErrnoException) => {\n if (err.code === 'EADDRINUSE') {\n console.log(` 🩺 HealingDashboard already running on port ${this.port}`);\n this.server = null;\n } else {\n console.warn(` ⚠ HealingDashboard failed to start: ${err.message}`);\n this.server = null;\n }\n resolve();\n });\n });\n }\n\n async stop(): Promise<void> {\n for (const client of this.sseClients) { try { client.end(); } catch { /* ignore */ } }\n this.sseClients = [];\n await new Promise<void>((resolve) => {\n if (!this.server) { resolve(); return; }\n this.server.close(() => { this.server = null; resolve(); });\n });\n }\n\n record(event: HealEvent): void {\n this.events.push(event);\n this.pushSse(event);\n }\n\n private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {\n const url = req.url ?? '/';\n if (url === '/events' && req.headers.accept?.includes('text/event-stream')) {\n this.handleSse(res); return;\n }\n if (url === '/api/events') {\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify(this.events, null, 2)); return;\n }\n if (url === '/api/summary') {\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify(this.buildSummary(), null, 2)); return;\n }\n if (url === '/api/registry') {\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify(readHealStore(), null, 2)); return;\n }\n if (url === '/api/registry/clear' && req.method === 'POST') {\n try { fs.mkdirSync(path.dirname(HEAL_STORE_PATH), { recursive: true }); fs.writeFileSync(HEAL_STORE_PATH, '{}', 'utf8'); } catch { /* ignore */ }\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify({ ok: true })); return;\n }\n res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });\n res.end(this.buildHtml());\n }\n\n private handleSse(res: http.ServerResponse): void {\n res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', ...cors });\n res.write(':ok\\n\\n');\n this.sseClients.push(res);\n res.on('close', () => { this.sseClients = this.sseClients.filter(c => c !== res); });\n }\n\n private pushSse(event: HealEvent): void {\n const data = `data: ${JSON.stringify(event)}\\n\\n`;\n for (const client of this.sseClients) { try { client.write(data); } catch { /* client disconnected */ } }\n }\n\n private buildSummary() {\n const total = this.events.length;\n const byStrategy: Record<string, number> = {};\n const byKey: Record<string, number> = {};\n const byProvider: Record<string, number> = {};\n for (const e of this.events) {\n byStrategy[e.strategy] = (byStrategy[e.strategy] ?? 0) + 1;\n byKey[e.key] = (byKey[e.key] ?? 0) + 1;\n if (e.provider) byProvider[e.provider] = (byProvider[e.provider] ?? 0) + 1;\n }\n return { total, uniqueKeys: Object.keys(byKey).length, aiHeals: byStrategy['ai-vision'] ?? 0, byStrategy, byKey, byProvider };\n }\n\n private buildHtml(): string {\n const summary = this.buildSummary();\n const store = readHealStore();\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"UTF-8\"><title>Healix — Self-Healing Dashboard</title>\n<style>\n*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}\n:root{--bg:#0d1117;--surface:#161b22;--border:#30363d;--accent:#58a6ff;--green:#3fb950;--yellow:#d29922;--red:#f85149;--purple:#bc8cff;--text:#e6edf3;--muted:#8b949e;--radius:8px}\nbody{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.5;padding:24px}\nheader{display:flex;align-items:center;gap:12px;margin-bottom:28px}header h1{font-size:20px;font-weight:600}\n.badge{background:var(--green);color:#000;font-size:11px;font-weight:700;padding:2px 8px;border-radius:20px}\n.provider-pill{margin-left:auto;background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:4px 12px;font-size:12px;color:var(--accent)}\n.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:16px;margin-bottom:28px}\n.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px}\n.card .label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px}\n.card .value{font-size:28px;font-weight:700;margin-top:4px}\n.card.green .value{color:var(--green)}.card.blue .value{color:var(--accent)}.card.purple .value{color:var(--purple)}.card.yellow .value{color:var(--yellow)}\n.breakdown{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;margin-bottom:28px}\n.breakdown h2{font-size:13px;font-weight:600;margin-bottom:12px;color:var(--muted);text-transform:uppercase}\n.bars{display:flex;flex-direction:column;gap:8px}.bar-row{display:flex;align-items:center;gap:10px}\n.bar-label{width:100px;font-size:12px;color:var(--muted);text-align:right}\n.bar-track{flex:1;background:var(--border);border-radius:4px;height:10px;overflow:hidden}\n.bar-fill{height:100%;border-radius:4px;background:var(--accent)}.bar-fill.ai-vision{background:var(--purple)}.bar-fill.ax-tree{background:var(--yellow)}.bar-fill.role{background:var(--green)}\n.bar-count{width:28px;font-size:12px;text-align:right}\n.section-title{font-size:13px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;display:flex;align-items:center;gap:8px}\n.section-title .count{background:var(--accent);color:#000;font-size:11px;font-weight:700;padding:1px 7px;border-radius:20px}\ntable{width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}\nth{background:#21262d;text-align:left;padding:10px 14px;font-size:11px;color:var(--muted);text-transform:uppercase;border-bottom:1px solid var(--border)}\ntd{padding:10px 14px;border-bottom:1px solid var(--border);vertical-align:top;font-size:13px}\ntr:last-child td{border-bottom:none}tr:hover td{background:rgba(88,166,255,.04)}\n.key-chip{background:rgba(88,166,255,.12);color:var(--accent);border-radius:4px;padding:2px 7px;font-size:12px;font-family:monospace}\n.selector{font-family:monospace;font-size:12px;color:var(--muted);word-break:break-all}\n.new-selector{font-family:monospace;font-size:12px;color:var(--green);word-break:break-all}\n.badge-strategy{display:inline-block;border-radius:4px;padding:2px 8px;font-size:11px;font-weight:600}\n.badge-strategy.ai-vision{background:rgba(188,140,255,.15);color:var(--purple)}\n.badge-strategy.ax-tree{background:rgba(210,153,34,.15);color:var(--yellow)}\n.badge-strategy.role,.badge-strategy.label{background:rgba(63,185,80,.15);color:var(--green)}\n.badge-strategy.text{background:rgba(88,166,255,.1);color:var(--accent)}\n.badge-strategy.cached{background:rgba(139,148,158,.15);color:var(--muted)}\n.ts{font-size:11px;color:var(--muted);white-space:nowrap}.scenario{font-size:11px;color:var(--muted);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.empty{text-align:center;padding:48px;color:var(--muted);font-size:13px}\n.pulse{display:inline-block;width:8px;height:8px;background:var(--green);border-radius:50%;margin-right:6px;animation:pulse 2s infinite}\n@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}\nfooter{margin-top:32px;text-align:center;font-size:11px;color:var(--muted)}\n</style>\n</head>\n<body>\n<header>\n <span>🩺</span><h1>Healix — Self-Healing Dashboard</h1><span class=\"badge\">LIVE</span>\n <span class=\"provider-pill\">AI · ${process.env.AI_PROVIDER ?? 'openai'} / ${process.env.AI_MODEL ?? 'gpt-4o'}</span>\n</header>\n<div class=\"cards\" id=\"cards\">${this.renderCards(summary)}</div>\n<div class=\"breakdown\"><h2>Strategy breakdown</h2><div class=\"bars\" id=\"bars\">${this.renderBars(summary)}</div></div>\n<div class=\"section-title\">Healing events <span class=\"count\" id=\"total-count\">${summary.total}</span>\n <span style=\"margin-left:auto;font-size:12px;color:var(--muted)\"><span class=\"pulse\"></span>live</span>\n</div>\n<table><thead><tr><th>Time</th><th>Key</th><th>Strategy</th><th>Original selector</th><th>Healed selector</th><th>Intent</th><th>Scenario</th></tr></thead>\n<tbody id=\"events-body\">${this.renderRows(this.events)}</tbody></table>\n<div class=\"section-title\" style=\"margin-top:32px\">Stored heals <span class=\"count\" id=\"registry-count\">${Object.keys(store).length}</span>\n <span style=\"margin-left:auto;font-size:12px;color:var(--muted)\">\n <code style=\"color:var(--accent);font-size:11px\">storage-state/healed-locators.json</code>\n &nbsp;·&nbsp;<button onclick=\"clearRegistry()\" style=\"background:rgba(248,81,73,.1);color:var(--red);border:1px solid var(--red);border-radius:4px;padding:2px 10px;font-size:11px;cursor:pointer\">Clear all</button>\n </span>\n</div>\n<table><thead><tr><th>Key</th><th>Strategy</th><th>Original selector</th><th>Healed selector</th><th>Heals</th><th>Last healed</th></tr></thead>\n<tbody id=\"registry-body\">${this.renderRegistry(store)}</tbody></table>\n<footer>QA Framework Self-Healing Dashboard · port ${this.port}</footer>\n<script>\nconst es=new EventSource('/events');\nes.onmessage=e=>{const ev=JSON.parse(e.data);prependRow(ev);refreshSummary();refreshRegistry();};\nfunction esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;').replace(/'/g,'&#39;');}\nfunction stratCls(s){return['ai-vision','ax-tree','role','label','text','cached'].includes(s)?s:'';}\nfunction prependRow(ev){const tb=document.getElementById('events-body');const nd=tb.querySelector('.no-data');if(nd)nd.remove();const ts=new Date(ev.timestamp).toLocaleTimeString();const tr=document.createElement('tr');tr.innerHTML=\\`<td class=\"ts\">\\${esc(ts)}</td><td><span class=\"key-chip\">\\${esc(ev.key)}</span></td><td><span class=\"badge-strategy \\${stratCls(ev.strategy)}\">\\${esc(ev.strategy)}\\${ev.provider?' · '+esc(ev.provider):''}</span></td><td class=\"selector\">\\${esc(ev.originalSelector)}</td><td class=\"new-selector\">\\${esc(ev.healedSelector??'—')}</td><td>\\${esc(ev.intent)}</td><td class=\"scenario\">\\${esc(ev.scenario??'—')}</td>\\`;tb.insertBefore(tr,tb.firstChild);}\nasync function refreshSummary(){try{const d=await(await fetch('/api/summary')).json();document.getElementById('total-count').textContent=d.total;document.getElementById('cards').innerHTML=renderCards(d);document.getElementById('bars').innerHTML=renderBars(d);}catch{}}\nasync function refreshRegistry(){try{const d=await(await fetch('/api/registry')).json();const keys=Object.keys(d);document.getElementById('registry-count').textContent=keys.length;document.getElementById('registry-body').innerHTML=keys.length===0?'<tr><td colspan=\"6\" style=\"color:var(--muted);text-align:center;padding:16px\">No heals stored yet</td></tr>':keys.map(k=>{const r=d[k];return\\`<tr><td><code style=\"color:var(--accent)\">\\${esc(k)}</code></td><td><span class=\"badge-strategy \\${stratCls(r.strategy??'')}\">\\${esc(r.strategy??'?')}</span></td><td><code style=\"color:var(--muted);font-size:11px\">\\${esc(r.originalSelector??'')}</code></td><td><code style=\"color:var(--green);font-size:12px\">\\${esc(r.healedSelector??'')}</code></td><td style=\"text-align:center\">\\${r.healCount??0}</td><td style=\"font-size:11px;color:var(--muted)\">\\${r.lastHealedAt?new Date(r.lastHealedAt).toLocaleTimeString():''}</td></tr>\\`;}).join('');}catch{}}\nasync function clearRegistry(){if(!confirm('Clear all stored heals?'))return;await fetch('/api/registry/clear',{method:'POST'});await refreshRegistry();}\nfunction renderCards(s){return\\`<div class=\"card green\"><div class=\"label\">Total heals</div><div class=\"value\">\\${s.total}</div></div><div class=\"card blue\"><div class=\"label\">Unique keys</div><div class=\"value\">\\${s.uniqueKeys}</div></div><div class=\"card purple\"><div class=\"label\">AI Vision heals</div><div class=\"value\">\\${s.aiHeals}</div></div><div class=\"card yellow\"><div class=\"label\">Strategies</div><div class=\"value\">\\${Object.keys(s.byStrategy).length}</div></div>\\`;}\nfunction renderBars(s){const t=s.total||1;const e=Object.entries(s.byStrategy);return e.length===0?'<div style=\"color:var(--muted);font-size:12px\">No events yet</div>':e.map(([k,v])=>{const p=Math.round(v/t*100);return\\`<div class=\"bar-row\"><div class=\"bar-label\">\\${esc(k)}</div><div class=\"bar-track\"><div class=\"bar-fill \\${stratCls(k)}\" style=\"width:\\${p}%\"></div></div><div class=\"bar-count\">\\${v}</div></div>\\`;}).join('');}\nrefreshRegistry();setInterval(refreshRegistry,5000);\n</script>\n</body></html>`;\n }\n\n private renderCards(s: ReturnType<HealingDashboard['buildSummary']>): string {\n return `<div class=\"card green\"><div class=\"label\">Total heals</div><div class=\"value\">${s.total}</div></div>\n <div class=\"card blue\"><div class=\"label\">Unique keys</div><div class=\"value\">${s.uniqueKeys}</div></div>\n <div class=\"card purple\"><div class=\"label\">AI Vision heals</div><div class=\"value\">${s.aiHeals}</div></div>\n <div class=\"card yellow\"><div class=\"label\">Strategies used</div><div class=\"value\">${Object.keys(s.byStrategy).length}</div></div>`;\n }\n\n private renderBars(s: ReturnType<HealingDashboard['buildSummary']>): string {\n const total = s.total || 1;\n const entries = Object.entries(s.byStrategy);\n if (!entries.length) return '<div style=\"color:var(--muted);font-size:12px\">No events yet</div>';\n return entries.map(([k, v]) => {\n const pct = Math.round((v / total) * 100);\n const cls = ['ai-vision','ax-tree','role'].includes(k) ? k : '';\n return `<div class=\"bar-row\"><div class=\"bar-label\">${esc(k)}</div><div class=\"bar-track\"><div class=\"bar-fill ${cls}\" style=\"width:${pct}%\"></div></div><div class=\"bar-count\">${v}</div></div>`;\n }).join('');\n }\n\n private renderRows(events: HealEvent[]): string {\n if (!events.length) return `<tr class=\"no-data\"><td colspan=\"7\" class=\"empty\">No healing events yet.</td></tr>`;\n return [...events].reverse().map(e => {\n const ts = new Date(e.timestamp).toLocaleTimeString();\n const cls = ['ai-vision','ax-tree','role','label','text','cached'].includes(e.strategy) ? e.strategy : '';\n return `<tr><td class=\"ts\">${esc(ts)}</td><td><span class=\"key-chip\">${esc(e.key)}</span></td><td><span class=\"badge-strategy ${cls}\">${esc(e.strategy)}${e.provider ? ` · ${esc(e.provider)}` : ''}</span></td><td class=\"selector\">${esc(e.originalSelector)}</td><td class=\"new-selector\">${esc(e.healedSelector ?? '—')}</td><td>${esc(e.intent)}</td><td class=\"scenario\" title=\"${esc(e.scenario ?? '')}\">${esc(e.scenario ?? '—')}</td></tr>`;\n }).join('');\n }\n\n private renderRegistry(store: Record<string, unknown>): string {\n const keys = Object.keys(store);\n if (!keys.length) return `<tr><td colspan=\"6\" style=\"color:var(--muted);text-align:center;padding:16px\">No heals stored yet</td></tr>`;\n return keys.map(k => {\n const r = store[k] as Record<string, unknown>;\n const strat = String(r['strategy'] ?? 'unknown');\n const cls = ['ai-vision','ax-tree','role','label','text','cached'].includes(strat) ? strat : '';\n return `<tr><td><code style=\"color:var(--accent)\">${esc(k)}</code></td><td><span class=\"badge-strategy ${cls}\">${esc(strat)}</span></td><td><code style=\"color:var(--muted);font-size:11px\">${esc(String(r['originalSelector'] ?? ''))}</code></td><td><code style=\"color:var(--green);font-size:12px\">${esc(String(r['healedSelector'] ?? ''))}</code></td><td style=\"text-align:center\">${String(r['healCount'] ?? 0)}</td><td style=\"font-size:11px;color:var(--muted)\">${r['lastHealedAt'] ? new Date(String(r['lastHealedAt'])).toLocaleString() : ''}</td></tr>`;\n }).join('');\n }\n}\n",
47
- "src/utils/locators/LocatorHealer.ts": "import { Page, Locator } from '@playwright/test';\nimport { LocatorRepository } from './LocatorRepository';\nimport { HealingDashboard } from './HealingDashboard';\n\nexport interface HealerLogger {\n info(msg: string): void;\n warn(msg: string): void;\n error(msg: string): void;\n}\n\n/**\n * LocatorHealer — Layer 1 Self-Healing\n *\n * Healing chain per action:\n * 1. Original selector (from LocatorRepository.getBestSelector)\n * 2. Role-based: getByRole inferred from intent\n * 3. Label-based: getByLabel / getByPlaceholder\n * 4. Text-based: getByText\n * 5. AI Vision: provider-agnostic vision API (openai | claude | grok | ollama | local)\n * 6. CDPSession AX tree walk (last resort)\n *\n * Provider is controlled by AI_PROVIDER in .env:\n * openai — OpenAI GPT-4o or gpt-4-vision-preview\n * claude — Anthropic Claude (claude-opus-4-6 or newer)\n * grok — xAI Grok vision API\n * ollama — Local Ollama with a vision model (e.g. llava, moondream2)\n * local — LM Studio or any OpenAI-compatible local server\n *\n * Healed selectors are persisted in LocatorRepository — zero overhead on repeat runs.\n */\nexport class LocatorHealer {\n private readonly TIMEOUT = 8_000;\n currentScenario?: string;\n\n constructor(\n private readonly page: Page,\n private readonly logger: HealerLogger,\n private readonly repo: LocatorRepository,\n ) {}\n\n async resolve(key: string, primarySelector: string, intent: string): Promise<Locator> {\n const cached = this.repo.getHealed(key);\n if (cached) {\n const loc = this.page.locator(cached);\n if (await this.isVisible(loc)) return loc;\n this.logger.warn(`[LocatorHealer] Cached heal stale for \"${key}\", re-healing`);\n }\n\n {\n const loc = this.page.locator(primarySelector);\n if (await this.isVisible(loc)) return loc;\n this.logger.warn(`[LocatorHealer] Primary selector failed for \"${key}\": ${primarySelector}`);\n }\n\n const aiLocator = await this.healByAiVision(key, intent, primarySelector);\n if (aiLocator) return aiLocator;\n\n const axLocator = await this.healByAxTree(key, intent, primarySelector);\n if (axLocator) return axLocator;\n\n throw new Error(\n `[LocatorHealer] All healing strategies exhausted for \"${key}\" (intent: \"${intent}\").\\n` +\n `Check HealingDashboard at http://localhost:${process.env.HEALING_DASHBOARD_PORT ?? 7890}`,\n );\n }\n\n async clickWithHealing(key: string, selector: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n await loc.click();\n this.logger.info(`[LocatorHealer] click \"${key}\"`);\n }\n\n async fillWithHealing(key: string, selector: string, value: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n\n const inputType = await loc.evaluate((el) => {\n const tag = (el as HTMLElement).tagName.toLowerCase();\n if (tag !== 'input' && tag !== 'textarea') return 'non-input';\n return (el as HTMLInputElement).type ?? 'text';\n });\n\n const nonFillableTypes = ['submit', 'button', 'reset', 'image', 'checkbox', 'radio', 'file'];\n if (nonFillableTypes.includes(inputType) || inputType === 'non-input') {\n this.logger.warn(`[LocatorHealer] Healed locator for \"${key}\" resolved to non-fillable element — re-healing`);\n this.repo.evict(key);\n const keyLabel = key.replace(/[_-]?(textbox|input|field|box)$/i, '').replace(/_/g, ' ').trim();\n const roleLoc = await (async () => {\n try {\n const l = this.page.getByRole('textbox', { name: keyLabel, exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n try {\n const l = this.page.getByPlaceholder(keyLabel, { exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n try {\n const l = this.page.getByLabel(keyLabel, { exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n return null;\n })();\n if (!roleLoc) throw new Error(`[LocatorHealer] Cannot find fillable element for \"${key}\" (intent: \"${intent}\")`);\n await roleLoc.fill(value);\n this.persist(key, roleLoc, 'role');\n return;\n }\n\n await loc.fill(value);\n this.logger.info(`[LocatorHealer] fill \"${key}\" = \"${value}\"`);\n }\n\n async assertVisibleWithHealing(key: string, selector: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n this.logger.info(`[LocatorHealer] assertVisible \"${key}\" ✓`);\n }\n\n async assertHiddenWithHealing(key: string, selector: string, _intent: string): Promise<void> {\n const loc = this.page.locator(selector);\n await loc.waitFor({ state: 'hidden', timeout: this.TIMEOUT });\n this.logger.info(`[LocatorHealer] assertHidden \"${key}\" ✓`);\n }\n\n async getLocator(key: string, selector: string, intent: string): Promise<Locator> {\n return this.resolve(key, selector, intent);\n }\n\n /**\n * AI Vision healing — supports openai | claude | grok | ollama | local\n *\n * Configured via .env:\n * AI_PROVIDER = openai | claude | grok | ollama | local\n * AI_API_KEY = your API key (not needed for ollama/local)\n * AI_MODEL = model name override\n * LOCAL_LLM_ENDPOINT = base URL for ollama or LM Studio\n */\n private async healByAiVision(key: string, intent: string, originalSelector = ''): Promise<Locator | null> {\n const provider = (process.env.AI_PROVIDER ?? 'openai').toLowerCase();\n const apiKey = process.env.AI_API_KEY || process.env.ANTHROPIC_API_KEY;\n const needsKey = !['ollama', 'local'].includes(provider);\n if (needsKey && !apiKey) {\n this.logger.warn(`[LocatorHealer] AI Vision skipped for \"${key}\" — AI_API_KEY not set`);\n return null;\n }\n\n const prompt =\n `You are a Playwright test automation expert. Look at this screenshot.\\n` +\n `Find the element that matches: \"${intent}\".\\n` +\n `Return ONLY a valid CSS selector string. No explanation. No quotes. No code blocks.`;\n\n try {\n const screenshotBuffer = await this.page.screenshot({ fullPage: false });\n const base64Screenshot = screenshotBuffer.toString('base64');\n let selector: string | undefined;\n\n if (provider === 'openai') {\n const res = await fetch('https://api.openai.com/v1/chat/completions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'gpt-4o',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] OpenAI error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else if (provider === 'claude' || provider === 'anthropic') {\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey!, 'anthropic-version': '2023-06-01' },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'claude-opus-4-6',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image', source: { type: 'base64', media_type: 'image/png', data: base64Screenshot } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Claude error ${res.status}`); return null; }\n const data = await res.json() as { content: Array<{ type: string; text: string }> };\n selector = data.content.find(b => b.type === 'text')?.text?.trim();\n }\n\n else if (provider === 'grok') {\n const res = await fetch('https://api.x.ai/v1/chat/completions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'grok-2-vision-latest',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Grok error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else if (provider === 'ollama') {\n const baseUrl = (process.env.OLLAMA_ENDPOINT || process.env.LOCAL_LLM_ENDPOINT || 'http://localhost:11434').replace(/\\/$/, '');\n const model = process.env.OLLAMA_MODEL || process.env.AI_MODEL || 'llava';\n const res = await fetch(`${baseUrl}/api/chat`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content: prompt, images: [base64Screenshot] }] }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Ollama error ${res.status}`); return null; }\n const data = await res.json() as { message?: { content: string } };\n selector = data.message?.content?.trim();\n }\n\n else if (provider === 'local') {\n const baseUrl = (process.env.LM_STUDIO_ENDPOINT || process.env.LOCAL_LLM_ENDPOINT || 'http://localhost:1234').replace(/\\/$/, '');\n const model = process.env.LM_STUDIO_MODEL || process.env.AI_MODEL || 'local-model';\n const res = await fetch(`${baseUrl}/v1/chat/completions`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ model, max_tokens: 256, messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}] }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] LM Studio error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else {\n this.logger.warn(`[LocatorHealer] Unknown AI_PROVIDER \"${provider}\"`);\n return null;\n }\n\n if (!selector) return null;\n selector = selector.replace(/\\[([^\\]='\"\\s]+)='([^']*)'\\]/g, '[$1=\"$2\"]');\n this.logger.warn(`[LocatorHealer] AI Vision (${provider}) suggested: ${selector}`);\n const loc = this.page.locator(selector);\n if (await this.isVisible(loc)) {\n this.persist(key, loc, 'ai-vision', intent, originalSelector, selector);\n return loc;\n }\n } catch (err) {\n this.logger.warn(`[LocatorHealer] AI Vision failed for \"${key}\": ${String(err)}`);\n }\n return null;\n }\n\n private async healByAxTree(key: string, intent: string, originalSelector = ''): Promise<Locator | null> {\n const INTERACTIVE_ROLES = new Set([\n 'textbox', 'searchbox', 'spinbutton', 'combobox', 'listbox',\n 'button', 'link', 'checkbox', 'radio', 'menuitem', 'tab', 'switch',\n 'slider', 'option', 'treeitem', 'gridcell',\n ]);\n\n try {\n const client = await (this.page.context() as unknown as { newCDPSession(page: Page): Promise<{ send(cmd: string): Promise<{ nodes: unknown[] }>; detach(): Promise<void> }> }).newCDPSession(this.page);\n const { nodes } = await client.send('Accessibility.getFullAXTree');\n await client.detach();\n\n const intentWords = intent.toLowerCase().split(/\\s+/).filter(w => w.length > 2);\n let best: { node: Record<string, unknown>; score: number } | null = null;\n\n for (const n of (nodes as Record<string, Record<string, string>>[]) ) {\n const role = (n['role']?.['value'] ?? '').toLowerCase();\n const name = (n['name']?.['value'] ?? '').toLowerCase();\n if (!INTERACTIVE_ROLES.has(role) || !name) continue;\n const hasWordMatch = intentWords.some(word => new RegExp(`\\\\b${word}\\\\b`).test(name));\n if (!hasWordMatch) continue;\n const score = intentWords.filter(word => new RegExp(`\\\\b${word}\\\\b`).test(name)).length;\n if (!best || score > best.score) best = { node: n as unknown as Record<string, unknown>, score };\n }\n\n if (best?.node) {\n const role = (best.node['role'] as Record<string, string>)?.['value'];\n const name = (best.node['name'] as Record<string, string>)?.['value'];\n if (role && name) {\n const axLoc = this.page.getByRole(role as Parameters<Page['getByRole']>[0], { name, exact: false });\n if (await this.isVisible(axLoc)) {\n this.persist(key, axLoc, 'ax-tree', intent, originalSelector, `[role=\"${role}\"][name=\"${name}\"]`);\n return axLoc;\n }\n }\n }\n } catch (err) {\n this.logger.warn(`[LocatorHealer] AX tree healing failed: ${String(err)}`);\n }\n return null;\n }\n\n private async isVisible(loc: Locator): Promise<boolean> {\n try {\n return await loc.isVisible({ timeout: 2_000 });\n } catch {\n return false;\n }\n }\n\n private persist(key: string, _loc: Locator, strategy: string, intent = '', originalSelector = '', healedSelector?: string): void {\n const provider = strategy === 'ai-vision' ? (process.env.AI_PROVIDER ?? 'openai') : undefined;\n if (healedSelector) {\n try {\n if (!this.repo.getHealed(key) && originalSelector) {\n this.repo.register(key, originalSelector, intent);\n }\n this.repo.setHealed(key, healedSelector, strategy, provider);\n this.logger.info(`[LocatorHealer] 💾 Healed selector saved for \"${key}\" → ${healedSelector}`);\n } catch (err) {\n this.logger.warn(`[LocatorHealer] Could not persist heal for \"${key}\": ${String(err)}`);\n }\n }\n try {\n HealingDashboard.getInstance().record({\n key,\n originalSelector,\n strategy,\n provider,\n healedSelector,\n intent,\n scenario: this.currentScenario,\n timestamp: new Date().toISOString(),\n });\n } catch { /* dashboard may not be running */ }\n }\n}\n",
47
+ "src/utils/locators/LocatorHealer.ts": "import { Page, Locator } from '@playwright/test';\nimport { LocatorRepository } from './LocatorRepository';\nimport { HealingDashboard } from './HealingDashboard';\n\nexport interface HealerLogger {\n info(msg: string): void;\n warn(msg: string): void;\n error(msg: string): void;\n}\n\n/**\n * LocatorHealer — Layer 1 Self-Healing\n *\n * Healing chain per action:\n * 1. Original selector (from LocatorRepository.getBestSelector)\n * 2. Role-based: getByRole inferred from intent\n * 3. Label-based: getByLabel / getByPlaceholder\n * 4. Text-based: getByText\n * 5. AI Vision: provider-agnostic vision API (openai | claude | grok | ollama | local)\n * 6. CDPSession AX tree walk (last resort)\n *\n * Provider is controlled by AI_PROVIDER in .env:\n * openai — OpenAI GPT-4o or gpt-4-vision-preview\n * claude — Anthropic Claude (claude-opus-4-6 or newer)\n * grok — xAI Grok vision API\n * ollama — Local Ollama with a vision model (e.g. llava, moondream2)\n * local — LM Studio or any OpenAI-compatible local server\n *\n * Healed selectors are persisted in LocatorRepository — zero overhead on repeat runs.\n */\nexport class LocatorHealer {\n private readonly TIMEOUT = 8_000;\n currentScenario?: string;\n\n constructor(\n private readonly page: Page,\n private readonly logger: HealerLogger,\n private readonly repo: LocatorRepository,\n ) {}\n\n async resolve(key: string, primarySelector: string, intent: string): Promise<Locator> {\n const cached = this.repo.getHealed(key);\n if (cached) {\n const loc = this.page.locator(cached);\n if (await this.isVisible(loc)) return loc;\n this.logger.warn(`[LocatorHealer] Cached heal stale for \"${key}\", re-healing`);\n }\n\n {\n const loc = this.page.locator(primarySelector);\n if (await this.isVisible(loc)) return loc;\n this.logger.warn(`[LocatorHealer] Primary selector failed for \"${key}\": ${primarySelector}`);\n }\n\n // 1.5. data-test fuzzy fallback — catches single-char typos/off-by-one errors in [data-test=\"X\"] selectors\n // Zero API calls; runs before AI Vision to avoid unnecessary LLM round-trips.\n const dataTestHeal = await this._healByDataTestFuzzy(primarySelector);\n if (dataTestHeal) {\n this.persist(key, dataTestHeal.loc, 'data-test-fuzzy', intent, primarySelector, dataTestHeal.selector);\n return dataTestHeal.loc;\n }\n\n const aiLocator = await this.healByAiVision(key, intent, primarySelector);\n if (aiLocator) return aiLocator;\n\n const axLocator = await this.healByAxTree(key, intent, primarySelector);\n if (axLocator) return axLocator;\n\n throw new Error(\n `[LocatorHealer] All healing strategies exhausted for \"${key}\" (intent: \"${intent}\").\\n` +\n `Check HealingDashboard at http://localhost:${process.env.HEALING_DASHBOARD_PORT ?? 7890}`,\n );\n }\n\n async clickWithHealing(key: string, selector: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n await loc.click();\n this.logger.info(`[LocatorHealer] click \"${key}\"`);\n }\n\n async fillWithHealing(key: string, selector: string, value: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n\n const inputType = await loc.evaluate((el) => {\n const tag = (el as HTMLElement).tagName.toLowerCase();\n if (tag !== 'input' && tag !== 'textarea') return 'non-input';\n return (el as HTMLInputElement).type ?? 'text';\n });\n\n const nonFillableTypes = ['submit', 'button', 'reset', 'image', 'checkbox', 'radio', 'file'];\n if (nonFillableTypes.includes(inputType) || inputType === 'non-input') {\n this.logger.warn(`[LocatorHealer] Healed locator for \"${key}\" resolved to non-fillable element — re-healing`);\n this.repo.evict(key);\n const keyLabel = key.replace(/[_-]?(textbox|input|field|box)$/i, '').replace(/_/g, ' ').trim();\n const roleLoc = await (async () => {\n try {\n const l = this.page.getByRole('textbox', { name: keyLabel, exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n try {\n const l = this.page.getByPlaceholder(keyLabel, { exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n try {\n const l = this.page.getByLabel(keyLabel, { exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n return null;\n })();\n if (!roleLoc) throw new Error(`[LocatorHealer] Cannot find fillable element for \"${key}\" (intent: \"${intent}\")`);\n await roleLoc.fill(value);\n this.persist(key, roleLoc, 'role');\n return;\n }\n\n await loc.fill(value);\n this.logger.info(`[LocatorHealer] fill \"${key}\" = \"${value}\"`);\n }\n\n async assertVisibleWithHealing(key: string, selector: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n this.logger.info(`[LocatorHealer] assertVisible \"${key}\" ✓`);\n }\n\n async assertHiddenWithHealing(key: string, selector: string, _intent: string): Promise<void> {\n const loc = this.page.locator(selector);\n await loc.waitFor({ state: 'hidden', timeout: this.TIMEOUT });\n this.logger.info(`[LocatorHealer] assertHidden \"${key}\" ✓`);\n }\n\n async getLocator(key: string, selector: string, intent: string): Promise<Locator> {\n return this.resolve(key, selector, intent);\n }\n\n /**\n * AI Vision healing — supports openai | claude | grok | ollama | local\n *\n * Configured via .env:\n * AI_PROVIDER = openai | claude | grok | ollama | local\n * AI_API_KEY = your API key (not needed for ollama/local)\n * AI_MODEL = model name override\n * LOCAL_LLM_ENDPOINT = base URL for ollama or LM Studio\n */\n private async healByAiVision(key: string, intent: string, originalSelector = ''): Promise<Locator | null> {\n const provider = (process.env.AI_PROVIDER ?? 'openai').toLowerCase();\n const apiKey = process.env.AI_API_KEY || process.env.ANTHROPIC_API_KEY;\n const needsKey = !['ollama', 'local'].includes(provider);\n if (needsKey && !apiKey) {\n this.logger.warn(`[LocatorHealer] AI Vision skipped for \"${key}\" — AI_API_KEY not set`);\n return null;\n }\n\n const prompt =\n `You are a Playwright test automation expert. Look at this screenshot.\\n` +\n `Find the element that matches: \"${intent}\".\\n` +\n `Return ONLY a valid CSS selector string. No explanation. No quotes. No code blocks.`;\n\n try {\n const screenshotBuffer = await this.page.screenshot({ fullPage: false });\n const base64Screenshot = screenshotBuffer.toString('base64');\n let selector: string | undefined;\n\n if (provider === 'openai') {\n const res = await fetch('https://api.openai.com/v1/chat/completions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'gpt-4o',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] OpenAI error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else if (provider === 'claude' || provider === 'anthropic') {\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey!, 'anthropic-version': '2023-06-01' },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'claude-opus-4-6',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image', source: { type: 'base64', media_type: 'image/png', data: base64Screenshot } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Claude error ${res.status}`); return null; }\n const data = await res.json() as { content: Array<{ type: string; text: string }> };\n selector = data.content.find(b => b.type === 'text')?.text?.trim();\n }\n\n else if (provider === 'grok') {\n const res = await fetch('https://api.x.ai/v1/chat/completions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'grok-2-vision-latest',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Grok error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else if (provider === 'ollama') {\n const baseUrl = (process.env.OLLAMA_ENDPOINT || process.env.LOCAL_LLM_ENDPOINT || 'http://localhost:11434').replace(/\\/$/, '');\n const model = process.env.OLLAMA_MODEL || process.env.AI_MODEL || 'llava';\n const res = await fetch(`${baseUrl}/api/chat`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content: prompt, images: [base64Screenshot] }] }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Ollama error ${res.status}`); return null; }\n const data = await res.json() as { message?: { content: string } };\n selector = data.message?.content?.trim();\n }\n\n else if (provider === 'local') {\n const baseUrl = (process.env.LM_STUDIO_ENDPOINT || process.env.LOCAL_LLM_ENDPOINT || 'http://localhost:1234').replace(/\\/$/, '');\n const model = process.env.LM_STUDIO_MODEL || process.env.AI_MODEL || 'local-model';\n const res = await fetch(`${baseUrl}/v1/chat/completions`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ model, max_tokens: 256, messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}] }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] LM Studio error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else {\n this.logger.warn(`[LocatorHealer] Unknown AI_PROVIDER \"${provider}\"`);\n return null;\n }\n\n if (!selector) return null;\n selector = selector.replace(/\\[([^\\]='\"\\s]+)='([^']*)'\\]/g, '[$1=\"$2\"]');\n this.logger.warn(`[LocatorHealer] AI Vision (${provider}) suggested: ${selector}`);\n const loc = this.page.locator(selector);\n if (await this.isVisible(loc)) {\n this.persist(key, loc, 'ai-vision', intent, originalSelector, selector);\n return loc;\n }\n } catch (err) {\n this.logger.warn(`[LocatorHealer] AI Vision failed for \"${key}\": ${String(err)}`);\n }\n return null;\n }\n\n private async healByAxTree(key: string, intent: string, originalSelector = ''): Promise<Locator | null> {\n const INTERACTIVE_ROLES = new Set([\n 'textbox', 'searchbox', 'spinbutton', 'combobox', 'listbox',\n 'button', 'link', 'checkbox', 'radio', 'menuitem', 'tab', 'switch',\n 'slider', 'option', 'treeitem', 'gridcell',\n ]);\n\n try {\n const client = await (this.page.context() as unknown as { newCDPSession(page: Page): Promise<{ send(cmd: string): Promise<{ nodes: unknown[] }>; detach(): Promise<void> }> }).newCDPSession(this.page);\n const { nodes } = await client.send('Accessibility.getFullAXTree');\n await client.detach();\n\n const intentWords = intent.toLowerCase().split(/\\s+/).filter(w => w.length > 2);\n let best: { node: Record<string, unknown>; score: number } | null = null;\n\n for (const n of (nodes as Record<string, Record<string, string>>[]) ) {\n const role = (n['role']?.['value'] ?? '').toLowerCase();\n const name = (n['name']?.['value'] ?? '').toLowerCase();\n if (!INTERACTIVE_ROLES.has(role) || !name) continue;\n const hasWordMatch = intentWords.some(word => new RegExp(`\\\\b${word}\\\\b`).test(name));\n if (!hasWordMatch) continue;\n const score = intentWords.filter(word => new RegExp(`\\\\b${word}\\\\b`).test(name)).length;\n if (!best || score > best.score) best = { node: n as unknown as Record<string, unknown>, score };\n }\n\n if (best?.node) {\n const role = (best.node['role'] as Record<string, string>)?.['value'];\n const name = (best.node['name'] as Record<string, string>)?.['value'];\n if (role && name) {\n const axLoc = this.page.getByRole(role as Parameters<Page['getByRole']>[0], { name, exact: false });\n if (await this.isVisible(axLoc)) {\n this.persist(key, axLoc, 'ax-tree', intent, originalSelector, `[role=\"${role}\"][name=\"${name}\"]`);\n return axLoc;\n }\n }\n }\n } catch (err) {\n this.logger.warn(`[LocatorHealer] AX tree healing failed: ${String(err)}`);\n }\n return null;\n }\n\n /**\n * Fuzzy fallback for [data-test=\"X\"] selectors.\n *\n * If the primary selector is a [data-test=\"X\"] attribute selector, this\n * method collects all [data-test] values currently in the DOM and returns\n * the closest match within Levenshtein distance 1 (single-char typo /\n * off-by-one). Zero API calls — runs synchronously via page.evaluate().\n *\n * Returns { loc, selector } when a visible match is found, otherwise null.\n */\n private async _healByDataTestFuzzy(\n primarySelector: string,\n ): Promise<{ loc: Locator; selector: string } | null> {\n const m = primarySelector.match(/\\[data-test(?:id)?=[\"']([^\"']+)[\"'\\]]/i);\n if (!m) return null;\n const target = m[1];\n const attr = /data-testid/i.test(primarySelector) ? 'data-testid' : 'data-test';\n\n let candidates: string[] = [];\n try {\n candidates = await this.page.evaluate((a: string) => {\n const els = document.querySelectorAll<HTMLElement>(`[${a}]`);\n return Array.from(els)\n .map(el => el.getAttribute(a) ?? '')\n .filter(Boolean);\n }, attr);\n } catch {\n return null;\n }\n\n const lev = (a: string, b: string): number => {\n const rows = a.length, cols = b.length;\n const dp: number[][] = Array.from({ length: rows + 1 }, (_, i) =>\n Array.from({ length: cols + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),\n );\n for (let i = 1; i <= rows; i++)\n for (let j = 1; j <= cols; j++)\n dp[i][j] =\n a[i - 1] === b[j - 1]\n ? dp[i - 1][j - 1]\n : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);\n return dp[rows][cols];\n };\n\n let best: { val: string; dist: number } | null = null;\n for (const val of candidates) {\n const dist = lev(target, val);\n if (dist <= 1 && (!best || dist < best.dist)) best = { val, dist };\n }\n\n if (!best) return null;\n\n const selector = `[${attr}=\"${best.val}\"]`;\n this.logger.warn(\n `[LocatorHealer] data-test fuzzy heal: \"${target}\" → \"${best.val}\" (dist=${best.dist})`,\n );\n const loc = this.page.locator(selector);\n if (await this.isVisible(loc)) return { loc, selector };\n return null;\n }\n private async isVisible(loc: Locator): Promise<boolean> {\n try {\n return await loc.isVisible({ timeout: 2_000 });\n } catch {\n return false;\n }\n }\n\n private persist(key: string, _loc: Locator, strategy: string, intent = '', originalSelector = '', healedSelector?: string): void {\n const provider = strategy === 'ai-vision' ? (process.env.AI_PROVIDER ?? 'openai') : undefined;\n if (healedSelector) {\n try {\n if (!this.repo.getHealed(key) && originalSelector) {\n this.repo.register(key, originalSelector, intent);\n }\n this.repo.setHealed(key, healedSelector, strategy, provider);\n this.logger.info(`[LocatorHealer] 💾 Healed selector saved for \"${key}\" → ${healedSelector}`);\n } catch (err) {\n this.logger.warn(`[LocatorHealer] Could not persist heal for \"${key}\": ${String(err)}`);\n }\n }\n try {\n HealingDashboard.getInstance().record({\n key,\n originalSelector,\n strategy,\n provider,\n healedSelector,\n intent,\n scenario: this.currentScenario,\n timestamp: new Date().toISOString(),\n });\n } catch { /* dashboard may not be running */ }\n }\n}\n",
48
48
  "src/utils/locators/LocatorManager.ts": "import { Page } from '@playwright/test';\nimport { logger } from '@utils/helpers/Logger';\nimport { LocatorStrategy, LocatorConfig } from './LocatorStrategy';\nimport { AISelfHealing } from '@utils/ai-assistant/AISelfHealing';\nimport { environment } from '@config/environment';\nimport * as fs from 'fs';\n\nexport type { LocatorConfig };\n\nexport interface LocatorDefinition {\n primary: LocatorConfig;\n fallbacks?: LocatorConfig[];\n}\n\nexport interface LocatorMap {\n [key: string]: LocatorDefinition;\n}\n\n/**\n * LocatorManager\n *\n * Centralised registry for page/component locator maps.\n * On lookup, tries primary → fallbacks → AISelfHealing.\n *\n * Usage (page object constructor):\n * this.locatorManager.registerLocators('LoginPage', LOGIN_LOCATORS, loginLocatorsFilePath);\n *\n * Usage (page method):\n * const loc = await this.locatorManager.getLocator('LoginPage', 'USERNAME_INPUT');\n */\nexport class LocatorManager {\n private readonly locatorStrategy: LocatorStrategy;\n private readonly locatorMaps = new Map<string, LocatorMap>();\n private readonly fileCache = new Map<string, string>();\n private readonly selfHealing: AISelfHealing;\n private readonly env = environment.getConfig();\n\n constructor(private readonly page: Page) {\n this.locatorStrategy = new LocatorStrategy(page);\n this.selfHealing = new AISelfHealing(page);\n }\n\n registerLocators(name: string, locators: LocatorMap, filePath?: string): void {\n this.locatorMaps.set(name, locators);\n if (filePath) this.fileCache.set(name, filePath);\n logger.debug(`Locators registered for: ${name} (${Object.keys(locators).length} keys)`);\n }\n\n async getLocator(pageName: string, locatorKey: string): Promise<import('@playwright/test').Locator | null> {\n const locatorMap = this.locatorMaps.get(pageName);\n if (!locatorMap) { logger.warn(`Locator map not found for page: ${pageName}`); return null; }\n const definition = locatorMap[locatorKey];\n if (!definition) { logger.warn(`Locator key not found: ${locatorKey} in ${pageName}`); return null; }\n\n const loc = await this.tryStrategies(definition, pageName, locatorKey);\n if (loc) return loc;\n\n if (this.env.enableSelfHealing) {\n return this.attemptSelfHealing(pageName, locatorKey, definition);\n }\n\n logger.warn(`All strategies failed for ${locatorKey} on ${pageName}`);\n return null;\n }\n\n private async tryStrategies(def: LocatorDefinition, pageName: string, key: string): Promise<import('@playwright/test').Locator | null> {\n for (const strategy of [def.primary, ...(def.fallbacks ?? [])]) {\n try {\n const loc = this.locatorStrategy.getLocator(strategy);\n await loc.waitFor({ timeout: 2_000 });\n logger.debug(`Found via ${strategy.strategy}`, { page: pageName, key });\n return loc;\n } catch { /* try next */ }\n }\n return null;\n }\n\n private async attemptSelfHealing(pageName: string, key: string, def: LocatorDefinition): Promise<import('@playwright/test').Locator | null> {\n logger.info(`Self-healing \"${key}\" on ${pageName}`);\n try {\n const report = await this.selfHealing.heal(\n def.primary.value,\n key.replace(/_/g, ' ').toLowerCase(),\n { elementType: this.inferElementType(key), context: pageName, maxAttempts: this.env.locatorHealAttempts ?? 3 },\n );\n if (report.success && report.healedSelector) {\n logger.info(`Healed via ${report.method}`, { key, selector: report.healedSelector });\n this.updateDefinition(pageName, key, report.healedSelector);\n return this.page.locator(report.healedSelector);\n }\n } catch (err) {\n logger.error(`Healing error for \"${key}\": ${String(err)}`);\n }\n return null;\n }\n\n private updateDefinition(pageName: string, key: string, healedSelector: string): void {\n const def = this.locatorMaps.get(pageName)?.[key];\n if (!def) return;\n def.primary = { strategy: 'css', value: healedSelector };\n try {\n const filePath = this.fileCache.get(pageName);\n if (filePath && fs.existsSync(filePath)) {\n const content = fs.readFileSync(filePath, 'utf-8');\n const updated = content.replace(\n new RegExp(`(\\\\[?${key}[\\\\]:]?)([^}]*?)value:\\\\s*['\"][^'\"]*['\"]`, 'g'),\n `$1$2value: '${healedSelector.replace(/'/g, \"\\\\'\")}'`,\n );\n fs.writeFileSync(filePath, updated);\n }\n } catch (err) {\n logger.warn(`Could not persist healed locator for \"${key}\": ${String(err)}`);\n }\n }\n\n getStrategies(pageName: string, locatorKey: string): LocatorConfig[] {\n const def = this.locatorMaps.get(pageName)?.[locatorKey];\n if (!def) return [];\n return [def.primary, ...(def.fallbacks ?? [])];\n }\n\n getDynamicLocator(pageName: string, locatorKey: string, params: Record<string, string>): LocatorConfig | null {\n const def = this.locatorMaps.get(pageName)?.[locatorKey];\n if (!def) return null;\n let value = def.primary.value;\n for (const [k, v] of Object.entries(params)) value = value.replace(`{${k}}`, v);\n return { ...def.primary, value };\n }\n\n getRegisteredPages(): string[] { return [...this.locatorMaps.keys()]; }\n getPageLocators(pageName: string): string[] {\n const map = this.locatorMaps.get(pageName);\n return map ? Object.keys(map) : [];\n }\n\n private inferElementType(key: string): string {\n const k = key.toUpperCase();\n const map: Record<string, string> = { BUTTON: 'button', CLICK: 'button', INPUT: 'input', FIELD: 'input', LINK: 'link', ANCHOR: 'link', CHECKBOX: 'checkbox', RADIO: 'radio', SELECT: 'select', DROPDOWN: 'select', LABEL: 'label', HEADING: 'heading', TITLE: 'heading', IMAGE: 'img', ICON: 'img' };\n for (const [pattern, type] of Object.entries(map)) { if (k.includes(pattern)) return type; }\n return 'button';\n }\n}\n",
49
49
  "src/utils/locators/LocatorRepository.ts": "import * as fs from 'fs';\nimport * as path from 'path';\n\nconst HEAL_STORE_PATH = path.resolve(\n process.env.HEAL_STORE_PATH ?? 'storage-state/healed-locators.json',\n);\n\ninterface HealRecord {\n healedSelector: string;\n originalSelector: string;\n strategy: string;\n provider?: string;\n intent: string;\n healCount: number;\n lastHealedAt: string;\n approved?: boolean;\n}\n\nfunction loadHealStore(): Record<string, HealRecord> {\n try {\n if (!fs.existsSync(HEAL_STORE_PATH)) return {};\n const raw = fs.readFileSync(HEAL_STORE_PATH, 'utf8');\n return JSON.parse(raw) as Record<string, HealRecord>;\n } catch {\n return {};\n }\n}\n\nfunction persistHeal(key: string, record: HealRecord): void {\n try {\n const dir = path.dirname(HEAL_STORE_PATH);\n if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });\n const store = loadHealStore();\n store[key] = record;\n fs.writeFileSync(HEAL_STORE_PATH, JSON.stringify(store, null, 2), 'utf8');\n } catch (err) {\n console.warn(`[LocatorRepository] Could not write heal store: ${String(err)}`);\n }\n}\n\nfunction evictHeal(key: string): void {\n try {\n if (!fs.existsSync(HEAL_STORE_PATH)) return;\n const store = loadHealStore();\n if (key in store) {\n delete store[key];\n fs.writeFileSync(HEAL_STORE_PATH, JSON.stringify(store, null, 2), 'utf8');\n }\n } catch { /* non-fatal */ }\n}\n\nexport interface LocatorEntry {\n key: string;\n selector: string;\n intent: string;\n healedSelector?: string;\n healStrategy?: string;\n healProvider?: string;\n healCount: number;\n lastHealedAt?: Date;\n id?: string;\n name?: string;\n page?: string;\n locator?: string;\n strategy?: string;\n value?: string;\n options?: Record<string, unknown>;\n metadata?: Record<string, unknown>;\n}\n\nexport class LocatorRepository {\n private static _instance: LocatorRepository | null = null;\n\n static getInstance(): LocatorRepository {\n if (!LocatorRepository._instance) {\n LocatorRepository._instance = new LocatorRepository();\n }\n return LocatorRepository._instance;\n }\n\n static resetInstance(): void {\n LocatorRepository._instance = null;\n }\n\n private readonly entries = new Map<string, LocatorEntry>();\n\n register(key: string, selector: string, intent: string): void {\n if (!this.entries.has(key)) {\n const entry: LocatorEntry = { key, selector, intent, healCount: 0 };\n const store = loadHealStore();\n if (store[key]) {\n entry.healedSelector = store[key].healedSelector;\n entry.healStrategy = store[key].strategy;\n entry.healProvider = store[key].provider;\n entry.healCount = store[key].healCount;\n entry.lastHealedAt = new Date(store[key].lastHealedAt);\n }\n this.entries.set(key, entry);\n }\n }\n\n getBestSelector(key: string): string {\n const entry = this.entries.get(key);\n if (!entry) throw new Error(`LocatorRepository: key \"${key}\" not registered`);\n return entry.healedSelector ?? entry.selector;\n }\n\n getHealed(key: string): string | null {\n return this.entries.get(key)?.healedSelector ?? null;\n }\n\n setHealed(key: string, healedSelector: string, strategy = 'unknown', provider?: string): void {\n const entry = this.entries.get(key);\n if (!entry) {\n this.entries.set(key, { key, selector: '', intent: key, healCount: 0 });\n }\n const e = this.entries.get(key)!;\n e.healedSelector = healedSelector;\n e.healStrategy = strategy;\n e.healProvider = provider;\n e.healCount++;\n e.lastHealedAt = new Date();\n persistHeal(key, {\n healedSelector,\n originalSelector: e.selector,\n strategy,\n provider,\n intent: e.intent,\n healCount: e.healCount,\n lastHealedAt: e.lastHealedAt.toISOString(),\n });\n }\n\n evict(key: string): void {\n const entry = this.entries.get(key);\n if (entry) {\n entry.healedSelector = undefined;\n entry.healStrategy = undefined;\n entry.healProvider = undefined;\n }\n evictHeal(key);\n }\n\n getIntent(key: string): string {\n const entry = this.entries.get(key);\n if (!entry) throw new Error(`LocatorRepository: key \"${key}\" not registered`);\n return entry.intent;\n }\n\n getByName(name: string): LocatorEntry | undefined {\n for (const entry of this.entries.values()) {\n if (entry.name === name || entry.key === name) return entry;\n }\n const lower = name.toLowerCase();\n for (const entry of this.entries.values()) {\n if (\n (entry.name ?? '').toLowerCase() === lower ||\n entry.key.toLowerCase() === lower\n ) return entry;\n }\n return undefined;\n }\n\n update(partial: Partial<LocatorEntry> & { id: string }): void {\n const key = partial.id;\n const existing = this.entries.get(key);\n if (existing) {\n Object.assign(existing, partial);\n if (partial.locator) {\n existing.healedSelector = partial.locator;\n existing.healCount = (existing.healCount ?? 0) + 1;\n existing.lastHealedAt = new Date();\n }\n } else {\n this.entries.set(key, {\n key,\n selector: partial.selector ?? partial.locator ?? '',\n intent: partial.name ?? key,\n healCount: 0,\n ...partial,\n ...(partial.locator ? { healedSelector: partial.locator } : {}),\n });\n }\n }\n\n getAll(): LocatorEntry[] {\n return Array.from(this.entries.values());\n }\n\n clearHealed(): void {\n for (const entry of this.entries.values()) {\n delete entry.healedSelector;\n entry.healCount = 0;\n delete entry.lastHealedAt;\n }\n }\n\n summary(): { total: number; healed: number; healRate: string } {\n const total = this.entries.size;\n const healed = Array.from(this.entries.values()).filter(e => e.healedSelector).length;\n return {\n total,\n healed,\n healRate: total ? `${Math.round((healed / total) * 100)}%` : '0%',\n };\n }\n}\n",
50
50
  "src/utils/locators/LocatorRules.ts": "import * as fs from 'fs';\nimport * as path from 'path';\nimport { logger } from '@utils/helpers/Logger';\n\nexport type RulePriorityKey = 'dataTestId' | 'role' | 'ariaLabel' | 'name' | 'id' | 'text' | 'placeholder' | 'css' | 'xpath';\nexport type LocatorPattern = 'testid' | 'role' | 'label' | 'placeholder' | 'text' | 'css' | 'xpath';\n\nexport interface OptimizationRules {\n priorities: RulePriorityKey[];\n uniqueCheck: boolean;\n}\n\nexport interface RuleCondition {\n attribute: string;\n operator: 'exists' | 'equals' | 'contains' | 'startsWith' | 'endsWith' | 'matches';\n value?: string | RegExp;\n required: boolean;\n}\n\nexport interface RuleExample {\n html: string;\n locatorValue: string;\n description: string;\n success: boolean;\n}\n\nexport interface LocatorRule {\n id: string;\n name: string;\n pattern: LocatorPattern;\n elementTypes: string[];\n priority: number;\n conditions: RuleCondition[];\n examples: RuleExample[];\n confidence: number;\n}\n\nconst CONTEXT_RULES = {\n formInputs: { preferredStrategies: ['testid', 'label', 'placeholder'], description: 'Form input fields' },\n buttons: { preferredStrategies: ['testid', 'role', 'text'], description: 'Clickable buttons' },\n navigationLinks: { preferredStrategies: ['text', 'role', 'testid'], description: 'Navigation links' },\n dropdowns: { preferredStrategies: ['testid', 'label', 'css'], description: 'Select/dropdown elements' },\n};\n\nexport function getContextRules() { return CONTEXT_RULES; }\n\nexport function getAILocatorRules(): LocatorRule[] {\n return [\n {\n id: 'rule-testid-primary', name: 'Test ID — Primary Strategy', pattern: 'testid',\n elementTypes: ['button', 'input', 'select', 'link', 'div', 'span'], priority: 1,\n conditions: [{ attribute: 'data-testid', operator: 'exists', required: true }],\n examples: [{ html: '<button data-testid=\"login-button\">Login</button>', locatorValue: 'login-button', description: 'Button with data-testid', success: true }],\n confidence: 0.95,\n },\n {\n id: 'rule-aria-label', name: 'ARIA Label Strategy', pattern: 'label',\n elementTypes: ['button', 'input', 'link'], priority: 2,\n conditions: [{ attribute: 'aria-label', operator: 'exists', required: true }],\n examples: [{ html: '<button aria-label=\"Close Menu\">×</button>', locatorValue: 'Close Menu', description: 'Button with aria-label', success: true }],\n confidence: 0.9,\n },\n {\n id: 'rule-placeholder', name: 'Placeholder Strategy', pattern: 'placeholder',\n elementTypes: ['input', 'textarea'], priority: 3,\n conditions: [{ attribute: 'placeholder', operator: 'exists', required: true }],\n examples: [{ html: '<input placeholder=\"Enter username\">', locatorValue: 'Enter username', description: 'Input with placeholder', success: true }],\n confidence: 0.85,\n },\n {\n id: 'rule-visible-text', name: 'Visible Text Strategy', pattern: 'text',\n elementTypes: ['button', 'link', 'span', 'div'], priority: 4,\n conditions: [{ attribute: 'textContent', operator: 'exists', required: true }],\n examples: [{ html: '<button>Login</button>', locatorValue: 'Login', description: 'Button with visible text', success: true }],\n confidence: 0.75,\n },\n {\n id: 'rule-css-selector', name: 'CSS Selector Strategy', pattern: 'css',\n elementTypes: ['*'], priority: 5,\n conditions: [{ attribute: 'class', operator: 'exists', required: true }],\n examples: [{ html: '<button class=\"btn btn-primary\">Login</button>', locatorValue: '.btn.btn-primary', description: 'Button with class', success: true }],\n confidence: 0.7,\n },\n ];\n}\n\n/**\n * LocatorRules\n *\n * Singleton that loads selector strategy priorities.\n * Optionally reads a `.vscode/copilot-instructions.md` file to auto-tune priorities.\n * Falls back to sensible defaults if the file is absent.\n */\nexport class LocatorRules {\n private static instance: LocatorRules;\n private rules: OptimizationRules = {\n priorities: ['dataTestId', 'role', 'ariaLabel', 'name', 'id', 'text', 'placeholder', 'css', 'xpath'],\n uniqueCheck: true,\n };\n\n private constructor() { this.loadRules(); }\n\n static getInstance(): LocatorRules {\n if (!LocatorRules.instance) LocatorRules.instance = new LocatorRules();\n return LocatorRules.instance;\n }\n\n private loadRules(): void {\n try {\n const rulesPath = path.join(process.cwd(), '.vscode', 'copilot-instructions.md');\n if (!fs.existsSync(rulesPath)) return;\n const content = fs.readFileSync(rulesPath, 'utf-8');\n const found: RulePriorityKey[] = [];\n const add = (key: RulePriorityKey, patterns: RegExp[]) => {\n if (patterns.some(re => re.test(content))) found.push(key);\n };\n add('dataTestId', [/data-?test(id)?/i, /testid/i]);\n add('role', [/role-?based/i, /\\brole\\b/i]);\n add('ariaLabel', [/aria-?label/i]);\n add('name', [/\\bname\\b/i]);\n add('id', [/\\bid\\b/i]);\n add('text', [/text-?based/i, /visible text/i]);\n add('placeholder',[/placeholder/i]);\n add('css', [/css selector/i]);\n add('xpath', [/xpath/i]);\n if (found.length) {\n this.rules.priorities = [...found, ...this.rules.priorities.filter(d => !found.includes(d))];\n }\n logger.info(`LocatorRules loaded: [${this.rules.priorities.join(', ')}]`);\n } catch (err) {\n logger.warn(`LocatorRules: using defaults (${String(err)})`);\n }\n }\n\n getPriorities(): RulePriorityKey[] { return this.rules.priorities; }\n\n orderStrategies(hints: Record<string, unknown>): RulePriorityKey[] {\n const available = this.rules.priorities.filter(p => hints[p] !== undefined);\n return available.length ? available : this.rules.priorities;\n }\n\n getRulesForElementType(elementType: string): LocatorRule[] {\n return getAILocatorRules().filter(r => r.elementTypes.includes(elementType) || r.elementTypes.includes('*')).sort((a, b) => a.priority - b.priority);\n }\n\n getContextPreferences(contextType: string): unknown {\n return CONTEXT_RULES[contextType as keyof typeof CONTEXT_RULES];\n }\n}\n",
@@ -44,7 +44,7 @@ BOILERPLATE: dict[str, str] = {
44
44
  "src/utils/locators/ElementContextHelper.ts": "import { Page } from '@playwright/test';\n\nexport interface ElementContext {\n id: string;\n name: string;\n page: string;\n metadata: {\n nearText?: string;\n role?: string;\n ariaLabel?: string;\n placeholder?: string;\n text?: string;\n };\n}\n\n/**\n * ElementContextHelper\n *\n * Gathers contextual metadata about a named element on the current page\n * to provide richer hints to AI self-healing and locator generation.\n *\n * Used by LocatorManager when building a heal context payload.\n */\nexport class ElementContextHelper {\n constructor(private readonly page: Page) {}\n\n async buildContext(name: string, overrides?: Partial<ElementContext['metadata']>): Promise<ElementContext> {\n const url = this.page.url();\n const nearText = await this.findNearbyText(name);\n const role = overrides?.role ?? await this.inferRole(name);\n const ariaLabel = overrides?.ariaLabel ?? await this.inferAriaLabel(name);\n const placeholder = overrides?.placeholder ?? await this.inferPlaceholder(name);\n\n return {\n id: name, name, page: url,\n metadata: {\n nearText: overrides?.nearText ?? (nearText || undefined),\n role: role || undefined,\n ariaLabel: ariaLabel || undefined,\n placeholder: placeholder || undefined,\n text: name,\n },\n };\n }\n\n private async inferRole(name: string): Promise<string | null> {\n const candidates = ['button', 'link', 'textbox', 'heading'];\n for (const role of candidates) {\n try {\n const count = await this.page.getByRole(role as Parameters<Page['getByRole']>[0], { name: new RegExp(name, 'i') }).count();\n if (count > 0) return role;\n } catch { /* continue */ }\n }\n return null;\n }\n\n private async inferAriaLabel(name: string): Promise<string | null> {\n const loc = this.page.locator('[aria-label]');\n const count = await loc.count();\n for (let i = 0; i < count; i++) {\n const label = await loc.nth(i).getAttribute('aria-label');\n if (label?.toLowerCase().includes(name.toLowerCase())) return label;\n }\n return null;\n }\n\n private async inferPlaceholder(name: string): Promise<string | null> {\n const loc = this.page.locator('[placeholder]');\n const count = await loc.count();\n for (let i = 0; i < count; i++) {\n const ph = await loc.nth(i).getAttribute('placeholder');\n if (ph?.toLowerCase().includes(name.toLowerCase())) return ph;\n }\n return null;\n }\n\n private async findNearbyText(name: string): Promise<string | null> {\n try {\n const count = await this.page.getByText(new RegExp(name, 'i')).count();\n return count > 0 ? name : null;\n } catch { return null; }\n }\n}\n",
45
45
  "src/utils/locators/HealApplicator.ts": "import * as fs from 'fs';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\n\n/**\n * HealApplicator\n *\n * Applies approved heals back to the source TypeScript files so the next run\n * uses the fixed selector natively (zero re-healing overhead).\n *\n * Workflow:\n * 1. Read healed-locators.json — find entries where `approved === true`\n * 2. Search `searchRoots` (.ts files) for the originalSelector string literal\n * 3. Replace the first occurrence with healedSelector\n * 4. Optionally create a Git branch + commit + PR via the `gh` CLI\n *\n * Configuration (env vars):\n * HEAL_SEARCH_ROOTS Comma-separated dirs to search (default: src/)\n * HEAL_TARGET_REPO Repo root for git operations (default: cwd)\n * HEAL_PR_TITLE PR title prefix\n * GH_TOKEN / GITHUB_TOKEN Required by `gh pr create` in CI\n */\n\nexport interface HealRecord {\n healedSelector: string;\n originalSelector: string;\n strategy: string;\n provider?: string;\n intent: string;\n healCount: number;\n lastHealedAt: string;\n approved?: boolean;\n}\n\nexport interface AppliedHeal {\n key: string;\n originalSelector: string;\n healedSelector: string;\n file: string;\n line: number;\n}\n\nexport interface ApplyResult {\n applied: AppliedHeal[];\n skipped: string[];\n errors: Array<{ key: string; error: string }>;\n changedFiles: string[];\n prUrl?: string;\n}\n\nexport class HealApplicator {\n private readonly searchRoots: string[];\n private readonly targetRepo: string;\n\n constructor(options?: { searchRoots?: string[]; targetRepo?: string }) {\n const envRoots = process.env.HEAL_SEARCH_ROOTS?.split(',').map(r => r.trim()) ?? [];\n this.searchRoots = options?.searchRoots?.length\n ? options.searchRoots\n : envRoots.length ? envRoots : [path.resolve(process.cwd(), 'src')];\n this.targetRepo = options?.targetRepo ?? process.env.HEAL_TARGET_REPO ?? process.cwd();\n }\n\n apply(store: Record<string, HealRecord>): ApplyResult {\n const result: ApplyResult = { applied: [], skipped: [], errors: [], changedFiles: [] };\n const changed = new Set<string>();\n\n for (const [key, record] of Object.entries(store)) {\n if (!record.approved) continue;\n if (!record.originalSelector || !record.healedSelector || record.originalSelector === record.healedSelector) {\n result.skipped.push(key); continue;\n }\n try {\n const hit = this.replaceInFiles(record.originalSelector, record.healedSelector);\n if (hit) { result.applied.push({ key, ...hit }); changed.add(hit.file); }\n else { result.skipped.push(key); }\n } catch (err) {\n result.errors.push({ key, error: String(err) });\n }\n }\n\n result.changedFiles = [...changed];\n return result;\n }\n\n createPR(changedFiles: string[], summary: AppliedHeal[]): string {\n if (!changedFiles.length) return '';\n const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);\n const branch = `heal/locator-fixes-${timestamp}`;\n const prTitle = process.env.HEAL_PR_TITLE ?? 'fix: apply AI-healed locator fixes';\n const run = (cmd: string) => execSync(cmd, { cwd: this.targetRepo, stdio: 'pipe' }).toString().trim();\n\n run(`git checkout -b ${branch}`);\n for (const file of changedFiles) { run(`git add \"${path.relative(this.targetRepo, file)}\"`); }\n const body = [\n '## AI-Healed Locator Fixes', '',\n '| Key | Original → Healed |', '|-----|-------------------|',\n ...summary.map(h => `| \\`${h.key}\\` | \\`${h.originalSelector}\\` → \\`${h.healedSelector}\\` |`),\n '', '_Applied by Healix self-healing dashboard_',\n ].join('\\n');\n run(`git commit -m \"${prTitle}\" --message \"${body.replace(/\"/g, '\\\\\"')}\"`);\n run(`git push origin ${branch}`);\n return run(`gh pr create --title \"${prTitle}\" --body \"${body.replace(/\"/g, '\\\\\"')}\" --head ${branch} --base main 2>/dev/null || echo \"\"`);\n }\n\n private replaceInFiles(original: string, healed: string): (Omit<AppliedHeal, 'key'>) | null {\n for (const root of this.searchRoots) {\n if (!fs.existsSync(root)) continue;\n for (const file of this.collectTsFiles(root)) {\n const result = this.replaceInFile(file, original, healed);\n if (result) return { file, line: result.lineNumber, originalSelector: original, healedSelector: result.normHealed };\n }\n }\n return null;\n }\n\n private replaceInFile(file: string, original: string, healed: string): { lineNumber: number; normHealed: string } | null {\n let content: string;\n try { content = fs.readFileSync(file, 'utf8'); } catch { return null; }\n\n const escaped = original.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`(['\"\\`])${escaped}\\\\1`);\n const match = content.match(pattern);\n if (!match || match.index === undefined) return null;\n\n const normHealed = healed.replace(/\\[([^\\]='\"\\s]+)='([^']*)'\\]/g, '[$1=\"$2\"]');\n const quote = match[1];\n const newContent = content.replace(pattern, `${quote}${normHealed}${quote}`);\n const lineNumber = (content.slice(0, match.index).match(/\\n/g)?.length ?? 0) + 1;\n\n fs.writeFileSync(file, newContent, 'utf8');\n return { lineNumber, normHealed };\n }\n\n private collectTsFiles(dir: string): string[] {\n const results: string[] = [];\n let entries: fs.Dirent[];\n try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }\n for (const entry of entries) {\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n if (!['node_modules', 'dist', '.git'].includes(entry.name)) {\n results.push(...this.collectTsFiles(full));\n }\n } else if (entry.isFile() && entry.name.endsWith('.ts')) {\n results.push(full);\n }\n }\n return results;\n }\n}\n",
46
46
  "src/utils/locators/HealingDashboard.ts": "/**\n * HealingDashboard — real-time self-healing observability server\n *\n * Starts a lightweight HTTP server (default port 7890) that:\n * • Accepts healing events pushed by LocatorHealer during test runs\n * • Serves a live HTML dashboard at http://localhost:<port>\n * • Exposes JSON APIs at /api/events, /api/summary, /api/registry\n * • Auto-refreshes via Server-Sent Events (SSE)\n *\n * Usage:\n * BeforeAll: await HealingDashboard.getInstance().start();\n * AfterAll: await HealingDashboard.getInstance().stop();\n */\n\nimport * as http from 'http';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nconst HEAL_STORE_PATH = path.resolve(\n process.env.HEAL_STORE_PATH ?? 'storage-state/healed-locators.json',\n);\n\nfunction readHealStore(): Record<string, unknown> {\n try {\n if (!fs.existsSync(HEAL_STORE_PATH)) return {};\n return JSON.parse(fs.readFileSync(HEAL_STORE_PATH, 'utf8'));\n } catch { return {}; }\n}\n\nexport interface HealEvent {\n key: string;\n originalSelector: string;\n strategy: string;\n provider?: string;\n healedSelector?: string;\n intent: string;\n scenario?: string;\n timestamp: string;\n}\n\nconst cors = { 'Access-Control-Allow-Origin': '*' };\n\nfunction esc(str: string): string {\n return str\n .replace(/&/g, '&amp;').replace(/</g, '&lt;')\n .replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n}\n\nexport class HealingDashboard {\n private static _instance: HealingDashboard | null = null;\n\n static getInstance(): HealingDashboard {\n if (!HealingDashboard._instance) {\n HealingDashboard._instance = new HealingDashboard();\n }\n return HealingDashboard._instance;\n }\n\n static reset(): void { HealingDashboard._instance = null; }\n\n private readonly port: number;\n private server: http.Server | null = null;\n private events: HealEvent[] = [];\n private sseClients: http.ServerResponse[] = [];\n\n private constructor() {\n this.port = parseInt(process.env.HEALING_DASHBOARD_PORT ?? '7890', 10);\n }\n\n async start(): Promise<void> {\n if (this.server) return;\n this.server = http.createServer((req, res) => this.handleRequest(req, res));\n await new Promise<void>((resolve) => {\n this.server!.listen(this.port, '127.0.0.1', () => {\n console.log(` 🩺 HealingDashboard → http://localhost:${this.port}`);\n resolve();\n });\n this.server!.on('error', (err: NodeJS.ErrnoException) => {\n if (err.code === 'EADDRINUSE') {\n console.log(` 🩺 HealingDashboard already running on port ${this.port}`);\n this.server = null;\n } else {\n console.warn(` ⚠ HealingDashboard failed to start: ${err.message}`);\n this.server = null;\n }\n resolve();\n });\n });\n }\n\n async stop(): Promise<void> {\n for (const client of this.sseClients) { try { client.end(); } catch { /* ignore */ } }\n this.sseClients = [];\n await new Promise<void>((resolve) => {\n if (!this.server) { resolve(); return; }\n this.server.close(() => { this.server = null; resolve(); });\n });\n }\n\n record(event: HealEvent): void {\n this.events.push(event);\n this.pushSse(event);\n }\n\n private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {\n const url = req.url ?? '/';\n if (url === '/events' && req.headers.accept?.includes('text/event-stream')) {\n this.handleSse(res); return;\n }\n if (url === '/api/events') {\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify(this.events, null, 2)); return;\n }\n if (url === '/api/summary') {\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify(this.buildSummary(), null, 2)); return;\n }\n if (url === '/api/registry') {\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify(readHealStore(), null, 2)); return;\n }\n if (url === '/api/registry/clear' && req.method === 'POST') {\n try { fs.mkdirSync(path.dirname(HEAL_STORE_PATH), { recursive: true }); fs.writeFileSync(HEAL_STORE_PATH, '{}', 'utf8'); } catch { /* ignore */ }\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify({ ok: true })); return;\n }\n res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });\n res.end(this.buildHtml());\n }\n\n private handleSse(res: http.ServerResponse): void {\n res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', ...cors });\n res.write(':ok\\n\\n');\n this.sseClients.push(res);\n res.on('close', () => { this.sseClients = this.sseClients.filter(c => c !== res); });\n }\n\n private pushSse(event: HealEvent): void {\n const data = `data: ${JSON.stringify(event)}\\n\\n`;\n for (const client of this.sseClients) { try { client.write(data); } catch { /* client disconnected */ } }\n }\n\n private buildSummary() {\n const total = this.events.length;\n const byStrategy: Record<string, number> = {};\n const byKey: Record<string, number> = {};\n const byProvider: Record<string, number> = {};\n for (const e of this.events) {\n byStrategy[e.strategy] = (byStrategy[e.strategy] ?? 0) + 1;\n byKey[e.key] = (byKey[e.key] ?? 0) + 1;\n if (e.provider) byProvider[e.provider] = (byProvider[e.provider] ?? 0) + 1;\n }\n return { total, uniqueKeys: Object.keys(byKey).length, aiHeals: byStrategy['ai-vision'] ?? 0, byStrategy, byKey, byProvider };\n }\n\n private buildHtml(): string {\n const summary = this.buildSummary();\n const store = readHealStore();\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"UTF-8\"><title>Healix — Self-Healing Dashboard</title>\n<style>\n*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}\n:root{--bg:#0d1117;--surface:#161b22;--border:#30363d;--accent:#58a6ff;--green:#3fb950;--yellow:#d29922;--red:#f85149;--purple:#bc8cff;--text:#e6edf3;--muted:#8b949e;--radius:8px}\nbody{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.5;padding:24px}\nheader{display:flex;align-items:center;gap:12px;margin-bottom:28px}header h1{font-size:20px;font-weight:600}\n.badge{background:var(--green);color:#000;font-size:11px;font-weight:700;padding:2px 8px;border-radius:20px}\n.provider-pill{margin-left:auto;background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:4px 12px;font-size:12px;color:var(--accent)}\n.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:16px;margin-bottom:28px}\n.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px}\n.card .label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px}\n.card .value{font-size:28px;font-weight:700;margin-top:4px}\n.card.green .value{color:var(--green)}.card.blue .value{color:var(--accent)}.card.purple .value{color:var(--purple)}.card.yellow .value{color:var(--yellow)}\n.breakdown{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;margin-bottom:28px}\n.breakdown h2{font-size:13px;font-weight:600;margin-bottom:12px;color:var(--muted);text-transform:uppercase}\n.bars{display:flex;flex-direction:column;gap:8px}.bar-row{display:flex;align-items:center;gap:10px}\n.bar-label{width:100px;font-size:12px;color:var(--muted);text-align:right}\n.bar-track{flex:1;background:var(--border);border-radius:4px;height:10px;overflow:hidden}\n.bar-fill{height:100%;border-radius:4px;background:var(--accent)}.bar-fill.ai-vision{background:var(--purple)}.bar-fill.ax-tree{background:var(--yellow)}.bar-fill.role{background:var(--green)}\n.bar-count{width:28px;font-size:12px;text-align:right}\n.section-title{font-size:13px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;display:flex;align-items:center;gap:8px}\n.section-title .count{background:var(--accent);color:#000;font-size:11px;font-weight:700;padding:1px 7px;border-radius:20px}\ntable{width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}\nth{background:#21262d;text-align:left;padding:10px 14px;font-size:11px;color:var(--muted);text-transform:uppercase;border-bottom:1px solid var(--border)}\ntd{padding:10px 14px;border-bottom:1px solid var(--border);vertical-align:top;font-size:13px}\ntr:last-child td{border-bottom:none}tr:hover td{background:rgba(88,166,255,.04)}\n.key-chip{background:rgba(88,166,255,.12);color:var(--accent);border-radius:4px;padding:2px 7px;font-size:12px;font-family:monospace}\n.selector{font-family:monospace;font-size:12px;color:var(--muted);word-break:break-all}\n.new-selector{font-family:monospace;font-size:12px;color:var(--green);word-break:break-all}\n.badge-strategy{display:inline-block;border-radius:4px;padding:2px 8px;font-size:11px;font-weight:600}\n.badge-strategy.ai-vision{background:rgba(188,140,255,.15);color:var(--purple)}\n.badge-strategy.ax-tree{background:rgba(210,153,34,.15);color:var(--yellow)}\n.badge-strategy.role,.badge-strategy.label{background:rgba(63,185,80,.15);color:var(--green)}\n.badge-strategy.text{background:rgba(88,166,255,.1);color:var(--accent)}\n.badge-strategy.cached{background:rgba(139,148,158,.15);color:var(--muted)}\n.ts{font-size:11px;color:var(--muted);white-space:nowrap}.scenario{font-size:11px;color:var(--muted);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.empty{text-align:center;padding:48px;color:var(--muted);font-size:13px}\n.pulse{display:inline-block;width:8px;height:8px;background:var(--green);border-radius:50%;margin-right:6px;animation:pulse 2s infinite}\n@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}\nfooter{margin-top:32px;text-align:center;font-size:11px;color:var(--muted)}\n</style>\n</head>\n<body>\n<header>\n <span>🩺</span><h1>Healix — Self-Healing Dashboard</h1><span class=\"badge\">LIVE</span>\n <span class=\"provider-pill\">AI · ${process.env.AI_PROVIDER ?? 'openai'} / ${process.env.AI_MODEL ?? 'gpt-4o'}</span>\n</header>\n<div class=\"cards\" id=\"cards\">${this.renderCards(summary)}</div>\n<div class=\"breakdown\"><h2>Strategy breakdown</h2><div class=\"bars\" id=\"bars\">${this.renderBars(summary)}</div></div>\n<div class=\"section-title\">Healing events <span class=\"count\" id=\"total-count\">${summary.total}</span>\n <span style=\"margin-left:auto;font-size:12px;color:var(--muted)\"><span class=\"pulse\"></span>live</span>\n</div>\n<table><thead><tr><th>Time</th><th>Key</th><th>Strategy</th><th>Original selector</th><th>Healed selector</th><th>Intent</th><th>Scenario</th></tr></thead>\n<tbody id=\"events-body\">${this.renderRows(this.events)}</tbody></table>\n<div class=\"section-title\" style=\"margin-top:32px\">Stored heals <span class=\"count\" id=\"registry-count\">${Object.keys(store).length}</span>\n <span style=\"margin-left:auto;font-size:12px;color:var(--muted)\">\n <code style=\"color:var(--accent);font-size:11px\">storage-state/healed-locators.json</code>\n &nbsp;·&nbsp;<button onclick=\"clearRegistry()\" style=\"background:rgba(248,81,73,.1);color:var(--red);border:1px solid var(--red);border-radius:4px;padding:2px 10px;font-size:11px;cursor:pointer\">Clear all</button>\n </span>\n</div>\n<table><thead><tr><th>Key</th><th>Strategy</th><th>Original selector</th><th>Healed selector</th><th>Heals</th><th>Last healed</th></tr></thead>\n<tbody id=\"registry-body\">${this.renderRegistry(store)}</tbody></table>\n<footer>QA Framework Self-Healing Dashboard · port ${this.port}</footer>\n<script>\nconst es=new EventSource('/events');\nes.onmessage=e=>{const ev=JSON.parse(e.data);prependRow(ev);refreshSummary();refreshRegistry();};\nfunction esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;').replace(/'/g,'&#39;');}\nfunction stratCls(s){return['ai-vision','ax-tree','role','label','text','cached'].includes(s)?s:'';}\nfunction prependRow(ev){const tb=document.getElementById('events-body');const nd=tb.querySelector('.no-data');if(nd)nd.remove();const ts=new Date(ev.timestamp).toLocaleTimeString();const tr=document.createElement('tr');tr.innerHTML=\\`<td class=\"ts\">\\${esc(ts)}</td><td><span class=\"key-chip\">\\${esc(ev.key)}</span></td><td><span class=\"badge-strategy \\${stratCls(ev.strategy)}\">\\${esc(ev.strategy)}\\${ev.provider?' · '+esc(ev.provider):''}</span></td><td class=\"selector\">\\${esc(ev.originalSelector)}</td><td class=\"new-selector\">\\${esc(ev.healedSelector??'—')}</td><td>\\${esc(ev.intent)}</td><td class=\"scenario\">\\${esc(ev.scenario??'—')}</td>\\`;tb.insertBefore(tr,tb.firstChild);}\nasync function refreshSummary(){try{const d=await(await fetch('/api/summary')).json();document.getElementById('total-count').textContent=d.total;document.getElementById('cards').innerHTML=renderCards(d);document.getElementById('bars').innerHTML=renderBars(d);}catch{}}\nasync function refreshRegistry(){try{const d=await(await fetch('/api/registry')).json();const keys=Object.keys(d);document.getElementById('registry-count').textContent=keys.length;document.getElementById('registry-body').innerHTML=keys.length===0?'<tr><td colspan=\"6\" style=\"color:var(--muted);text-align:center;padding:16px\">No heals stored yet</td></tr>':keys.map(k=>{const r=d[k];return\\`<tr><td><code style=\"color:var(--accent)\">\\${esc(k)}</code></td><td><span class=\"badge-strategy \\${stratCls(r.strategy??'')}\">\\${esc(r.strategy??'?')}</span></td><td><code style=\"color:var(--muted);font-size:11px\">\\${esc(r.originalSelector??'')}</code></td><td><code style=\"color:var(--green);font-size:12px\">\\${esc(r.healedSelector??'')}</code></td><td style=\"text-align:center\">\\${r.healCount??0}</td><td style=\"font-size:11px;color:var(--muted)\">\\${r.lastHealedAt?new Date(r.lastHealedAt).toLocaleTimeString():''}</td></tr>\\`;}).join('');}catch{}}\nasync function clearRegistry(){if(!confirm('Clear all stored heals?'))return;await fetch('/api/registry/clear',{method:'POST'});await refreshRegistry();}\nfunction renderCards(s){return\\`<div class=\"card green\"><div class=\"label\">Total heals</div><div class=\"value\">\\${s.total}</div></div><div class=\"card blue\"><div class=\"label\">Unique keys</div><div class=\"value\">\\${s.uniqueKeys}</div></div><div class=\"card purple\"><div class=\"label\">AI Vision heals</div><div class=\"value\">\\${s.aiHeals}</div></div><div class=\"card yellow\"><div class=\"label\">Strategies</div><div class=\"value\">\\${Object.keys(s.byStrategy).length}</div></div>\\`;}\nfunction renderBars(s){const t=s.total||1;const e=Object.entries(s.byStrategy);return e.length===0?'<div style=\"color:var(--muted);font-size:12px\">No events yet</div>':e.map(([k,v])=>{const p=Math.round(v/t*100);return\\`<div class=\"bar-row\"><div class=\"bar-label\">\\${esc(k)}</div><div class=\"bar-track\"><div class=\"bar-fill \\${stratCls(k)}\" style=\"width:\\${p}%\"></div></div><div class=\"bar-count\">\\${v}</div></div>\\`;}).join('');}\nrefreshRegistry();setInterval(refreshRegistry,5000);\n</script>\n</body></html>`;\n }\n\n private renderCards(s: ReturnType<HealingDashboard['buildSummary']>): string {\n return `<div class=\"card green\"><div class=\"label\">Total heals</div><div class=\"value\">${s.total}</div></div>\n <div class=\"card blue\"><div class=\"label\">Unique keys</div><div class=\"value\">${s.uniqueKeys}</div></div>\n <div class=\"card purple\"><div class=\"label\">AI Vision heals</div><div class=\"value\">${s.aiHeals}</div></div>\n <div class=\"card yellow\"><div class=\"label\">Strategies used</div><div class=\"value\">${Object.keys(s.byStrategy).length}</div></div>`;\n }\n\n private renderBars(s: ReturnType<HealingDashboard['buildSummary']>): string {\n const total = s.total || 1;\n const entries = Object.entries(s.byStrategy);\n if (!entries.length) return '<div style=\"color:var(--muted);font-size:12px\">No events yet</div>';\n return entries.map(([k, v]) => {\n const pct = Math.round((v / total) * 100);\n const cls = ['ai-vision','ax-tree','role'].includes(k) ? k : '';\n return `<div class=\"bar-row\"><div class=\"bar-label\">${esc(k)}</div><div class=\"bar-track\"><div class=\"bar-fill ${cls}\" style=\"width:${pct}%\"></div></div><div class=\"bar-count\">${v}</div></div>`;\n }).join('');\n }\n\n private renderRows(events: HealEvent[]): string {\n if (!events.length) return `<tr class=\"no-data\"><td colspan=\"7\" class=\"empty\">No healing events yet.</td></tr>`;\n return [...events].reverse().map(e => {\n const ts = new Date(e.timestamp).toLocaleTimeString();\n const cls = ['ai-vision','ax-tree','role','label','text','cached'].includes(e.strategy) ? e.strategy : '';\n return `<tr><td class=\"ts\">${esc(ts)}</td><td><span class=\"key-chip\">${esc(e.key)}</span></td><td><span class=\"badge-strategy ${cls}\">${esc(e.strategy)}${e.provider ? ` · ${esc(e.provider)}` : ''}</span></td><td class=\"selector\">${esc(e.originalSelector)}</td><td class=\"new-selector\">${esc(e.healedSelector ?? '—')}</td><td>${esc(e.intent)}</td><td class=\"scenario\" title=\"${esc(e.scenario ?? '')}\">${esc(e.scenario ?? '—')}</td></tr>`;\n }).join('');\n }\n\n private renderRegistry(store: Record<string, unknown>): string {\n const keys = Object.keys(store);\n if (!keys.length) return `<tr><td colspan=\"6\" style=\"color:var(--muted);text-align:center;padding:16px\">No heals stored yet</td></tr>`;\n return keys.map(k => {\n const r = store[k] as Record<string, unknown>;\n const strat = String(r['strategy'] ?? 'unknown');\n const cls = ['ai-vision','ax-tree','role','label','text','cached'].includes(strat) ? strat : '';\n return `<tr><td><code style=\"color:var(--accent)\">${esc(k)}</code></td><td><span class=\"badge-strategy ${cls}\">${esc(strat)}</span></td><td><code style=\"color:var(--muted);font-size:11px\">${esc(String(r['originalSelector'] ?? ''))}</code></td><td><code style=\"color:var(--green);font-size:12px\">${esc(String(r['healedSelector'] ?? ''))}</code></td><td style=\"text-align:center\">${String(r['healCount'] ?? 0)}</td><td style=\"font-size:11px;color:var(--muted)\">${r['lastHealedAt'] ? new Date(String(r['lastHealedAt'])).toLocaleString() : ''}</td></tr>`;\n }).join('');\n }\n}\n",
47
- "src/utils/locators/LocatorHealer.ts": "import { Page, Locator } from '@playwright/test';\nimport { LocatorRepository } from './LocatorRepository';\nimport { HealingDashboard } from './HealingDashboard';\n\nexport interface HealerLogger {\n info(msg: string): void;\n warn(msg: string): void;\n error(msg: string): void;\n}\n\n/**\n * LocatorHealer — Layer 1 Self-Healing\n *\n * Healing chain per action:\n * 1. Original selector (from LocatorRepository.getBestSelector)\n * 2. Role-based: getByRole inferred from intent\n * 3. Label-based: getByLabel / getByPlaceholder\n * 4. Text-based: getByText\n * 5. AI Vision: provider-agnostic vision API (openai | claude | grok | ollama | local)\n * 6. CDPSession AX tree walk (last resort)\n *\n * Provider is controlled by AI_PROVIDER in .env:\n * openai — OpenAI GPT-4o or gpt-4-vision-preview\n * claude — Anthropic Claude (claude-opus-4-6 or newer)\n * grok — xAI Grok vision API\n * ollama — Local Ollama with a vision model (e.g. llava, moondream2)\n * local — LM Studio or any OpenAI-compatible local server\n *\n * Healed selectors are persisted in LocatorRepository — zero overhead on repeat runs.\n */\nexport class LocatorHealer {\n private readonly TIMEOUT = 8_000;\n currentScenario?: string;\n\n constructor(\n private readonly page: Page,\n private readonly logger: HealerLogger,\n private readonly repo: LocatorRepository,\n ) {}\n\n async resolve(key: string, primarySelector: string, intent: string): Promise<Locator> {\n const cached = this.repo.getHealed(key);\n if (cached) {\n const loc = this.page.locator(cached);\n if (await this.isVisible(loc)) return loc;\n this.logger.warn(`[LocatorHealer] Cached heal stale for \"${key}\", re-healing`);\n }\n\n {\n const loc = this.page.locator(primarySelector);\n if (await this.isVisible(loc)) return loc;\n this.logger.warn(`[LocatorHealer] Primary selector failed for \"${key}\": ${primarySelector}`);\n }\n\n const aiLocator = await this.healByAiVision(key, intent, primarySelector);\n if (aiLocator) return aiLocator;\n\n const axLocator = await this.healByAxTree(key, intent, primarySelector);\n if (axLocator) return axLocator;\n\n throw new Error(\n `[LocatorHealer] All healing strategies exhausted for \"${key}\" (intent: \"${intent}\").\\n` +\n `Check HealingDashboard at http://localhost:${process.env.HEALING_DASHBOARD_PORT ?? 7890}`,\n );\n }\n\n async clickWithHealing(key: string, selector: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n await loc.click();\n this.logger.info(`[LocatorHealer] click \"${key}\"`);\n }\n\n async fillWithHealing(key: string, selector: string, value: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n\n const inputType = await loc.evaluate((el) => {\n const tag = (el as HTMLElement).tagName.toLowerCase();\n if (tag !== 'input' && tag !== 'textarea') return 'non-input';\n return (el as HTMLInputElement).type ?? 'text';\n });\n\n const nonFillableTypes = ['submit', 'button', 'reset', 'image', 'checkbox', 'radio', 'file'];\n if (nonFillableTypes.includes(inputType) || inputType === 'non-input') {\n this.logger.warn(`[LocatorHealer] Healed locator for \"${key}\" resolved to non-fillable element — re-healing`);\n this.repo.evict(key);\n const keyLabel = key.replace(/[_-]?(textbox|input|field|box)$/i, '').replace(/_/g, ' ').trim();\n const roleLoc = await (async () => {\n try {\n const l = this.page.getByRole('textbox', { name: keyLabel, exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n try {\n const l = this.page.getByPlaceholder(keyLabel, { exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n try {\n const l = this.page.getByLabel(keyLabel, { exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n return null;\n })();\n if (!roleLoc) throw new Error(`[LocatorHealer] Cannot find fillable element for \"${key}\" (intent: \"${intent}\")`);\n await roleLoc.fill(value);\n this.persist(key, roleLoc, 'role');\n return;\n }\n\n await loc.fill(value);\n this.logger.info(`[LocatorHealer] fill \"${key}\" = \"${value}\"`);\n }\n\n async assertVisibleWithHealing(key: string, selector: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n this.logger.info(`[LocatorHealer] assertVisible \"${key}\" ✓`);\n }\n\n async assertHiddenWithHealing(key: string, selector: string, _intent: string): Promise<void> {\n const loc = this.page.locator(selector);\n await loc.waitFor({ state: 'hidden', timeout: this.TIMEOUT });\n this.logger.info(`[LocatorHealer] assertHidden \"${key}\" ✓`);\n }\n\n async getLocator(key: string, selector: string, intent: string): Promise<Locator> {\n return this.resolve(key, selector, intent);\n }\n\n /**\n * AI Vision healing — supports openai | claude | grok | ollama | local\n *\n * Configured via .env:\n * AI_PROVIDER = openai | claude | grok | ollama | local\n * AI_API_KEY = your API key (not needed for ollama/local)\n * AI_MODEL = model name override\n * LOCAL_LLM_ENDPOINT = base URL for ollama or LM Studio\n */\n private async healByAiVision(key: string, intent: string, originalSelector = ''): Promise<Locator | null> {\n const provider = (process.env.AI_PROVIDER ?? 'openai').toLowerCase();\n const apiKey = process.env.AI_API_KEY || process.env.ANTHROPIC_API_KEY;\n const needsKey = !['ollama', 'local'].includes(provider);\n if (needsKey && !apiKey) {\n this.logger.warn(`[LocatorHealer] AI Vision skipped for \"${key}\" — AI_API_KEY not set`);\n return null;\n }\n\n const prompt =\n `You are a Playwright test automation expert. Look at this screenshot.\\n` +\n `Find the element that matches: \"${intent}\".\\n` +\n `Return ONLY a valid CSS selector string. No explanation. No quotes. No code blocks.`;\n\n try {\n const screenshotBuffer = await this.page.screenshot({ fullPage: false });\n const base64Screenshot = screenshotBuffer.toString('base64');\n let selector: string | undefined;\n\n if (provider === 'openai') {\n const res = await fetch('https://api.openai.com/v1/chat/completions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'gpt-4o',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] OpenAI error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else if (provider === 'claude' || provider === 'anthropic') {\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey!, 'anthropic-version': '2023-06-01' },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'claude-opus-4-6',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image', source: { type: 'base64', media_type: 'image/png', data: base64Screenshot } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Claude error ${res.status}`); return null; }\n const data = await res.json() as { content: Array<{ type: string; text: string }> };\n selector = data.content.find(b => b.type === 'text')?.text?.trim();\n }\n\n else if (provider === 'grok') {\n const res = await fetch('https://api.x.ai/v1/chat/completions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'grok-2-vision-latest',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Grok error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else if (provider === 'ollama') {\n const baseUrl = (process.env.OLLAMA_ENDPOINT || process.env.LOCAL_LLM_ENDPOINT || 'http://localhost:11434').replace(/\\/$/, '');\n const model = process.env.OLLAMA_MODEL || process.env.AI_MODEL || 'llava';\n const res = await fetch(`${baseUrl}/api/chat`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content: prompt, images: [base64Screenshot] }] }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Ollama error ${res.status}`); return null; }\n const data = await res.json() as { message?: { content: string } };\n selector = data.message?.content?.trim();\n }\n\n else if (provider === 'local') {\n const baseUrl = (process.env.LM_STUDIO_ENDPOINT || process.env.LOCAL_LLM_ENDPOINT || 'http://localhost:1234').replace(/\\/$/, '');\n const model = process.env.LM_STUDIO_MODEL || process.env.AI_MODEL || 'local-model';\n const res = await fetch(`${baseUrl}/v1/chat/completions`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ model, max_tokens: 256, messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}] }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] LM Studio error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else {\n this.logger.warn(`[LocatorHealer] Unknown AI_PROVIDER \"${provider}\"`);\n return null;\n }\n\n if (!selector) return null;\n selector = selector.replace(/\\[([^\\]='\"\\s]+)='([^']*)'\\]/g, '[$1=\"$2\"]');\n this.logger.warn(`[LocatorHealer] AI Vision (${provider}) suggested: ${selector}`);\n const loc = this.page.locator(selector);\n if (await this.isVisible(loc)) {\n this.persist(key, loc, 'ai-vision', intent, originalSelector, selector);\n return loc;\n }\n } catch (err) {\n this.logger.warn(`[LocatorHealer] AI Vision failed for \"${key}\": ${String(err)}`);\n }\n return null;\n }\n\n private async healByAxTree(key: string, intent: string, originalSelector = ''): Promise<Locator | null> {\n const INTERACTIVE_ROLES = new Set([\n 'textbox', 'searchbox', 'spinbutton', 'combobox', 'listbox',\n 'button', 'link', 'checkbox', 'radio', 'menuitem', 'tab', 'switch',\n 'slider', 'option', 'treeitem', 'gridcell',\n ]);\n\n try {\n const client = await (this.page.context() as unknown as { newCDPSession(page: Page): Promise<{ send(cmd: string): Promise<{ nodes: unknown[] }>; detach(): Promise<void> }> }).newCDPSession(this.page);\n const { nodes } = await client.send('Accessibility.getFullAXTree');\n await client.detach();\n\n const intentWords = intent.toLowerCase().split(/\\s+/).filter(w => w.length > 2);\n let best: { node: Record<string, unknown>; score: number } | null = null;\n\n for (const n of (nodes as Record<string, Record<string, string>>[]) ) {\n const role = (n['role']?.['value'] ?? '').toLowerCase();\n const name = (n['name']?.['value'] ?? '').toLowerCase();\n if (!INTERACTIVE_ROLES.has(role) || !name) continue;\n const hasWordMatch = intentWords.some(word => new RegExp(`\\\\b${word}\\\\b`).test(name));\n if (!hasWordMatch) continue;\n const score = intentWords.filter(word => new RegExp(`\\\\b${word}\\\\b`).test(name)).length;\n if (!best || score > best.score) best = { node: n as unknown as Record<string, unknown>, score };\n }\n\n if (best?.node) {\n const role = (best.node['role'] as Record<string, string>)?.['value'];\n const name = (best.node['name'] as Record<string, string>)?.['value'];\n if (role && name) {\n const axLoc = this.page.getByRole(role as Parameters<Page['getByRole']>[0], { name, exact: false });\n if (await this.isVisible(axLoc)) {\n this.persist(key, axLoc, 'ax-tree', intent, originalSelector, `[role=\"${role}\"][name=\"${name}\"]`);\n return axLoc;\n }\n }\n }\n } catch (err) {\n this.logger.warn(`[LocatorHealer] AX tree healing failed: ${String(err)}`);\n }\n return null;\n }\n\n private async isVisible(loc: Locator): Promise<boolean> {\n try {\n return await loc.isVisible({ timeout: 2_000 });\n } catch {\n return false;\n }\n }\n\n private persist(key: string, _loc: Locator, strategy: string, intent = '', originalSelector = '', healedSelector?: string): void {\n const provider = strategy === 'ai-vision' ? (process.env.AI_PROVIDER ?? 'openai') : undefined;\n if (healedSelector) {\n try {\n if (!this.repo.getHealed(key) && originalSelector) {\n this.repo.register(key, originalSelector, intent);\n }\n this.repo.setHealed(key, healedSelector, strategy, provider);\n this.logger.info(`[LocatorHealer] 💾 Healed selector saved for \"${key}\" → ${healedSelector}`);\n } catch (err) {\n this.logger.warn(`[LocatorHealer] Could not persist heal for \"${key}\": ${String(err)}`);\n }\n }\n try {\n HealingDashboard.getInstance().record({\n key,\n originalSelector,\n strategy,\n provider,\n healedSelector,\n intent,\n scenario: this.currentScenario,\n timestamp: new Date().toISOString(),\n });\n } catch { /* dashboard may not be running */ }\n }\n}\n",
47
+ "src/utils/locators/LocatorHealer.ts": "import { Page, Locator } from '@playwright/test';\nimport { LocatorRepository } from './LocatorRepository';\nimport { HealingDashboard } from './HealingDashboard';\n\nexport interface HealerLogger {\n info(msg: string): void;\n warn(msg: string): void;\n error(msg: string): void;\n}\n\n/**\n * LocatorHealer — Layer 1 Self-Healing\n *\n * Healing chain per action:\n * 1. Original selector (from LocatorRepository.getBestSelector)\n * 2. Role-based: getByRole inferred from intent\n * 3. Label-based: getByLabel / getByPlaceholder\n * 4. Text-based: getByText\n * 5. AI Vision: provider-agnostic vision API (openai | claude | grok | ollama | local)\n * 6. CDPSession AX tree walk (last resort)\n *\n * Provider is controlled by AI_PROVIDER in .env:\n * openai — OpenAI GPT-4o or gpt-4-vision-preview\n * claude — Anthropic Claude (claude-opus-4-6 or newer)\n * grok — xAI Grok vision API\n * ollama — Local Ollama with a vision model (e.g. llava, moondream2)\n * local — LM Studio or any OpenAI-compatible local server\n *\n * Healed selectors are persisted in LocatorRepository — zero overhead on repeat runs.\n */\nexport class LocatorHealer {\n private readonly TIMEOUT = 8_000;\n currentScenario?: string;\n\n constructor(\n private readonly page: Page,\n private readonly logger: HealerLogger,\n private readonly repo: LocatorRepository,\n ) {}\n\n async resolve(key: string, primarySelector: string, intent: string): Promise<Locator> {\n const cached = this.repo.getHealed(key);\n if (cached) {\n const loc = this.page.locator(cached);\n if (await this.isVisible(loc)) return loc;\n this.logger.warn(`[LocatorHealer] Cached heal stale for \"${key}\", re-healing`);\n }\n\n {\n const loc = this.page.locator(primarySelector);\n if (await this.isVisible(loc)) return loc;\n this.logger.warn(`[LocatorHealer] Primary selector failed for \"${key}\": ${primarySelector}`);\n }\n\n // 1.5. data-test fuzzy fallback — catches single-char typos/off-by-one errors in [data-test=\"X\"] selectors\n // Zero API calls; runs before AI Vision to avoid unnecessary LLM round-trips.\n const dataTestHeal = await this._healByDataTestFuzzy(primarySelector);\n if (dataTestHeal) {\n this.persist(key, dataTestHeal.loc, 'data-test-fuzzy', intent, primarySelector, dataTestHeal.selector);\n return dataTestHeal.loc;\n }\n\n const aiLocator = await this.healByAiVision(key, intent, primarySelector);\n if (aiLocator) return aiLocator;\n\n const axLocator = await this.healByAxTree(key, intent, primarySelector);\n if (axLocator) return axLocator;\n\n throw new Error(\n `[LocatorHealer] All healing strategies exhausted for \"${key}\" (intent: \"${intent}\").\\n` +\n `Check HealingDashboard at http://localhost:${process.env.HEALING_DASHBOARD_PORT ?? 7890}`,\n );\n }\n\n async clickWithHealing(key: string, selector: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n await loc.click();\n this.logger.info(`[LocatorHealer] click \"${key}\"`);\n }\n\n async fillWithHealing(key: string, selector: string, value: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n\n const inputType = await loc.evaluate((el) => {\n const tag = (el as HTMLElement).tagName.toLowerCase();\n if (tag !== 'input' && tag !== 'textarea') return 'non-input';\n return (el as HTMLInputElement).type ?? 'text';\n });\n\n const nonFillableTypes = ['submit', 'button', 'reset', 'image', 'checkbox', 'radio', 'file'];\n if (nonFillableTypes.includes(inputType) || inputType === 'non-input') {\n this.logger.warn(`[LocatorHealer] Healed locator for \"${key}\" resolved to non-fillable element — re-healing`);\n this.repo.evict(key);\n const keyLabel = key.replace(/[_-]?(textbox|input|field|box)$/i, '').replace(/_/g, ' ').trim();\n const roleLoc = await (async () => {\n try {\n const l = this.page.getByRole('textbox', { name: keyLabel, exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n try {\n const l = this.page.getByPlaceholder(keyLabel, { exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n try {\n const l = this.page.getByLabel(keyLabel, { exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n return null;\n })();\n if (!roleLoc) throw new Error(`[LocatorHealer] Cannot find fillable element for \"${key}\" (intent: \"${intent}\")`);\n await roleLoc.fill(value);\n this.persist(key, roleLoc, 'role');\n return;\n }\n\n await loc.fill(value);\n this.logger.info(`[LocatorHealer] fill \"${key}\" = \"${value}\"`);\n }\n\n async assertVisibleWithHealing(key: string, selector: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n this.logger.info(`[LocatorHealer] assertVisible \"${key}\" ✓`);\n }\n\n async assertHiddenWithHealing(key: string, selector: string, _intent: string): Promise<void> {\n const loc = this.page.locator(selector);\n await loc.waitFor({ state: 'hidden', timeout: this.TIMEOUT });\n this.logger.info(`[LocatorHealer] assertHidden \"${key}\" ✓`);\n }\n\n async getLocator(key: string, selector: string, intent: string): Promise<Locator> {\n return this.resolve(key, selector, intent);\n }\n\n /**\n * AI Vision healing — supports openai | claude | grok | ollama | local\n *\n * Configured via .env:\n * AI_PROVIDER = openai | claude | grok | ollama | local\n * AI_API_KEY = your API key (not needed for ollama/local)\n * AI_MODEL = model name override\n * LOCAL_LLM_ENDPOINT = base URL for ollama or LM Studio\n */\n private async healByAiVision(key: string, intent: string, originalSelector = ''): Promise<Locator | null> {\n const provider = (process.env.AI_PROVIDER ?? 'openai').toLowerCase();\n const apiKey = process.env.AI_API_KEY || process.env.ANTHROPIC_API_KEY;\n const needsKey = !['ollama', 'local'].includes(provider);\n if (needsKey && !apiKey) {\n this.logger.warn(`[LocatorHealer] AI Vision skipped for \"${key}\" — AI_API_KEY not set`);\n return null;\n }\n\n const prompt =\n `You are a Playwright test automation expert. Look at this screenshot.\\n` +\n `Find the element that matches: \"${intent}\".\\n` +\n `Return ONLY a valid CSS selector string. No explanation. No quotes. No code blocks.`;\n\n try {\n const screenshotBuffer = await this.page.screenshot({ fullPage: false });\n const base64Screenshot = screenshotBuffer.toString('base64');\n let selector: string | undefined;\n\n if (provider === 'openai') {\n const res = await fetch('https://api.openai.com/v1/chat/completions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'gpt-4o',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] OpenAI error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else if (provider === 'claude' || provider === 'anthropic') {\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey!, 'anthropic-version': '2023-06-01' },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'claude-opus-4-6',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image', source: { type: 'base64', media_type: 'image/png', data: base64Screenshot } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Claude error ${res.status}`); return null; }\n const data = await res.json() as { content: Array<{ type: string; text: string }> };\n selector = data.content.find(b => b.type === 'text')?.text?.trim();\n }\n\n else if (provider === 'grok') {\n const res = await fetch('https://api.x.ai/v1/chat/completions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'grok-2-vision-latest',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Grok error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else if (provider === 'ollama') {\n const baseUrl = (process.env.OLLAMA_ENDPOINT || process.env.LOCAL_LLM_ENDPOINT || 'http://localhost:11434').replace(/\\/$/, '');\n const model = process.env.OLLAMA_MODEL || process.env.AI_MODEL || 'llava';\n const res = await fetch(`${baseUrl}/api/chat`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content: prompt, images: [base64Screenshot] }] }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Ollama error ${res.status}`); return null; }\n const data = await res.json() as { message?: { content: string } };\n selector = data.message?.content?.trim();\n }\n\n else if (provider === 'local') {\n const baseUrl = (process.env.LM_STUDIO_ENDPOINT || process.env.LOCAL_LLM_ENDPOINT || 'http://localhost:1234').replace(/\\/$/, '');\n const model = process.env.LM_STUDIO_MODEL || process.env.AI_MODEL || 'local-model';\n const res = await fetch(`${baseUrl}/v1/chat/completions`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ model, max_tokens: 256, messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}] }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] LM Studio error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else {\n this.logger.warn(`[LocatorHealer] Unknown AI_PROVIDER \"${provider}\"`);\n return null;\n }\n\n if (!selector) return null;\n selector = selector.replace(/\\[([^\\]='\"\\s]+)='([^']*)'\\]/g, '[$1=\"$2\"]');\n this.logger.warn(`[LocatorHealer] AI Vision (${provider}) suggested: ${selector}`);\n const loc = this.page.locator(selector);\n if (await this.isVisible(loc)) {\n this.persist(key, loc, 'ai-vision', intent, originalSelector, selector);\n return loc;\n }\n } catch (err) {\n this.logger.warn(`[LocatorHealer] AI Vision failed for \"${key}\": ${String(err)}`);\n }\n return null;\n }\n\n private async healByAxTree(key: string, intent: string, originalSelector = ''): Promise<Locator | null> {\n const INTERACTIVE_ROLES = new Set([\n 'textbox', 'searchbox', 'spinbutton', 'combobox', 'listbox',\n 'button', 'link', 'checkbox', 'radio', 'menuitem', 'tab', 'switch',\n 'slider', 'option', 'treeitem', 'gridcell',\n ]);\n\n try {\n const client = await (this.page.context() as unknown as { newCDPSession(page: Page): Promise<{ send(cmd: string): Promise<{ nodes: unknown[] }>; detach(): Promise<void> }> }).newCDPSession(this.page);\n const { nodes } = await client.send('Accessibility.getFullAXTree');\n await client.detach();\n\n const intentWords = intent.toLowerCase().split(/\\s+/).filter(w => w.length > 2);\n let best: { node: Record<string, unknown>; score: number } | null = null;\n\n for (const n of (nodes as Record<string, Record<string, string>>[]) ) {\n const role = (n['role']?.['value'] ?? '').toLowerCase();\n const name = (n['name']?.['value'] ?? '').toLowerCase();\n if (!INTERACTIVE_ROLES.has(role) || !name) continue;\n const hasWordMatch = intentWords.some(word => new RegExp(`\\\\b${word}\\\\b`).test(name));\n if (!hasWordMatch) continue;\n const score = intentWords.filter(word => new RegExp(`\\\\b${word}\\\\b`).test(name)).length;\n if (!best || score > best.score) best = { node: n as unknown as Record<string, unknown>, score };\n }\n\n if (best?.node) {\n const role = (best.node['role'] as Record<string, string>)?.['value'];\n const name = (best.node['name'] as Record<string, string>)?.['value'];\n if (role && name) {\n const axLoc = this.page.getByRole(role as Parameters<Page['getByRole']>[0], { name, exact: false });\n if (await this.isVisible(axLoc)) {\n this.persist(key, axLoc, 'ax-tree', intent, originalSelector, `[role=\"${role}\"][name=\"${name}\"]`);\n return axLoc;\n }\n }\n }\n } catch (err) {\n this.logger.warn(`[LocatorHealer] AX tree healing failed: ${String(err)}`);\n }\n return null;\n }\n\n /**\n * Fuzzy fallback for [data-test=\"X\"] selectors.\n *\n * If the primary selector is a [data-test=\"X\"] attribute selector, this\n * method collects all [data-test] values currently in the DOM and returns\n * the closest match within Levenshtein distance 1 (single-char typo /\n * off-by-one). Zero API calls — runs synchronously via page.evaluate().\n *\n * Returns { loc, selector } when a visible match is found, otherwise null.\n */\n private async _healByDataTestFuzzy(\n primarySelector: string,\n ): Promise<{ loc: Locator; selector: string } | null> {\n const m = primarySelector.match(/\\[data-test(?:id)?=[\"']([^\"']+)[\"'\\]]/i);\n if (!m) return null;\n const target = m[1];\n const attr = /data-testid/i.test(primarySelector) ? 'data-testid' : 'data-test';\n\n let candidates: string[] = [];\n try {\n candidates = await this.page.evaluate((a: string) => {\n const els = document.querySelectorAll<HTMLElement>(`[${a}]`);\n return Array.from(els)\n .map(el => el.getAttribute(a) ?? '')\n .filter(Boolean);\n }, attr);\n } catch {\n return null;\n }\n\n const lev = (a: string, b: string): number => {\n const rows = a.length, cols = b.length;\n const dp: number[][] = Array.from({ length: rows + 1 }, (_, i) =>\n Array.from({ length: cols + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),\n );\n for (let i = 1; i <= rows; i++)\n for (let j = 1; j <= cols; j++)\n dp[i][j] =\n a[i - 1] === b[j - 1]\n ? dp[i - 1][j - 1]\n : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);\n return dp[rows][cols];\n };\n\n let best: { val: string; dist: number } | null = null;\n for (const val of candidates) {\n const dist = lev(target, val);\n if (dist <= 1 && (!best || dist < best.dist)) best = { val, dist };\n }\n\n if (!best) return null;\n\n const selector = `[${attr}=\"${best.val}\"]`;\n this.logger.warn(\n `[LocatorHealer] data-test fuzzy heal: \"${target}\" → \"${best.val}\" (dist=${best.dist})`,\n );\n const loc = this.page.locator(selector);\n if (await this.isVisible(loc)) return { loc, selector };\n return null;\n }\n private async isVisible(loc: Locator): Promise<boolean> {\n try {\n return await loc.isVisible({ timeout: 2_000 });\n } catch {\n return false;\n }\n }\n\n private persist(key: string, _loc: Locator, strategy: string, intent = '', originalSelector = '', healedSelector?: string): void {\n const provider = strategy === 'ai-vision' ? (process.env.AI_PROVIDER ?? 'openai') : undefined;\n if (healedSelector) {\n try {\n if (!this.repo.getHealed(key) && originalSelector) {\n this.repo.register(key, originalSelector, intent);\n }\n this.repo.setHealed(key, healedSelector, strategy, provider);\n this.logger.info(`[LocatorHealer] 💾 Healed selector saved for \"${key}\" → ${healedSelector}`);\n } catch (err) {\n this.logger.warn(`[LocatorHealer] Could not persist heal for \"${key}\": ${String(err)}`);\n }\n }\n try {\n HealingDashboard.getInstance().record({\n key,\n originalSelector,\n strategy,\n provider,\n healedSelector,\n intent,\n scenario: this.currentScenario,\n timestamp: new Date().toISOString(),\n });\n } catch { /* dashboard may not be running */ }\n }\n}\n",
48
48
  "src/utils/locators/LocatorManager.ts": "import { Page } from '@playwright/test';\nimport { logger } from '@utils/helpers/Logger';\nimport { LocatorStrategy, LocatorConfig } from './LocatorStrategy';\nimport { AISelfHealing } from '@utils/ai-assistant/AISelfHealing';\nimport { environment } from '@config/environment';\nimport * as fs from 'fs';\n\nexport type { LocatorConfig };\n\nexport interface LocatorDefinition {\n primary: LocatorConfig;\n fallbacks?: LocatorConfig[];\n}\n\nexport interface LocatorMap {\n [key: string]: LocatorDefinition;\n}\n\n/**\n * LocatorManager\n *\n * Centralised registry for page/component locator maps.\n * On lookup, tries primary → fallbacks → AISelfHealing.\n *\n * Usage (page object constructor):\n * this.locatorManager.registerLocators('LoginPage', LOGIN_LOCATORS, loginLocatorsFilePath);\n *\n * Usage (page method):\n * const loc = await this.locatorManager.getLocator('LoginPage', 'USERNAME_INPUT');\n */\nexport class LocatorManager {\n private readonly locatorStrategy: LocatorStrategy;\n private readonly locatorMaps = new Map<string, LocatorMap>();\n private readonly fileCache = new Map<string, string>();\n private readonly selfHealing: AISelfHealing;\n private readonly env = environment.getConfig();\n\n constructor(private readonly page: Page) {\n this.locatorStrategy = new LocatorStrategy(page);\n this.selfHealing = new AISelfHealing(page);\n }\n\n registerLocators(name: string, locators: LocatorMap, filePath?: string): void {\n this.locatorMaps.set(name, locators);\n if (filePath) this.fileCache.set(name, filePath);\n logger.debug(`Locators registered for: ${name} (${Object.keys(locators).length} keys)`);\n }\n\n async getLocator(pageName: string, locatorKey: string): Promise<import('@playwright/test').Locator | null> {\n const locatorMap = this.locatorMaps.get(pageName);\n if (!locatorMap) { logger.warn(`Locator map not found for page: ${pageName}`); return null; }\n const definition = locatorMap[locatorKey];\n if (!definition) { logger.warn(`Locator key not found: ${locatorKey} in ${pageName}`); return null; }\n\n const loc = await this.tryStrategies(definition, pageName, locatorKey);\n if (loc) return loc;\n\n if (this.env.enableSelfHealing) {\n return this.attemptSelfHealing(pageName, locatorKey, definition);\n }\n\n logger.warn(`All strategies failed for ${locatorKey} on ${pageName}`);\n return null;\n }\n\n private async tryStrategies(def: LocatorDefinition, pageName: string, key: string): Promise<import('@playwright/test').Locator | null> {\n for (const strategy of [def.primary, ...(def.fallbacks ?? [])]) {\n try {\n const loc = this.locatorStrategy.getLocator(strategy);\n await loc.waitFor({ timeout: 2_000 });\n logger.debug(`Found via ${strategy.strategy}`, { page: pageName, key });\n return loc;\n } catch { /* try next */ }\n }\n return null;\n }\n\n private async attemptSelfHealing(pageName: string, key: string, def: LocatorDefinition): Promise<import('@playwright/test').Locator | null> {\n logger.info(`Self-healing \"${key}\" on ${pageName}`);\n try {\n const report = await this.selfHealing.heal(\n def.primary.value,\n key.replace(/_/g, ' ').toLowerCase(),\n { elementType: this.inferElementType(key), context: pageName, maxAttempts: this.env.locatorHealAttempts ?? 3 },\n );\n if (report.success && report.healedSelector) {\n logger.info(`Healed via ${report.method}`, { key, selector: report.healedSelector });\n this.updateDefinition(pageName, key, report.healedSelector);\n return this.page.locator(report.healedSelector);\n }\n } catch (err) {\n logger.error(`Healing error for \"${key}\": ${String(err)}`);\n }\n return null;\n }\n\n private updateDefinition(pageName: string, key: string, healedSelector: string): void {\n const def = this.locatorMaps.get(pageName)?.[key];\n if (!def) return;\n def.primary = { strategy: 'css', value: healedSelector };\n try {\n const filePath = this.fileCache.get(pageName);\n if (filePath && fs.existsSync(filePath)) {\n const content = fs.readFileSync(filePath, 'utf-8');\n const updated = content.replace(\n new RegExp(`(\\\\[?${key}[\\\\]:]?)([^}]*?)value:\\\\s*['\"][^'\"]*['\"]`, 'g'),\n `$1$2value: '${healedSelector.replace(/'/g, \"\\\\'\")}'`,\n );\n fs.writeFileSync(filePath, updated);\n }\n } catch (err) {\n logger.warn(`Could not persist healed locator for \"${key}\": ${String(err)}`);\n }\n }\n\n getStrategies(pageName: string, locatorKey: string): LocatorConfig[] {\n const def = this.locatorMaps.get(pageName)?.[locatorKey];\n if (!def) return [];\n return [def.primary, ...(def.fallbacks ?? [])];\n }\n\n getDynamicLocator(pageName: string, locatorKey: string, params: Record<string, string>): LocatorConfig | null {\n const def = this.locatorMaps.get(pageName)?.[locatorKey];\n if (!def) return null;\n let value = def.primary.value;\n for (const [k, v] of Object.entries(params)) value = value.replace(`{${k}}`, v);\n return { ...def.primary, value };\n }\n\n getRegisteredPages(): string[] { return [...this.locatorMaps.keys()]; }\n getPageLocators(pageName: string): string[] {\n const map = this.locatorMaps.get(pageName);\n return map ? Object.keys(map) : [];\n }\n\n private inferElementType(key: string): string {\n const k = key.toUpperCase();\n const map: Record<string, string> = { BUTTON: 'button', CLICK: 'button', INPUT: 'input', FIELD: 'input', LINK: 'link', ANCHOR: 'link', CHECKBOX: 'checkbox', RADIO: 'radio', SELECT: 'select', DROPDOWN: 'select', LABEL: 'label', HEADING: 'heading', TITLE: 'heading', IMAGE: 'img', ICON: 'img' };\n for (const [pattern, type] of Object.entries(map)) { if (k.includes(pattern)) return type; }\n return 'button';\n }\n}\n",
49
49
  "src/utils/locators/LocatorRepository.ts": "import * as fs from 'fs';\nimport * as path from 'path';\n\nconst HEAL_STORE_PATH = path.resolve(\n process.env.HEAL_STORE_PATH ?? 'storage-state/healed-locators.json',\n);\n\ninterface HealRecord {\n healedSelector: string;\n originalSelector: string;\n strategy: string;\n provider?: string;\n intent: string;\n healCount: number;\n lastHealedAt: string;\n approved?: boolean;\n}\n\nfunction loadHealStore(): Record<string, HealRecord> {\n try {\n if (!fs.existsSync(HEAL_STORE_PATH)) return {};\n const raw = fs.readFileSync(HEAL_STORE_PATH, 'utf8');\n return JSON.parse(raw) as Record<string, HealRecord>;\n } catch {\n return {};\n }\n}\n\nfunction persistHeal(key: string, record: HealRecord): void {\n try {\n const dir = path.dirname(HEAL_STORE_PATH);\n if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });\n const store = loadHealStore();\n store[key] = record;\n fs.writeFileSync(HEAL_STORE_PATH, JSON.stringify(store, null, 2), 'utf8');\n } catch (err) {\n console.warn(`[LocatorRepository] Could not write heal store: ${String(err)}`);\n }\n}\n\nfunction evictHeal(key: string): void {\n try {\n if (!fs.existsSync(HEAL_STORE_PATH)) return;\n const store = loadHealStore();\n if (key in store) {\n delete store[key];\n fs.writeFileSync(HEAL_STORE_PATH, JSON.stringify(store, null, 2), 'utf8');\n }\n } catch { /* non-fatal */ }\n}\n\nexport interface LocatorEntry {\n key: string;\n selector: string;\n intent: string;\n healedSelector?: string;\n healStrategy?: string;\n healProvider?: string;\n healCount: number;\n lastHealedAt?: Date;\n id?: string;\n name?: string;\n page?: string;\n locator?: string;\n strategy?: string;\n value?: string;\n options?: Record<string, unknown>;\n metadata?: Record<string, unknown>;\n}\n\nexport class LocatorRepository {\n private static _instance: LocatorRepository | null = null;\n\n static getInstance(): LocatorRepository {\n if (!LocatorRepository._instance) {\n LocatorRepository._instance = new LocatorRepository();\n }\n return LocatorRepository._instance;\n }\n\n static resetInstance(): void {\n LocatorRepository._instance = null;\n }\n\n private readonly entries = new Map<string, LocatorEntry>();\n\n register(key: string, selector: string, intent: string): void {\n if (!this.entries.has(key)) {\n const entry: LocatorEntry = { key, selector, intent, healCount: 0 };\n const store = loadHealStore();\n if (store[key]) {\n entry.healedSelector = store[key].healedSelector;\n entry.healStrategy = store[key].strategy;\n entry.healProvider = store[key].provider;\n entry.healCount = store[key].healCount;\n entry.lastHealedAt = new Date(store[key].lastHealedAt);\n }\n this.entries.set(key, entry);\n }\n }\n\n getBestSelector(key: string): string {\n const entry = this.entries.get(key);\n if (!entry) throw new Error(`LocatorRepository: key \"${key}\" not registered`);\n return entry.healedSelector ?? entry.selector;\n }\n\n getHealed(key: string): string | null {\n return this.entries.get(key)?.healedSelector ?? null;\n }\n\n setHealed(key: string, healedSelector: string, strategy = 'unknown', provider?: string): void {\n const entry = this.entries.get(key);\n if (!entry) {\n this.entries.set(key, { key, selector: '', intent: key, healCount: 0 });\n }\n const e = this.entries.get(key)!;\n e.healedSelector = healedSelector;\n e.healStrategy = strategy;\n e.healProvider = provider;\n e.healCount++;\n e.lastHealedAt = new Date();\n persistHeal(key, {\n healedSelector,\n originalSelector: e.selector,\n strategy,\n provider,\n intent: e.intent,\n healCount: e.healCount,\n lastHealedAt: e.lastHealedAt.toISOString(),\n });\n }\n\n evict(key: string): void {\n const entry = this.entries.get(key);\n if (entry) {\n entry.healedSelector = undefined;\n entry.healStrategy = undefined;\n entry.healProvider = undefined;\n }\n evictHeal(key);\n }\n\n getIntent(key: string): string {\n const entry = this.entries.get(key);\n if (!entry) throw new Error(`LocatorRepository: key \"${key}\" not registered`);\n return entry.intent;\n }\n\n getByName(name: string): LocatorEntry | undefined {\n for (const entry of this.entries.values()) {\n if (entry.name === name || entry.key === name) return entry;\n }\n const lower = name.toLowerCase();\n for (const entry of this.entries.values()) {\n if (\n (entry.name ?? '').toLowerCase() === lower ||\n entry.key.toLowerCase() === lower\n ) return entry;\n }\n return undefined;\n }\n\n update(partial: Partial<LocatorEntry> & { id: string }): void {\n const key = partial.id;\n const existing = this.entries.get(key);\n if (existing) {\n Object.assign(existing, partial);\n if (partial.locator) {\n existing.healedSelector = partial.locator;\n existing.healCount = (existing.healCount ?? 0) + 1;\n existing.lastHealedAt = new Date();\n }\n } else {\n this.entries.set(key, {\n key,\n selector: partial.selector ?? partial.locator ?? '',\n intent: partial.name ?? key,\n healCount: 0,\n ...partial,\n ...(partial.locator ? { healedSelector: partial.locator } : {}),\n });\n }\n }\n\n getAll(): LocatorEntry[] {\n return Array.from(this.entries.values());\n }\n\n clearHealed(): void {\n for (const entry of this.entries.values()) {\n delete entry.healedSelector;\n entry.healCount = 0;\n delete entry.lastHealedAt;\n }\n }\n\n summary(): { total: number; healed: number; healRate: string } {\n const total = this.entries.size;\n const healed = Array.from(this.entries.values()).filter(e => e.healedSelector).length;\n return {\n total,\n healed,\n healRate: total ? `${Math.round((healed / total) * 100)}%` : '0%',\n };\n }\n}\n",
50
50
  "src/utils/locators/LocatorRules.ts": "import * as fs from 'fs';\nimport * as path from 'path';\nimport { logger } from '@utils/helpers/Logger';\n\nexport type RulePriorityKey = 'dataTestId' | 'role' | 'ariaLabel' | 'name' | 'id' | 'text' | 'placeholder' | 'css' | 'xpath';\nexport type LocatorPattern = 'testid' | 'role' | 'label' | 'placeholder' | 'text' | 'css' | 'xpath';\n\nexport interface OptimizationRules {\n priorities: RulePriorityKey[];\n uniqueCheck: boolean;\n}\n\nexport interface RuleCondition {\n attribute: string;\n operator: 'exists' | 'equals' | 'contains' | 'startsWith' | 'endsWith' | 'matches';\n value?: string | RegExp;\n required: boolean;\n}\n\nexport interface RuleExample {\n html: string;\n locatorValue: string;\n description: string;\n success: boolean;\n}\n\nexport interface LocatorRule {\n id: string;\n name: string;\n pattern: LocatorPattern;\n elementTypes: string[];\n priority: number;\n conditions: RuleCondition[];\n examples: RuleExample[];\n confidence: number;\n}\n\nconst CONTEXT_RULES = {\n formInputs: { preferredStrategies: ['testid', 'label', 'placeholder'], description: 'Form input fields' },\n buttons: { preferredStrategies: ['testid', 'role', 'text'], description: 'Clickable buttons' },\n navigationLinks: { preferredStrategies: ['text', 'role', 'testid'], description: 'Navigation links' },\n dropdowns: { preferredStrategies: ['testid', 'label', 'css'], description: 'Select/dropdown elements' },\n};\n\nexport function getContextRules() { return CONTEXT_RULES; }\n\nexport function getAILocatorRules(): LocatorRule[] {\n return [\n {\n id: 'rule-testid-primary', name: 'Test ID — Primary Strategy', pattern: 'testid',\n elementTypes: ['button', 'input', 'select', 'link', 'div', 'span'], priority: 1,\n conditions: [{ attribute: 'data-testid', operator: 'exists', required: true }],\n examples: [{ html: '<button data-testid=\"login-button\">Login</button>', locatorValue: 'login-button', description: 'Button with data-testid', success: true }],\n confidence: 0.95,\n },\n {\n id: 'rule-aria-label', name: 'ARIA Label Strategy', pattern: 'label',\n elementTypes: ['button', 'input', 'link'], priority: 2,\n conditions: [{ attribute: 'aria-label', operator: 'exists', required: true }],\n examples: [{ html: '<button aria-label=\"Close Menu\">×</button>', locatorValue: 'Close Menu', description: 'Button with aria-label', success: true }],\n confidence: 0.9,\n },\n {\n id: 'rule-placeholder', name: 'Placeholder Strategy', pattern: 'placeholder',\n elementTypes: ['input', 'textarea'], priority: 3,\n conditions: [{ attribute: 'placeholder', operator: 'exists', required: true }],\n examples: [{ html: '<input placeholder=\"Enter username\">', locatorValue: 'Enter username', description: 'Input with placeholder', success: true }],\n confidence: 0.85,\n },\n {\n id: 'rule-visible-text', name: 'Visible Text Strategy', pattern: 'text',\n elementTypes: ['button', 'link', 'span', 'div'], priority: 4,\n conditions: [{ attribute: 'textContent', operator: 'exists', required: true }],\n examples: [{ html: '<button>Login</button>', locatorValue: 'Login', description: 'Button with visible text', success: true }],\n confidence: 0.75,\n },\n {\n id: 'rule-css-selector', name: 'CSS Selector Strategy', pattern: 'css',\n elementTypes: ['*'], priority: 5,\n conditions: [{ attribute: 'class', operator: 'exists', required: true }],\n examples: [{ html: '<button class=\"btn btn-primary\">Login</button>', locatorValue: '.btn.btn-primary', description: 'Button with class', success: true }],\n confidence: 0.7,\n },\n ];\n}\n\n/**\n * LocatorRules\n *\n * Singleton that loads selector strategy priorities.\n * Optionally reads a `.vscode/copilot-instructions.md` file to auto-tune priorities.\n * Falls back to sensible defaults if the file is absent.\n */\nexport class LocatorRules {\n private static instance: LocatorRules;\n private rules: OptimizationRules = {\n priorities: ['dataTestId', 'role', 'ariaLabel', 'name', 'id', 'text', 'placeholder', 'css', 'xpath'],\n uniqueCheck: true,\n };\n\n private constructor() { this.loadRules(); }\n\n static getInstance(): LocatorRules {\n if (!LocatorRules.instance) LocatorRules.instance = new LocatorRules();\n return LocatorRules.instance;\n }\n\n private loadRules(): void {\n try {\n const rulesPath = path.join(process.cwd(), '.vscode', 'copilot-instructions.md');\n if (!fs.existsSync(rulesPath)) return;\n const content = fs.readFileSync(rulesPath, 'utf-8');\n const found: RulePriorityKey[] = [];\n const add = (key: RulePriorityKey, patterns: RegExp[]) => {\n if (patterns.some(re => re.test(content))) found.push(key);\n };\n add('dataTestId', [/data-?test(id)?/i, /testid/i]);\n add('role', [/role-?based/i, /\\brole\\b/i]);\n add('ariaLabel', [/aria-?label/i]);\n add('name', [/\\bname\\b/i]);\n add('id', [/\\bid\\b/i]);\n add('text', [/text-?based/i, /visible text/i]);\n add('placeholder',[/placeholder/i]);\n add('css', [/css selector/i]);\n add('xpath', [/xpath/i]);\n if (found.length) {\n this.rules.priorities = [...found, ...this.rules.priorities.filter(d => !found.includes(d))];\n }\n logger.info(`LocatorRules loaded: [${this.rules.priorities.join(', ')}]`);\n } catch (err) {\n logger.warn(`LocatorRules: using defaults (${String(err)})`);\n }\n }\n\n getPriorities(): RulePriorityKey[] { return this.rules.priorities; }\n\n orderStrategies(hints: Record<string, unknown>): RulePriorityKey[] {\n const available = this.rules.priorities.filter(p => hints[p] !== undefined);\n return available.length ? available : this.rules.priorities;\n }\n\n getRulesForElementType(elementType: string): LocatorRule[] {\n return getAILocatorRules().filter(r => r.elementTypes.includes(elementType) || r.elementTypes.includes('*')).sort((a, b) => a.priority - b.priority);\n }\n\n getContextPreferences(contextType: string): unknown {\n return CONTEXT_RULES[contextType as keyof typeof CONTEXT_RULES];\n }\n}\n",
@@ -27,10 +27,19 @@ Infrastructure file protection
27
27
  Within-file deduplication
28
28
  For locators.ts : new const-object entries are merged; duplicate keys skipped.
29
29
  For *.steps.ts : new step blocks are appended; duplicate regex patterns skipped.
30
+ Cross-file deduplication is also applied: before writing any
31
+ *.steps.ts, ALL other *.steps.ts files in src/test/steps/ are
32
+ scanned for existing patterns. A pattern already defined in
33
+ any sibling file is treated as a duplicate — the block is
34
+ dropped from the incoming content. This prevents the
35
+ "Multiple step definitions match" Cucumber error.
30
36
  For *.page.ts : new async methods are appended; duplicate method names skipped.
31
37
  For *.feature : new Scenario blocks are appended; duplicate titles skipped.
32
- When a title matches, the existing scenario (and its original step
33
- wording) is kept the generated version is dropped entirely.
38
+ Cross-file deduplication is also applied: before writing any
39
+ *.feature file, ALL other *.feature files in
40
+ src/test/features/ are scanned for existing scenario titles.
41
+ A title already present in any sibling file is treated as a
42
+ duplicate and dropped from the incoming content.
34
43
 
35
44
  Interface adapter
36
45
  The generator emits repo.updateHealed / repo.incrementSuccess / repo.getBBox etc.
@@ -211,9 +220,53 @@ def _merge_locators(existing: str, generated: str) -> tuple[str, list[str], list
211
220
  return merged, added, skipped
212
221
 
213
222
 
214
- def _merge_steps(existing: str, generated: str) -> tuple[str, list[str], list[str]]:
215
- """Append step blocks whose regex pattern is not already in existing."""
223
+ def _collect_all_scenario_titles(features_dir: Path, exclude_file: Path | None = None) -> set[str]:
224
+ """Return every scenario title (lower-cased) defined in all *.feature files
225
+ in features_dir, optionally excluding one file (the one currently being written)."""
226
+ _title_re = re.compile(
227
+ r"^\s*Scenario(?:\s+Outline)?\s*:\s*(.+)$", re.MULTILINE | re.IGNORECASE
228
+ )
229
+ titles: set[str] = set()
230
+ if not features_dir.is_dir():
231
+ return titles
232
+ for f in features_dir.glob("*.feature"):
233
+ if exclude_file and f.resolve() == exclude_file.resolve():
234
+ continue
235
+ try:
236
+ titles.update(
237
+ m.group(1).strip().lower()
238
+ for m in _title_re.finditer(f.read_text(encoding="utf-8"))
239
+ )
240
+ except OSError:
241
+ pass
242
+ return titles
243
+
244
+
245
+
246
+ """Return every /^pattern$/ defined in all *.steps.ts files in steps_dir,
247
+ optionally excluding one file (the one currently being written)."""
248
+ patterns: set[str] = set()
249
+ if not steps_dir.is_dir():
250
+ return patterns
251
+ for f in steps_dir.glob("*.steps.ts"):
252
+ if exclude_file and f.resolve() == exclude_file.resolve():
253
+ continue
254
+ try:
255
+ patterns.update(re.findall(r"/\^([^/]+)\$/", f.read_text(encoding="utf-8")))
256
+ except OSError:
257
+ pass
258
+ return patterns
259
+
260
+
261
+ def _merge_steps(
262
+ existing: str,
263
+ generated: str,
264
+ cross_file_patterns: set[str] | None = None,
265
+ ) -> tuple[str, list[str], list[str]]:
266
+ """Append step blocks whose regex pattern is not already in existing or in
267
+ any sibling step file (cross_file_patterns)."""
216
268
  existing_patterns = set(re.findall(r"/\^([^/]+)\$/", existing))
269
+ forbidden = existing_patterns | (cross_file_patterns or set())
217
270
 
218
271
  step_block_re = re.compile(r"^(Given|When|Then)\(", re.MULTILINE)
219
272
  parts = step_block_re.split(generated)
@@ -233,11 +286,14 @@ def _merge_steps(existing: str, generated: str) -> tuple[str, list[str], list[st
233
286
  for _kw, block in blocks:
234
287
  pat_match = re.search(r"/\^([^/]+)\$/", block)
235
288
  pattern = pat_match.group(1) if pat_match else block[:40]
236
- if pattern in existing_patterns:
289
+ if pattern in forbidden:
237
290
  skipped.append(pattern)
238
291
  else:
239
292
  new_blocks.append(block)
240
293
  added.append(pattern)
294
+ # Track newly added so subsequent blocks in the same batch
295
+ # don't get written twice either
296
+ forbidden.add(pattern)
241
297
 
242
298
  merged = existing.rstrip() + ("\n\n" + "\n".join(new_blocks) if new_blocks else "") + "\n"
243
299
  return merged, added, skipped
@@ -302,9 +358,14 @@ def _parse_feature_blocks(content: str) -> tuple[str, list[str]]:
302
358
  return "".join(header), blocks
303
359
 
304
360
 
305
- def _merge_feature_scenarios(existing: str, generated: str) -> tuple[str, list[str], list[str]]:
361
+ def _merge_feature_scenarios(
362
+ existing: str,
363
+ generated: str,
364
+ cross_file_titles: set[str] | None = None,
365
+ ) -> tuple[str, list[str], list[str]]:
306
366
  """
307
- Append Scenario / Scenario Outline blocks whose titles are not already present.
367
+ Append Scenario / Scenario Outline blocks whose titles are not already present
368
+ in this file or in any sibling feature file (cross_file_titles).
308
369
 
309
370
  Deduplication is by title (case-insensitive exact match). When a collision is
310
371
  found the existing scenario — including its original step wording — is kept and
@@ -322,6 +383,7 @@ def _merge_feature_scenarios(existing: str, generated: str) -> tuple[str, list[s
322
383
  existing_titles = {
323
384
  m.group(1).strip().lower() for m in _scenario_title_re.finditer(existing)
324
385
  }
386
+ forbidden = existing_titles | (cross_file_titles or set())
325
387
 
326
388
  _, gen_blocks = _parse_feature_blocks(generated)
327
389
 
@@ -334,11 +396,12 @@ def _merge_feature_scenarios(existing: str, generated: str) -> tuple[str, list[s
334
396
  if not title_match:
335
397
  continue
336
398
  title = title_match.group(1).strip()
337
- if title.lower() in existing_titles:
399
+ if title.lower() in forbidden:
338
400
  skipped.append(title)
339
401
  else:
340
402
  new_blocks.append(block.rstrip())
341
403
  added.append(title)
404
+ forbidden.add(title.lower())
342
405
 
343
406
  if not new_blocks:
344
407
  return existing, added, skipped
@@ -596,7 +659,9 @@ def write_files_to_helix(
596
659
  try:
597
660
  if dest.exists():
598
661
  existing_text = dest.read_text(encoding="utf-8")
599
- merged, added, dup = _merge_feature_scenarios(existing_text, content)
662
+ features_dir = root / "src" / "test" / "features"
663
+ cross = _collect_all_scenario_titles(features_dir, exclude_file=dest)
664
+ merged, added, dup = _merge_feature_scenarios(existing_text, content, cross)
600
665
  deduplication[dest_rel] = {
601
666
  "type": "feature",
602
667
  "added_scenarios": added,
@@ -629,7 +694,9 @@ def write_files_to_helix(
629
694
  "type": "locators", "added_keys": added, "skipped_keys": dup,
630
695
  }
631
696
  elif _STEPS_RE.search(file_key):
632
- merged, added, dup = _merge_steps(existing_text, content)
697
+ steps_dir = root / "src" / "test" / "steps"
698
+ cross = _collect_all_step_patterns(steps_dir, exclude_file=dest)
699
+ merged, added, dup = _merge_steps(existing_text, content, cross)
633
700
  deduplication[dest_rel] = {
634
701
  "type": "steps", "added_patterns": added, "skipped_patterns": dup,
635
702
  }
@@ -731,21 +798,28 @@ def update_helix_file(
731
798
  merged, added, dup = _merge_locators(existing_text, adapted)
732
799
  dedup = {"type": "locators", "added_keys": added, "skipped_keys": dup}
733
800
  elif _STEPS_RE.search(file_key):
734
- merged, added, dup = _merge_steps(existing_text, adapted)
801
+ steps_dir = root / "src" / "test" / "steps"
802
+ cross = _collect_all_step_patterns(steps_dir, exclude_file=target)
803
+ merged, added, dup = _merge_steps(existing_text, adapted, cross)
735
804
  dedup = {"type": "steps", "added_patterns": added, "skipped_patterns": dup}
736
805
  elif _PAGE_RE.search(file_key):
737
806
  merged, added, dup = _merge_page_methods(existing_text, adapted)
738
807
  dedup = {"type": "page", "added_methods": added, "skipped_methods": dup}
739
808
  elif _FEATURE_RE.search(file_key):
740
- # Feature files are the Gherkin source of truth — always overwrite
741
- target.write_text(adapted, encoding="utf-8")
742
- return {
743
- "success": True,
744
- "path": dest_rel,
745
- "action": "overwritten",
746
- "bytes": len(adapted.encode()),
747
- "deduplication": None,
748
- }
809
+ features_dir = root / "src" / "test" / "features"
810
+ cross = _collect_all_scenario_titles(features_dir, exclude_file=target)
811
+ if not target.exists():
812
+ target.write_text(adapted, encoding="utf-8")
813
+ return {
814
+ "success": True,
815
+ "path": dest_rel,
816
+ "action": "created",
817
+ "bytes": len(adapted.encode()),
818
+ "deduplication": None,
819
+ }
820
+ existing_text = target.read_text(encoding="utf-8")
821
+ merged, added, dup = _merge_feature_scenarios(existing_text, adapted, cross)
822
+ dedup = {"type": "feature", "added_scenarios": added, "skipped_scenarios": dup}
749
823
  else:
750
824
  merged = adapted
751
825
  dedup = {"type": "unknown", "action": "overwritten"}