@qa-gentic/stlc-agents 1.0.3 → 1.0.4

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.3",
3
+ "version": "1.0.4",
4
4
  "description": "QA STLC Agents — four MCP servers + skills for AI-powered test case, Gherkin, Playwright generation, and Helix-QA file writing against Azure DevOps. Includes Playwright + Cucumber + TypeScript scaffold. Works with Claude Code, GitHub Copilot, Cursor, Windsurf.",
5
5
  "keywords": [
6
6
  "playwright",
@@ -4,7 +4,7 @@
4
4
  * boilerplate-bundle.js — embedded Helix-QA framework template files.
5
5
  * Generated by scripts/gen_boilerplate_js.py — do not edit by hand.
6
6
  *
7
- * Contains 51 files from boilerplate/framework/.
7
+ * Contains 52 files from boilerplate/framework/.
8
8
  * Bundle keys are paths relative to the framework root.
9
9
  * .gitkeep entries are empty strings (directory markers).
10
10
  */
@@ -18,7 +18,7 @@ const BUNDLE = {
18
18
  "cucumber.js": "const common = {\n requireModule: ['ts-node/register', 'tsconfig-paths/register'],\n require: ['src/test/steps/**/*.ts'],\n paths: ['src/test/features/**/*.feature'],\n timeout: 60000,\n format: [\n 'progress-bar',\n 'html:test-results/reports/cucumber-report.html',\n 'json:test-results/reports/cucumber-report.json',\n '@cucumber/pretty-formatter',\n ],\n formatOptions: {\n snippetInterface: 'async-await',\n colorsEnabled: true,\n },\n publishQuiet: true,\n};\n\nmodule.exports = {\n default: {\n ...common,\n parallel: 2,\n retry: 1,\n },\n ci: {\n ...common,\n parallel: 4,\n retry: 2,\n retryTagFilter: '@flaky',\n },\n docker: {\n ...common,\n parallel: 4,\n retry: 2,\n },\n debug: {\n ...common,\n parallel: 1,\n retry: 0,\n },\n};\n",
19
19
  "docker-compose.yml": "version: '3.8'\n\nservices:\n qa-tests:\n build:\n context: .\n dockerfile: Dockerfile\n environment:\n - BASE_URL=${BASE_URL:-https://your-app-url.com}\n - AI_PROVIDER=${AI_PROVIDER:-openai}\n - AI_API_KEY=${AI_API_KEY}\n - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}\n - AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT}\n - AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY}\n - AZURE_OPENAI_DEPLOYMENT=${AZURE_OPENAI_DEPLOYMENT}\n - AUTH_USERNAME=${AUTH_USERNAME}\n - AUTH_PASSWORD=${AUTH_PASSWORD}\n - ENABLE_SELF_HEALING=${ENABLE_SELF_HEALING:-true}\n - CI=true\n - HEADLESS=true\n - BROWSER=chromium\n - PARALLEL_WORKERS=4\n volumes:\n - ./test-results:/app/test-results\n - ./storage-state:/app/storage-state\n - ./logs:/app/logs\n",
20
20
  "logs/.gitkeep": "",
21
- "package.json": "{\n \"name\": \"{{PROJECT_NAME}}\",\n \"version\": \"1.0.0\",\n \"description\": \"QA automation framework — Playwright + TypeScript + Cucumber BDD with AI-powered self-healing locators\",\n \"main\": \"index.js\",\n \"scripts\": {\n \"test\": \"cucumber-js\",\n \"test:smoke\": \"cucumber-js --tags \\\"@smoke\\\"\",\n \"test:regression\": \"cucumber-js --tags \\\"@regression\\\"\",\n \"test:headed\": \"HEADLESS=false cucumber-js\",\n \"test:headed:smoke\": \"HEADLESS=false cucumber-js --tags \\\"@smoke\\\"\",\n \"test:docker\": \"cucumber-js --profile docker\",\n \"test:ci\": \"cucumber-js --profile ci\",\n \"test:debug\": \"PWDEBUG=1 cucumber-js\",\n \"test:parallel\": \"cucumber-js --parallel 4 --tags \\\"@regression\\\"\",\n \"test:chrome\": \"BROWSER=chromium cucumber-js\",\n \"test:firefox\": \"BROWSER=firefox cucumber-js\",\n \"test:webkit\": \"BROWSER=webkit cucumber-js\",\n \"auth:setup\": \"ts-node src/utils/storage-state/AuthSetup.ts\",\n \"report:open\": \"open test-results/reports/cucumber-report.html\",\n \"docker:build\": \"docker build -f Dockerfile -t qa-framework .\",\n \"docker:run\": \"docker-compose up --abort-on-container-exit\",\n \"docker:down\": \"docker-compose down\",\n \"lint\": \"eslint src --ext .ts\",\n \"lint:fix\": \"eslint src --ext .ts --fix\",\n \"format\": \"prettier --write \\\"src/**/*.ts\\\"\",\n \"format:check\": \"prettier --check \\\"src/**/*.ts\\\"\",\n \"typecheck\": \"tsc --noEmit\",\n \"build\": \"tsc\",\n \"healix:review\": \"ts-node -r tsconfig-paths/register src/utils/locators/review-server.ts\",\n \"healix:apply-ci\": \"ts-node -r tsconfig-paths/register src/utils/locators/healix-ci-apply.ts\",\n \"clean\": \"rimraf dist test-results\",\n \"prepare\": \"husky install\",\n \"pre-commit\": \"lint-staged\"\n },\n \"keywords\": [\n \"playwright\",\n \"typescript\",\n \"cucumber\",\n \"bdd\",\n \"self-healing\",\n \"ai\",\n \"test-automation\",\n \"qa\"\n ],\n \"author\": \"\",\n \"license\": \"MIT\",\n \"dependencies\": {\n \"@anthropic-ai/sdk\": \"^0.71.2\",\n \"@azure/openai\": \"^2.0.0\",\n \"@cucumber/cucumber\": \"^12.5.0\",\n \"@playwright/test\": \"^1.57.0\",\n \"@qa-gentic/agents\": \"^1.1.3\",\n \"axios\": \"^1.6.2\",\n \"dotenv\": \"^16.3.1\",\n \"openai\": \"^4.20.1\",\n \"sharp\": \"^0.33.0\",\n \"winston\": \"^3.11.0\",\n \"zod\": \"^3.22.4\"\n },\n \"devDependencies\": {\n \"@cucumber/html-formatter\": \"^21.0.0\",\n \"@cucumber/pretty-formatter\": \"^1.0.0\",\n \"@types/node\": \"^20.10.5\",\n \"@typescript-eslint/eslint-plugin\": \"^6.15.0\",\n \"@typescript-eslint/parser\": \"^6.15.0\",\n \"eslint\": \"^8.56.0\",\n \"eslint-config-prettier\": \"^9.1.0\",\n \"eslint-plugin-playwright\": \"^0.20.0\",\n \"husky\": \"^8.0.3\",\n \"lint-staged\": \"^15.2.0\",\n \"prettier\": \"^3.1.1\",\n \"rimraf\": \"^5.0.5\",\n \"ts-node\": \"^10.9.2\",\n \"tsconfig-paths\": \"^4.2.0\",\n \"typescript\": \"^5.9.3\"\n },\n \"lint-staged\": {\n \"*.ts\": [\"eslint --fix\", \"prettier --write\"]\n },\n \"engines\": {\n \"node\": \">=18.0.0\",\n \"npm\": \">=9.0.0\"\n }\n}\n",
21
+ "package.json": "{\n \"name\": \"{{PROJECT_NAME}}\",\n \"version\": \"1.0.0\",\n \"description\": \"QA automation framework — Playwright + TypeScript + Cucumber BDD with AI-powered self-healing locators\",\n \"main\": \"index.js\",\n \"scripts\": {\n \"test\": \"cucumber-js\",\n \"test:smoke\": \"cucumber-js --tags \\\"@smoke\\\"\",\n \"test:regression\": \"cucumber-js --tags \\\"@regression\\\"\",\n \"test:headed\": \"HEADLESS=false cucumber-js\",\n \"test:headed:smoke\": \"HEADLESS=false cucumber-js --tags \\\"@smoke\\\"\",\n \"test:docker\": \"cucumber-js --profile docker\",\n \"test:ci\": \"cucumber-js --profile ci\",\n \"test:debug\": \"PWDEBUG=1 cucumber-js\",\n \"test:parallel\": \"cucumber-js --parallel 4 --tags \\\"@regression\\\"\",\n \"test:chrome\": \"BROWSER=chromium cucumber-js\",\n \"test:firefox\": \"BROWSER=firefox cucumber-js\",\n \"test:webkit\": \"BROWSER=webkit cucumber-js\",\n \"auth:setup\": \"ts-node src/utils/storage-state/AuthSetup.ts\",\n \"report:open\": \"open test-results/reports/cucumber-report.html\",\n \"docker:build\": \"docker build -f Dockerfile -t qa-framework .\",\n \"docker:run\": \"docker-compose up --abort-on-container-exit\",\n \"docker:down\": \"docker-compose down\",\n \"lint\": \"eslint src --ext .ts\",\n \"lint:fix\": \"eslint src --ext .ts --fix\",\n \"format\": \"prettier --write \\\"src/**/*.ts\\\"\",\n \"format:check\": \"prettier --check \\\"src/**/*.ts\\\"\",\n \"typecheck\": \"tsc --noEmit\",\n \"build\": \"tsc\",\n \"healix:review\": \"ts-node -r tsconfig-paths/register src/utils/locators/review-server.ts\",\n \"healix:apply-ci\": \"ts-node -r tsconfig-paths/register src/utils/locators/healix-ci-apply.ts\",\n \"clean\": \"rimraf dist test-results\",\n \"prepare\": \"husky install\",\n \"pre-commit\": \"lint-staged\"\n },\n \"keywords\": [\n \"playwright\",\n \"typescript\",\n \"cucumber\",\n \"bdd\",\n \"self-healing\",\n \"ai\",\n \"test-automation\",\n \"qa\"\n ],\n \"author\": \"\",\n \"license\": \"MIT\",\n \"dependencies\": {\n \"@anthropic-ai/sdk\": \"^0.71.2\",\n \"@azure/openai\": \"^2.0.0\",\n \"@cucumber/cucumber\": \"^12.5.0\",\n \"@playwright/test\": \"^1.57.0\",\n \"axios\": \"^1.6.2\",\n \"dotenv\": \"^16.3.1\",\n \"openai\": \"^4.20.1\",\n \"sharp\": \"^0.33.0\",\n \"winston\": \"^3.11.0\",\n \"zod\": \"^3.22.4\"\n },\n \"devDependencies\": {\n \"@cucumber/html-formatter\": \"^21.0.0\",\n \"@cucumber/pretty-formatter\": \"^1.0.0\",\n \"@types/node\": \"^20.10.5\",\n \"@typescript-eslint/eslint-plugin\": \"^6.15.0\",\n \"@typescript-eslint/parser\": \"^6.15.0\",\n \"eslint\": \"^8.56.0\",\n \"eslint-config-prettier\": \"^9.1.0\",\n \"eslint-plugin-playwright\": \"^0.20.0\",\n \"husky\": \"^8.0.3\",\n \"lint-staged\": \"^15.2.0\",\n \"prettier\": \"^3.1.1\",\n \"rimraf\": \"^5.0.5\",\n \"ts-node\": \"^10.9.2\",\n \"tsconfig-paths\": \"^4.2.0\",\n \"typescript\": \"^5.9.3\"\n },\n \"lint-staged\": {\n \"*.ts\": [\"eslint --fix\", \"prettier --write\"]\n },\n \"engines\": {\n \"node\": \">=18.0.0\",\n \"npm\": \">=9.0.0\"\n }\n}\n",
22
22
  "setup.sh": "#!/usr/bin/env bash\n# setup.sh — one-shot project bootstrap\n# Usage: bash setup.sh\n\nset -euo pipefail\n\nCYAN='\\033[0;36m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nRED='\\033[0;31m'\nNC='\\033[0m'\n\ninfo() { echo -e \"${CYAN}→${NC} $*\"; }\nok() { echo -e \"${GREEN}✓${NC} $*\"; }\nwarn() { echo -e \"${YELLOW}⚠${NC} $*\"; }\ndie() { echo -e \"${RED}✗${NC} $*\"; exit 1; }\n\necho \"\"\necho \" QA Framework — Project Setup\"\necho \" ─────────────────────────────\"\necho \"\"\n\n# ── Node.js ──────────────────────────────────────────────────────────────────\ninfo \"Checking Node.js...\"\nnode_version=$(node -v 2>/dev/null || echo \"none\")\nif [[ \"$node_version\" == \"none\" ]]; then\n die \"Node.js not found. Install v18+ from https://nodejs.org\"\nfi\nok \"Node.js $node_version\"\n\n# ── npm install ───────────────────────────────────────────────────────────────\ninfo \"Installing npm dependencies...\"\nnpm ci || npm install\nok \"Dependencies installed\"\n\n# ── Playwright browsers ───────────────────────────────────────────────────────\ninfo \"Installing Playwright browsers...\"\nnpx playwright install chromium --with-deps\nok \"Playwright browsers installed\"\n\n# ── .env ─────────────────────────────────────────────────────────────────────\nif [[ ! -f .env ]]; then\n info \"Creating .env from template...\"\n cp .env.example .env\n ok \".env created — open it and set BASE_URL, AUTH_USERNAME, AUTH_PASSWORD, AI_API_KEY\"\nelse\n warn \".env already exists — skipping\"\nfi\n\n# ── Output directories ────────────────────────────────────────────────────────\ninfo \"Creating output directories...\"\nmkdir -p test-results/reports test-results/screenshots test-results/videos \\\n test-results/traces test-results/visual-baselines storage-state logs\nok \"Output directories ready\"\n\necho \"\"\necho \"${GREEN}Setup complete!${NC}\"\necho \"\"\necho \" Next steps:\"\necho \" 1. Edit .env — set BASE_URL, AUTH_USERNAME, AUTH_PASSWORD, AI_API_KEY\"\necho \" 2. Run smoke tests: npm run test:smoke\"\necho \" 3. Run all tests: npm test\"\necho \" 4. Open HTML report: npm run report:open\"\necho \"\"\n",
23
23
  "src/config/environment.ts": "import { config as dotenvConfig } from \"dotenv\";\n\ndotenvConfig();\n\nexport interface EnvironmentConfig {\n baseUrl: string;\n environment: string;\n authUsername: string;\n authPassword: string;\n storageStatePath: string;\n authTimeout: number;\n aiProvider: \"openai\" | \"azure\" | \"anthropic\" | \"local\";\n aiApiKey: string;\n aiModel: string;\n aiTemperature: number;\n aiMaxTokens: number;\n enableSelfHealing: boolean;\n enableAITestGeneration: boolean;\n azureOpenAIEndpoint?: string;\n azureOpenAIApiKey?: string;\n azureOpenAIDeployment?: string;\n anthropicApiKey?: string;\n localLLMEndpoint?: string;\n headless: boolean;\n browser: \"chromium\" | \"firefox\" | \"webkit\";\n parallelWorkers: number;\n retryAttempts: number;\n timeout: number;\n navigationTimeout: number;\n actionTimeout: number;\n slowMo: number;\n screenshotOnFailure: boolean;\n videoOnFailure: boolean;\n traceOnFailure: boolean;\n reportOutputDir: string;\n fullPageScreenshots: boolean;\n locatorHealAttempts: number;\n locatorTimeout: number;\n enableLocatorVersioning: boolean;\n logLevel: \"error\" | \"warn\" | \"info\" | \"debug\";\n logToFile: boolean;\n logFilePath: string;\n isCI: boolean;\n shardIndex: number;\n shardTotal: number;\n performanceMonitoring: boolean;\n collectMetrics: boolean;\n maskSensitiveData: boolean;\n}\n\nclass Environment {\n private static instance: Environment;\n private config: EnvironmentConfig;\n\n private constructor() {\n this.config = this.loadConfig();\n this.validateConfig();\n }\n\n public static getInstance(): Environment {\n if (!Environment.instance) {\n Environment.instance = new Environment();\n }\n return Environment.instance;\n }\n\n private loadConfig(): EnvironmentConfig {\n return {\n baseUrl: process.env.BASE_URL || \"http://localhost:3000\",\n environment: process.env.ENVIRONMENT || \"local\",\n authUsername: process.env.AUTH_USERNAME || \"\",\n authPassword: process.env.AUTH_PASSWORD || \"\",\n storageStatePath: process.env.STORAGE_STATE_PATH || \"./storage-state/auth.json\",\n authTimeout: parseInt(process.env.AUTH_TIMEOUT || \"30000\"),\n aiProvider: (process.env.AI_PROVIDER as EnvironmentConfig[\"aiProvider\"]) || \"openai\",\n aiApiKey: process.env.AI_API_KEY || \"\",\n aiModel: process.env.AI_MODEL || \"gpt-4o\",\n aiTemperature: parseFloat(process.env.AI_TEMPERATURE || \"0.3\"),\n aiMaxTokens: parseInt(process.env.AI_MAX_TOKENS || \"2000\"),\n enableSelfHealing: process.env.ENABLE_SELF_HEALING !== \"false\",\n enableAITestGeneration: process.env.ENABLE_AI_TEST_GENERATION === \"true\",\n azureOpenAIEndpoint: process.env.AZURE_OPENAI_ENDPOINT,\n azureOpenAIApiKey: process.env.AZURE_OPENAI_API_KEY,\n azureOpenAIDeployment: process.env.AZURE_OPENAI_DEPLOYMENT,\n anthropicApiKey: process.env.ANTHROPIC_API_KEY,\n localLLMEndpoint: process.env.LOCAL_LLM_ENDPOINT,\n headless: process.env.HEADLESS !== \"false\",\n browser: (process.env.BROWSER as EnvironmentConfig[\"browser\"]) || \"chromium\",\n parallelWorkers: parseInt(process.env.PARALLEL_WORKERS || \"2\"),\n retryAttempts: parseInt(process.env.RETRY_ATTEMPTS || \"1\"),\n timeout: parseInt(process.env.TIMEOUT || \"30000\"),\n navigationTimeout: parseInt(process.env.NAVIGATION_TIMEOUT || \"30000\"),\n actionTimeout: parseInt(process.env.ACTION_TIMEOUT || \"10000\"),\n slowMo: parseInt(process.env.SLOW_MO || \"0\"),\n screenshotOnFailure: process.env.SCREENSHOT_ON_FAILURE === \"true\",\n videoOnFailure: process.env.VIDEO_ON_FAILURE === \"true\",\n traceOnFailure: process.env.TRACE_ON_FAILURE === \"true\",\n reportOutputDir: process.env.REPORT_OUTPUT_DIR || \"./test-results/reports\",\n fullPageScreenshots: process.env.FULL_PAGE_SCREENSHOTS === \"true\",\n locatorHealAttempts: parseInt(process.env.LOCATOR_HEAL_ATTEMPTS || \"3\"),\n locatorTimeout: parseInt(process.env.LOCATOR_TIMEOUT || \"10000\"),\n enableLocatorVersioning: process.env.ENABLE_LOCATOR_VERSIONING === \"true\",\n logLevel: (process.env.LOG_LEVEL as EnvironmentConfig[\"logLevel\"]) || \"info\",\n logToFile: process.env.LOG_TO_FILE === \"true\",\n logFilePath: process.env.LOG_FILE_PATH || \"./logs/test-execution.log\",\n isCI: process.env.CI === \"true\",\n shardIndex: parseInt(process.env.SHARD_INDEX || \"1\"),\n shardTotal: parseInt(process.env.SHARD_TOTAL || \"1\"),\n performanceMonitoring: process.env.PERFORMANCE_MONITORING === \"true\",\n collectMetrics: process.env.COLLECT_METRICS === \"true\",\n maskSensitiveData: process.env.MASK_SENSITIVE_DATA === \"true\",\n };\n }\n\n private validateConfig(): void {\n const errors: string[] = [];\n if (!this.config.baseUrl) {\n errors.push(\"BASE_URL is required\");\n }\n if (this.config.enableSelfHealing && !this.config.aiApiKey && !this.config.anthropicApiKey) {\n console.warn(\"[Environment] Warning: ENABLE_SELF_HEALING=true but no AI API key provided. Self-healing will fall back to rule-based strategies.\");\n }\n if (errors.length > 0) {\n throw new Error(`[Environment] Configuration errors:\\n${errors.join(\"\\n\")}`);\n }\n }\n\n public getConfig(): EnvironmentConfig {\n return this.config;\n }\n}\n\nexport const environment = Environment.getInstance();\n",
24
24
  "src/config/global-setup.ts": "import { FullConfig } from \"@playwright/test\";\nimport { logger } from \"@utils/helpers/Logger\";\nimport { environment } from \"./environment\";\nimport * as fs from \"fs\";\nimport * as path from \"path\";\n\nasync function globalSetup(_config: FullConfig) {\n logger.info(\"Starting global setup...\");\n\n const directories = [\n \"test-results\",\n \"test-results/reports\",\n \"test-results/screenshots\",\n \"test-results/videos\",\n \"test-results/traces\",\n \"test-results/visual-baselines\",\n \"storage-state\",\n \"logs\",\n ];\n\n for (const dir of directories) {\n const dirPath = path.join(process.cwd(), dir);\n if (!fs.existsSync(dirPath)) {\n fs.mkdirSync(dirPath, { recursive: true });\n logger.info(`Created directory: ${dir}`);\n }\n }\n\n const env = environment.getConfig();\n logger.info(\"Environment configuration:\", {\n baseUrl: env.baseUrl,\n environment: env.environment,\n browser: env.browser,\n headless: env.headless,\n aiProvider: env.aiProvider,\n selfHealingEnabled: env.enableSelfHealing,\n });\n\n logger.info(\"Global setup completed\");\n}\n\nexport default globalSetup;\n",
@@ -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 // 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",
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",
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",
@@ -18,7 +18,7 @@ BOILERPLATE: dict[str, str] = {
18
18
  "azure-pipelines.yml": "trigger:\n branches:\n include:\n - main\n - develop\n - 'release/*'\n paths:\n exclude:\n - 'docs/*'\n - '*.md'\n\npr:\n branches:\n include:\n - main\n - develop\n\nvariables:\n NODE_VERSION: '20.x'\n PARALLEL_WORKERS: 4\n\nstages:\n # ── Stage 1: Quality Gates ────────────────────────────────────────────────\n - stage: Quality\n displayName: 'Quality Gates'\n jobs:\n - job: Lint\n displayName: 'Lint & Type Check'\n pool:\n vmImage: 'ubuntu-latest'\n steps:\n - task: NodeTool@0\n inputs:\n versionSpec: $(NODE_VERSION)\n displayName: 'Install Node.js'\n - script: npm ci\n displayName: 'Install dependencies'\n - script: npm run lint\n displayName: 'ESLint'\n - script: npm run typecheck\n displayName: 'TypeScript check'\n\n # ── Stage 2: Smoke Tests ──────────────────────────────────────────────────\n - stage: Smoke\n displayName: 'Smoke Tests'\n dependsOn: Quality\n condition: succeeded()\n jobs:\n - job: SmokeTests\n displayName: 'Run @smoke tests'\n pool:\n vmImage: 'ubuntu-latest'\n timeoutInMinutes: \"30\"\n steps:\n - task: NodeTool@0\n inputs:\n versionSpec: $(NODE_VERSION)\n - script: npm ci\n displayName: 'Install dependencies'\n - script: npx playwright install --with-deps chromium\n displayName: 'Install Playwright browsers'\n - script: cp .env.example .env\n displayName: 'Configure environment'\n - script: npm run test:smoke\n displayName: 'Run smoke tests'\n env:\n BASE_URL: $(BASE_URL)\n AUTH_USERNAME: $(AUTH_USERNAME)\n AUTH_PASSWORD: $(AUTH_PASSWORD)\n AI_API_KEY: $(AI_API_KEY)\n ANTHROPIC_API_KEY: $(ANTHROPIC_API_KEY)\n CI: 'true'\n HEADLESS: 'true'\n - task: PublishTestResults@2\n condition: always()\n inputs:\n testResultsFormat: 'JUnit'\n testResultsFiles: 'test-results/reports/*.xml'\n - task: PublishPipelineArtifact@1\n condition: always()\n inputs:\n targetPath: 'test-results'\n artifact: 'smoke-test-results'\n\n # ── Stage 3: Regression Tests ─────────────────────────────────────────────\n - stage: Regression\n displayName: 'Regression Tests'\n dependsOn: Smoke\n condition: succeeded()\n jobs:\n - job: RegressionTests\n displayName: 'Run @regression tests'\n pool:\n vmImage: 'ubuntu-latest'\n timeoutInMinutes: \"60\"\n steps:\n - task: NodeTool@0\n inputs:\n versionSpec: $(NODE_VERSION)\n - script: npm ci\n - script: npx playwright install --with-deps chromium\n - script: cp .env.example .env\n - script: npm run test:ci\n displayName: 'Run regression tests'\n env:\n BASE_URL: $(BASE_URL)\n AUTH_USERNAME: $(AUTH_USERNAME)\n AUTH_PASSWORD: $(AUTH_PASSWORD)\n AI_API_KEY: $(AI_API_KEY)\n ANTHROPIC_API_KEY: $(ANTHROPIC_API_KEY)\n CI: 'true'\n HEADLESS: 'true'\n PARALLEL_WORKERS: $(PARALLEL_WORKERS)\n - script: npm run healix:apply-ci\n displayName: 'Apply healed locators (CI)'\n condition: always()\n env:\n GH_TOKEN: $(GH_TOKEN)\n HEAL_SEARCH_ROOTS: 'src/'\n - task: PublishPipelineArtifact@1\n condition: always()\n inputs:\n targetPath: 'test-results'\n artifact: 'regression-test-results'\n - task: PublishPipelineArtifact@1\n condition: always()\n inputs:\n targetPath: 'storage-state/healed-locators.json'\n artifact: 'healed-locators'\n",
19
19
  "cucumber.js": "const common = {\n requireModule: ['ts-node/register', 'tsconfig-paths/register'],\n require: ['src/test/steps/**/*.ts'],\n paths: ['src/test/features/**/*.feature'],\n timeout: 60000,\n format: [\n 'progress-bar',\n 'html:test-results/reports/cucumber-report.html',\n 'json:test-results/reports/cucumber-report.json',\n '@cucumber/pretty-formatter',\n ],\n formatOptions: {\n snippetInterface: 'async-await',\n colorsEnabled: true,\n },\n publishQuiet: true,\n};\n\nmodule.exports = {\n default: {\n ...common,\n parallel: 2,\n retry: 1,\n },\n ci: {\n ...common,\n parallel: 4,\n retry: 2,\n retryTagFilter: '@flaky',\n },\n docker: {\n ...common,\n parallel: 4,\n retry: 2,\n },\n debug: {\n ...common,\n parallel: 1,\n retry: 0,\n },\n};\n",
20
20
  "docker-compose.yml": "version: '3.8'\n\nservices:\n qa-tests:\n build:\n context: .\n dockerfile: Dockerfile\n environment:\n - BASE_URL=${BASE_URL:-https://your-app-url.com}\n - AI_PROVIDER=${AI_PROVIDER:-openai}\n - AI_API_KEY=${AI_API_KEY}\n - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}\n - AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT}\n - AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY}\n - AZURE_OPENAI_DEPLOYMENT=${AZURE_OPENAI_DEPLOYMENT}\n - AUTH_USERNAME=${AUTH_USERNAME}\n - AUTH_PASSWORD=${AUTH_PASSWORD}\n - ENABLE_SELF_HEALING=${ENABLE_SELF_HEALING:-true}\n - CI=true\n - HEADLESS=true\n - BROWSER=chromium\n - PARALLEL_WORKERS=4\n volumes:\n - ./test-results:/app/test-results\n - ./storage-state:/app/storage-state\n - ./logs:/app/logs\n",
21
- "package.json": "{\n \"name\": \"{{PROJECT_NAME}}\",\n \"version\": \"1.0.0\",\n \"description\": \"QA automation framework — Playwright + TypeScript + Cucumber BDD with AI-powered self-healing locators\",\n \"main\": \"index.js\",\n \"scripts\": {\n \"test\": \"cucumber-js\",\n \"test:smoke\": \"cucumber-js --tags \\\"@smoke\\\"\",\n \"test:regression\": \"cucumber-js --tags \\\"@regression\\\"\",\n \"test:headed\": \"HEADLESS=false cucumber-js\",\n \"test:headed:smoke\": \"HEADLESS=false cucumber-js --tags \\\"@smoke\\\"\",\n \"test:docker\": \"cucumber-js --profile docker\",\n \"test:ci\": \"cucumber-js --profile ci\",\n \"test:debug\": \"PWDEBUG=1 cucumber-js\",\n \"test:parallel\": \"cucumber-js --parallel 4 --tags \\\"@regression\\\"\",\n \"test:chrome\": \"BROWSER=chromium cucumber-js\",\n \"test:firefox\": \"BROWSER=firefox cucumber-js\",\n \"test:webkit\": \"BROWSER=webkit cucumber-js\",\n \"auth:setup\": \"ts-node src/utils/storage-state/AuthSetup.ts\",\n \"report:open\": \"open test-results/reports/cucumber-report.html\",\n \"docker:build\": \"docker build -f Dockerfile -t qa-framework .\",\n \"docker:run\": \"docker-compose up --abort-on-container-exit\",\n \"docker:down\": \"docker-compose down\",\n \"lint\": \"eslint src --ext .ts\",\n \"lint:fix\": \"eslint src --ext .ts --fix\",\n \"format\": \"prettier --write \\\"src/**/*.ts\\\"\",\n \"format:check\": \"prettier --check \\\"src/**/*.ts\\\"\",\n \"typecheck\": \"tsc --noEmit\",\n \"build\": \"tsc\",\n \"healix:review\": \"ts-node -r tsconfig-paths/register src/utils/locators/review-server.ts\",\n \"healix:apply-ci\": \"ts-node -r tsconfig-paths/register src/utils/locators/healix-ci-apply.ts\",\n \"clean\": \"rimraf dist test-results\",\n \"prepare\": \"husky install\",\n \"pre-commit\": \"lint-staged\"\n },\n \"keywords\": [\n \"playwright\",\n \"typescript\",\n \"cucumber\",\n \"bdd\",\n \"self-healing\",\n \"ai\",\n \"test-automation\",\n \"qa\"\n ],\n \"author\": \"\",\n \"license\": \"MIT\",\n \"dependencies\": {\n \"@anthropic-ai/sdk\": \"^0.71.2\",\n \"@azure/openai\": \"^2.0.0\",\n \"@cucumber/cucumber\": \"^12.5.0\",\n \"@playwright/test\": \"^1.57.0\",\n \"@qa-gentic/agents\": \"^1.1.3\",\n \"axios\": \"^1.6.2\",\n \"dotenv\": \"^16.3.1\",\n \"openai\": \"^4.20.1\",\n \"sharp\": \"^0.33.0\",\n \"winston\": \"^3.11.0\",\n \"zod\": \"^3.22.4\"\n },\n \"devDependencies\": {\n \"@cucumber/html-formatter\": \"^21.0.0\",\n \"@cucumber/pretty-formatter\": \"^1.0.0\",\n \"@types/node\": \"^20.10.5\",\n \"@typescript-eslint/eslint-plugin\": \"^6.15.0\",\n \"@typescript-eslint/parser\": \"^6.15.0\",\n \"eslint\": \"^8.56.0\",\n \"eslint-config-prettier\": \"^9.1.0\",\n \"eslint-plugin-playwright\": \"^0.20.0\",\n \"husky\": \"^8.0.3\",\n \"lint-staged\": \"^15.2.0\",\n \"prettier\": \"^3.1.1\",\n \"rimraf\": \"^5.0.5\",\n \"ts-node\": \"^10.9.2\",\n \"tsconfig-paths\": \"^4.2.0\",\n \"typescript\": \"^5.9.3\"\n },\n \"lint-staged\": {\n \"*.ts\": [\"eslint --fix\", \"prettier --write\"]\n },\n \"engines\": {\n \"node\": \">=18.0.0\",\n \"npm\": \">=9.0.0\"\n }\n}\n",
21
+ "package.json": "{\n \"name\": \"{{PROJECT_NAME}}\",\n \"version\": \"1.0.0\",\n \"description\": \"QA automation framework — Playwright + TypeScript + Cucumber BDD with AI-powered self-healing locators\",\n \"main\": \"index.js\",\n \"scripts\": {\n \"test\": \"cucumber-js\",\n \"test:smoke\": \"cucumber-js --tags \\\"@smoke\\\"\",\n \"test:regression\": \"cucumber-js --tags \\\"@regression\\\"\",\n \"test:headed\": \"HEADLESS=false cucumber-js\",\n \"test:headed:smoke\": \"HEADLESS=false cucumber-js --tags \\\"@smoke\\\"\",\n \"test:docker\": \"cucumber-js --profile docker\",\n \"test:ci\": \"cucumber-js --profile ci\",\n \"test:debug\": \"PWDEBUG=1 cucumber-js\",\n \"test:parallel\": \"cucumber-js --parallel 4 --tags \\\"@regression\\\"\",\n \"test:chrome\": \"BROWSER=chromium cucumber-js\",\n \"test:firefox\": \"BROWSER=firefox cucumber-js\",\n \"test:webkit\": \"BROWSER=webkit cucumber-js\",\n \"auth:setup\": \"ts-node src/utils/storage-state/AuthSetup.ts\",\n \"report:open\": \"open test-results/reports/cucumber-report.html\",\n \"docker:build\": \"docker build -f Dockerfile -t qa-framework .\",\n \"docker:run\": \"docker-compose up --abort-on-container-exit\",\n \"docker:down\": \"docker-compose down\",\n \"lint\": \"eslint src --ext .ts\",\n \"lint:fix\": \"eslint src --ext .ts --fix\",\n \"format\": \"prettier --write \\\"src/**/*.ts\\\"\",\n \"format:check\": \"prettier --check \\\"src/**/*.ts\\\"\",\n \"typecheck\": \"tsc --noEmit\",\n \"build\": \"tsc\",\n \"healix:review\": \"ts-node -r tsconfig-paths/register src/utils/locators/review-server.ts\",\n \"healix:apply-ci\": \"ts-node -r tsconfig-paths/register src/utils/locators/healix-ci-apply.ts\",\n \"clean\": \"rimraf dist test-results\",\n \"prepare\": \"husky install\",\n \"pre-commit\": \"lint-staged\"\n },\n \"keywords\": [\n \"playwright\",\n \"typescript\",\n \"cucumber\",\n \"bdd\",\n \"self-healing\",\n \"ai\",\n \"test-automation\",\n \"qa\"\n ],\n \"author\": \"\",\n \"license\": \"MIT\",\n \"dependencies\": {\n \"@anthropic-ai/sdk\": \"^0.71.2\",\n \"@azure/openai\": \"^2.0.0\",\n \"@cucumber/cucumber\": \"^12.5.0\",\n \"@playwright/test\": \"^1.57.0\",\n \"axios\": \"^1.6.2\",\n \"dotenv\": \"^16.3.1\",\n \"openai\": \"^4.20.1\",\n \"sharp\": \"^0.33.0\",\n \"winston\": \"^3.11.0\",\n \"zod\": \"^3.22.4\"\n },\n \"devDependencies\": {\n \"@cucumber/html-formatter\": \"^21.0.0\",\n \"@cucumber/pretty-formatter\": \"^1.0.0\",\n \"@types/node\": \"^20.10.5\",\n \"@typescript-eslint/eslint-plugin\": \"^6.15.0\",\n \"@typescript-eslint/parser\": \"^6.15.0\",\n \"eslint\": \"^8.56.0\",\n \"eslint-config-prettier\": \"^9.1.0\",\n \"eslint-plugin-playwright\": \"^0.20.0\",\n \"husky\": \"^8.0.3\",\n \"lint-staged\": \"^15.2.0\",\n \"prettier\": \"^3.1.1\",\n \"rimraf\": \"^5.0.5\",\n \"ts-node\": \"^10.9.2\",\n \"tsconfig-paths\": \"^4.2.0\",\n \"typescript\": \"^5.9.3\"\n },\n \"lint-staged\": {\n \"*.ts\": [\"eslint --fix\", \"prettier --write\"]\n },\n \"engines\": {\n \"node\": \">=18.0.0\",\n \"npm\": \">=9.0.0\"\n }\n}\n",
22
22
  "setup.sh": "#!/usr/bin/env bash\n# setup.sh — one-shot project bootstrap\n# Usage: bash setup.sh\n\nset -euo pipefail\n\nCYAN='\\033[0;36m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nRED='\\033[0;31m'\nNC='\\033[0m'\n\ninfo() { echo -e \"${CYAN}→${NC} $*\"; }\nok() { echo -e \"${GREEN}✓${NC} $*\"; }\nwarn() { echo -e \"${YELLOW}⚠${NC} $*\"; }\ndie() { echo -e \"${RED}✗${NC} $*\"; exit 1; }\n\necho \"\"\necho \" QA Framework — Project Setup\"\necho \" ─────────────────────────────\"\necho \"\"\n\n# ── Node.js ──────────────────────────────────────────────────────────────────\ninfo \"Checking Node.js...\"\nnode_version=$(node -v 2>/dev/null || echo \"none\")\nif [[ \"$node_version\" == \"none\" ]]; then\n die \"Node.js not found. Install v18+ from https://nodejs.org\"\nfi\nok \"Node.js $node_version\"\n\n# ── npm install ───────────────────────────────────────────────────────────────\ninfo \"Installing npm dependencies...\"\nnpm ci || npm install\nok \"Dependencies installed\"\n\n# ── Playwright browsers ───────────────────────────────────────────────────────\ninfo \"Installing Playwright browsers...\"\nnpx playwright install chromium --with-deps\nok \"Playwright browsers installed\"\n\n# ── .env ─────────────────────────────────────────────────────────────────────\nif [[ ! -f .env ]]; then\n info \"Creating .env from template...\"\n cp .env.example .env\n ok \".env created — open it and set BASE_URL, AUTH_USERNAME, AUTH_PASSWORD, AI_API_KEY\"\nelse\n warn \".env already exists — skipping\"\nfi\n\n# ── Output directories ────────────────────────────────────────────────────────\ninfo \"Creating output directories...\"\nmkdir -p test-results/reports test-results/screenshots test-results/videos \\\n test-results/traces test-results/visual-baselines storage-state logs\nok \"Output directories ready\"\n\necho \"\"\necho \"${GREEN}Setup complete!${NC}\"\necho \"\"\necho \" Next steps:\"\necho \" 1. Edit .env — set BASE_URL, AUTH_USERNAME, AUTH_PASSWORD, AI_API_KEY\"\necho \" 2. Run smoke tests: npm run test:smoke\"\necho \" 3. Run all tests: npm test\"\necho \" 4. Open HTML report: npm run report:open\"\necho \"\"\n",
23
23
  "src/config/environment.ts": "import { config as dotenvConfig } from \"dotenv\";\n\ndotenvConfig();\n\nexport interface EnvironmentConfig {\n baseUrl: string;\n environment: string;\n authUsername: string;\n authPassword: string;\n storageStatePath: string;\n authTimeout: number;\n aiProvider: \"openai\" | \"azure\" | \"anthropic\" | \"local\";\n aiApiKey: string;\n aiModel: string;\n aiTemperature: number;\n aiMaxTokens: number;\n enableSelfHealing: boolean;\n enableAITestGeneration: boolean;\n azureOpenAIEndpoint?: string;\n azureOpenAIApiKey?: string;\n azureOpenAIDeployment?: string;\n anthropicApiKey?: string;\n localLLMEndpoint?: string;\n headless: boolean;\n browser: \"chromium\" | \"firefox\" | \"webkit\";\n parallelWorkers: number;\n retryAttempts: number;\n timeout: number;\n navigationTimeout: number;\n actionTimeout: number;\n slowMo: number;\n screenshotOnFailure: boolean;\n videoOnFailure: boolean;\n traceOnFailure: boolean;\n reportOutputDir: string;\n fullPageScreenshots: boolean;\n locatorHealAttempts: number;\n locatorTimeout: number;\n enableLocatorVersioning: boolean;\n logLevel: \"error\" | \"warn\" | \"info\" | \"debug\";\n logToFile: boolean;\n logFilePath: string;\n isCI: boolean;\n shardIndex: number;\n shardTotal: number;\n performanceMonitoring: boolean;\n collectMetrics: boolean;\n maskSensitiveData: boolean;\n}\n\nclass Environment {\n private static instance: Environment;\n private config: EnvironmentConfig;\n\n private constructor() {\n this.config = this.loadConfig();\n this.validateConfig();\n }\n\n public static getInstance(): Environment {\n if (!Environment.instance) {\n Environment.instance = new Environment();\n }\n return Environment.instance;\n }\n\n private loadConfig(): EnvironmentConfig {\n return {\n baseUrl: process.env.BASE_URL || \"http://localhost:3000\",\n environment: process.env.ENVIRONMENT || \"local\",\n authUsername: process.env.AUTH_USERNAME || \"\",\n authPassword: process.env.AUTH_PASSWORD || \"\",\n storageStatePath: process.env.STORAGE_STATE_PATH || \"./storage-state/auth.json\",\n authTimeout: parseInt(process.env.AUTH_TIMEOUT || \"30000\"),\n aiProvider: (process.env.AI_PROVIDER as EnvironmentConfig[\"aiProvider\"]) || \"openai\",\n aiApiKey: process.env.AI_API_KEY || \"\",\n aiModel: process.env.AI_MODEL || \"gpt-4o\",\n aiTemperature: parseFloat(process.env.AI_TEMPERATURE || \"0.3\"),\n aiMaxTokens: parseInt(process.env.AI_MAX_TOKENS || \"2000\"),\n enableSelfHealing: process.env.ENABLE_SELF_HEALING !== \"false\",\n enableAITestGeneration: process.env.ENABLE_AI_TEST_GENERATION === \"true\",\n azureOpenAIEndpoint: process.env.AZURE_OPENAI_ENDPOINT,\n azureOpenAIApiKey: process.env.AZURE_OPENAI_API_KEY,\n azureOpenAIDeployment: process.env.AZURE_OPENAI_DEPLOYMENT,\n anthropicApiKey: process.env.ANTHROPIC_API_KEY,\n localLLMEndpoint: process.env.LOCAL_LLM_ENDPOINT,\n headless: process.env.HEADLESS !== \"false\",\n browser: (process.env.BROWSER as EnvironmentConfig[\"browser\"]) || \"chromium\",\n parallelWorkers: parseInt(process.env.PARALLEL_WORKERS || \"2\"),\n retryAttempts: parseInt(process.env.RETRY_ATTEMPTS || \"1\"),\n timeout: parseInt(process.env.TIMEOUT || \"30000\"),\n navigationTimeout: parseInt(process.env.NAVIGATION_TIMEOUT || \"30000\"),\n actionTimeout: parseInt(process.env.ACTION_TIMEOUT || \"10000\"),\n slowMo: parseInt(process.env.SLOW_MO || \"0\"),\n screenshotOnFailure: process.env.SCREENSHOT_ON_FAILURE === \"true\",\n videoOnFailure: process.env.VIDEO_ON_FAILURE === \"true\",\n traceOnFailure: process.env.TRACE_ON_FAILURE === \"true\",\n reportOutputDir: process.env.REPORT_OUTPUT_DIR || \"./test-results/reports\",\n fullPageScreenshots: process.env.FULL_PAGE_SCREENSHOTS === \"true\",\n locatorHealAttempts: parseInt(process.env.LOCATOR_HEAL_ATTEMPTS || \"3\"),\n locatorTimeout: parseInt(process.env.LOCATOR_TIMEOUT || \"10000\"),\n enableLocatorVersioning: process.env.ENABLE_LOCATOR_VERSIONING === \"true\",\n logLevel: (process.env.LOG_LEVEL as EnvironmentConfig[\"logLevel\"]) || \"info\",\n logToFile: process.env.LOG_TO_FILE === \"true\",\n logFilePath: process.env.LOG_FILE_PATH || \"./logs/test-execution.log\",\n isCI: process.env.CI === \"true\",\n shardIndex: parseInt(process.env.SHARD_INDEX || \"1\"),\n shardTotal: parseInt(process.env.SHARD_TOTAL || \"1\"),\n performanceMonitoring: process.env.PERFORMANCE_MONITORING === \"true\",\n collectMetrics: process.env.COLLECT_METRICS === \"true\",\n maskSensitiveData: process.env.MASK_SENSITIVE_DATA === \"true\",\n };\n }\n\n private validateConfig(): void {\n const errors: string[] = [];\n if (!this.config.baseUrl) {\n errors.push(\"BASE_URL is required\");\n }\n if (this.config.enableSelfHealing && !this.config.aiApiKey && !this.config.anthropicApiKey) {\n console.warn(\"[Environment] Warning: ENABLE_SELF_HEALING=true but no AI API key provided. Self-healing will fall back to rule-based strategies.\");\n }\n if (errors.length > 0) {\n throw new Error(`[Environment] Configuration errors:\\n${errors.join(\"\\n\")}`);\n }\n }\n\n public getConfig(): EnvironmentConfig {\n return this.config;\n }\n}\n\nexport const environment = Environment.getInstance();\n",
24
24
  "src/config/global-setup.ts": "import { FullConfig } from \"@playwright/test\";\nimport { logger } from \"@utils/helpers/Logger\";\nimport { environment } from \"./environment\";\nimport * as fs from \"fs\";\nimport * as path from \"path\";\n\nasync function globalSetup(_config: FullConfig) {\n logger.info(\"Starting global setup...\");\n\n const directories = [\n \"test-results\",\n \"test-results/reports\",\n \"test-results/screenshots\",\n \"test-results/videos\",\n \"test-results/traces\",\n \"test-results/visual-baselines\",\n \"storage-state\",\n \"logs\",\n ];\n\n for (const dir of directories) {\n const dirPath = path.join(process.cwd(), dir);\n if (!fs.existsSync(dirPath)) {\n fs.mkdirSync(dirPath, { recursive: true });\n logger.info(`Created directory: ${dir}`);\n }\n }\n\n const env = environment.getConfig();\n logger.info(\"Environment configuration:\", {\n baseUrl: env.baseUrl,\n environment: env.environment,\n browser: env.browser,\n headless: env.headless,\n aiProvider: env.aiProvider,\n selfHealingEnabled: env.enableSelfHealing,\n });\n\n logger.info(\"Global setup completed\");\n}\n\nexport default globalSetup;\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 // 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",
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",
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",