@qa-gentic/stlc-agents 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +310 -0
  2. package/bin/postinstall.js +78 -0
  3. package/bin/qa-stlc.js +89 -0
  4. package/package.json +48 -0
  5. package/skills/qa-stlc/AGENT-BEHAVIOR.md +383 -0
  6. package/skills/qa-stlc/deduplication-protocol.md +303 -0
  7. package/skills/qa-stlc/generate-gherkin.md +550 -0
  8. package/skills/qa-stlc/generate-playwright-code.md +464 -0
  9. package/skills/qa-stlc/generate-test-cases.md +176 -0
  10. package/skills/qa-stlc/write-helix-files.md +374 -0
  11. package/src/boilerplate-bundle.js +66 -0
  12. package/src/cmd-init.js +92 -0
  13. package/src/cmd-mcp-config.js +177 -0
  14. package/src/cmd-scaffold.js +130 -0
  15. package/src/cmd-skills.js +124 -0
  16. package/src/cmd-verify.js +129 -0
  17. package/src/stlc_agents/__init__.py +0 -0
  18. package/src/stlc_agents/agent_gherkin_generator/__init__.py +0 -0
  19. package/src/stlc_agents/agent_gherkin_generator/server.py +502 -0
  20. package/src/stlc_agents/agent_gherkin_generator/tools/__init__.py +0 -0
  21. package/src/stlc_agents/agent_gherkin_generator/tools/ado_gherkin.py +854 -0
  22. package/src/stlc_agents/agent_helix_writer/__init__.py +0 -0
  23. package/src/stlc_agents/agent_helix_writer/server.py +529 -0
  24. package/src/stlc_agents/agent_helix_writer/tools/__init__.py +0 -0
  25. package/src/stlc_agents/agent_helix_writer/tools/boilerplate.py +70 -0
  26. package/src/stlc_agents/agent_helix_writer/tools/helix_write.py +796 -0
  27. package/src/stlc_agents/agent_playwright_generator/__init__.py +0 -0
  28. package/src/stlc_agents/agent_playwright_generator/server.py +2610 -0
  29. package/src/stlc_agents/agent_playwright_generator/tools/__init__.py +0 -0
  30. package/src/stlc_agents/agent_playwright_generator/tools/ado_attach.py +62 -0
  31. package/src/stlc_agents/agent_test_case_manager/__init__.py +0 -0
  32. package/src/stlc_agents/agent_test_case_manager/server.py +483 -0
  33. package/src/stlc_agents/agent_test_case_manager/tools/__init__.py +0 -0
  34. package/src/stlc_agents/agent_test_case_manager/tools/ado_workitem.py +302 -0
  35. package/src/stlc_agents/shared/__init__.py +0 -0
  36. package/src/stlc_agents/shared/auth.py +119 -0
@@ -0,0 +1,70 @@
1
+ """
2
+ boilerplate.py — Embedded Helix-QA framework template files.
3
+
4
+ BOILERPLATE maps framework-relative paths to file content.
5
+ All files come from boilerplate/framework/ — embedded here so the
6
+ package has no runtime dependency on the boilerplate directory.
7
+
8
+ INFRA_FILES is the subset written by write_files_to_helix scaffold_and_tests mode.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ # fmt: off
13
+ BOILERPLATE: dict[str, str] = {
14
+ ".env.example": "# ── Application ──────────────────────────────────────────────────────────────\nBASE_URL=https://your-app-url.com\nENVIRONMENT=local\n\n# ── Authentication ────────────────────────────────────────────────────────────\nAUTH_USERNAME=your-test-user\nAUTH_PASSWORD=your-test-password\nSTORAGE_STATE_PATH=./storage-state/auth.json\nAUTH_TIMEOUT=30000\n\n# ── AI / Self-Healing ─────────────────────────────────────────────────────────\n# Provider: openai | azure | anthropic | local\nAI_PROVIDER=openai\nAI_API_KEY=sk-your-openai-api-key\nAI_MODEL=gpt-4o\nAI_TEMPERATURE=0.3\nAI_MAX_TOKENS=2000\nENABLE_SELF_HEALING=true\nENABLE_AI_TEST_GENERATION=false\n\n# Azure OpenAI (only when AI_PROVIDER=azure)\nAZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/\nAZURE_OPENAI_API_KEY=your-azure-key\nAZURE_OPENAI_DEPLOYMENT=gpt-4o\n\n# Anthropic (only when AI_PROVIDER=anthropic)\nANTHROPIC_API_KEY=sk-ant-your-anthropic-key\n\n# Local LLM (only when AI_PROVIDER=local)\nLOCAL_LLM_ENDPOINT=http://localhost:11434/api\n\n# ── Browser ───────────────────────────────────────────────────────────────────\nHEADLESS=true\nBROWSER=chromium\nSLOW_MO=0\nRECORD_VIDEO=false\n\n# ── Execution ─────────────────────────────────────────────────────────────────\nPARALLEL_WORKERS=2\nRETRY_ATTEMPTS=1\nTIMEOUT=30000\nNAVIGATION_TIMEOUT=30000\nACTION_TIMEOUT=10000\n\n# ── Reporting ─────────────────────────────────────────────────────────────────\nSCREENSHOT_ON_FAILURE=true\nVIDEO_ON_FAILURE=false\nTRACE_ON_FAILURE=true\nREPORT_OUTPUT_DIR=./test-results/reports\nFULL_PAGE_SCREENSHOTS=false\n\n# ── Locator Healing ───────────────────────────────────────────────────────────\nLOCATOR_HEAL_ATTEMPTS=3\nLOCATOR_TIMEOUT=10000\nENABLE_LOCATOR_VERSIONING=false\nHEAL_STORE_PATH=./storage-state/healed-locators.json\nHEALING_DASHBOARD_PORT=7890\nHEALIX_REVIEW_PORT=7891\n\n# ── Logging ───────────────────────────────────────────────────────────────────\nLOG_LEVEL=info\nLOG_TO_FILE=false\nLOG_FILE_PATH=./logs/test-execution.log\n\n# ── CI/CD ─────────────────────────────────────────────────────────────────────\nCI=false\nSHARD_INDEX=1\nSHARD_TOTAL=1\n\n# ── Performance ───────────────────────────────────────────────────────────────\nPERFORMANCE_MONITORING=false\nCOLLECT_METRICS=false\n\n# ── Security ─────────────────────────────────────────────────────────────────\nMASK_SENSITIVE_DATA=true\n",
15
+ ".gitignore": "node_modules/\ndist/\n.env\n*.log\ntest-results/screenshots/\ntest-results/videos/\ntest-results/traces/\ntest-results/visual-baselines/\ntest-results/reports/\nlogs/\nstorage-state/auth.json\nstorage-state/healed-locators.json\n.DS_Store\n*.js.map\n",
16
+ "Dockerfile": "FROM mcr.microsoft.com/playwright:v1.57.0-jammy\n\nWORKDIR /app\n\nCOPY package*.json ./\nRUN npm ci\n\n# Install Playwright browsers\nRUN npx playwright install --with-deps chromium\n\nCOPY . .\n\nENV CI=true\nENV HEADLESS=true\nENV BROWSER=chromium\nENV PARALLEL_WORKERS=4\n\nCMD [\"npm\", \"run\", \"test:ci\"]\n",
17
+ "README.md": "# QA Automation Framework Boilerplate\n\nA production-ready, zero-config **Playwright + Cucumber + TypeScript** test automation framework with **three-layer AI self-healing** for locators. Copy once, customise to your app.\n\n---\n\n## Features\n\n| Capability | Detail |\n|---|---|\n| **BDD / Gherkin** | Cucumber 12 with tagged scenario filtering |\n| **Parallel execution** | 2 workers (local), 4 workers (CI) |\n| **Three-layer self-healing** | LocatorHealer → TimingHealer → VisualIntentChecker |\n| **5 AI providers** | OpenAI, Anthropic/Claude, Grok, Ollama, LM Studio |\n| **Healing dashboard** | Live browser UI at port 7890 during test runs |\n| **Review server** | Post-run review + PR creation at port 7891 |\n| **CI/CD integration** | Azure Pipelines + GitHub Actions; `healix:apply-ci` script |\n| **Docker** | `docker-compose.yml` for reproducible runs |\n| **Auth caching** | Browser storage-state login once, reuse across all tests |\n| **TypeScript** | Strict mode, ES2022, full path aliases |\n\n---\n\n## Quick Start\n\n```bash\n# 1. Install dependencies\nnpm install\nnpx playwright install chromium\n\n# 2. Configure your environment\ncp .env.example .env\n# Edit .env — set BASE_URL and AI_API_KEY at minimum\n\n# 3. Run the example test\nnpm test\n\n# 4. View the HTML report\nopen test-results/reports/cucumber-report.html\n```\n\n---\n\n## Directory Structure\n\n```\nsrc/\n config/ # Environment, global setup/teardown\n hooks/ # Cucumber BeforeAll/Before/After/AfterAll\n pages/ # BasePage (all healing APIs wired here)\n locators/ # base.locators.ts — common selectors\n test/\n features/ # Your .feature files (Gherkin scenarios)\n steps/ # Cucumber step definitions\n templates/ # Copy these to create new pages rapidly\n utils/\n helpers/ # Logger, RetryHandler, WaitHelper\n locators/ # Full self-healing infrastructure\n ai-assistant/ # AILocatorGenerator, AISelfHealing, AITestGenerator\n storage-state/ # AuthManager, AuthSetup\nstorage-state/ # Saved browser auth state (git-ignored)\ntest-results/ # Reports, screenshots, traces, videos\nlogs/ # winston log files\n```\n\n---\n\n## Creating a New Page\n\n```bash\n# 1. Copy templates\ncp src/templates/_template.feature src/test/features/my-page.feature\ncp src/templates/_template.locators.ts src/locators/my-page.locators.ts\ncp src/templates/_template.steps.ts src/test/steps/my-page.steps.ts\ncp src/templates/_TemplatePage.ts src/pages/MyPage.ts\n\n# 2. Rename \"Template\" → your page name in each file\n# 3. Update locators to match your page elements\n# 4. Write your Gherkin scenarios\n# 5. npm test -- --tags @my-tag\n```\n\n---\n\n## Self-Healing Architecture\n\n### Layer 1 — LocatorHealer\nWhen a locator fails, automatically tries:\n1. Primary selector (from locators file)\n2. `getByRole` inferred from `intent` string\n3. `getByLabel` / `getByPlaceholder`\n4. `getByText`\n5. AI Vision (page screenshot → AI identifies element)\n6. CDPSession Accessibility Tree fallback\n\n### Layer 2 — TimingHealer\nAdaptive EMA-based network-idle timeouts per labelled action. Learns expected network patterns per step over time, adjusting automatically as your app's performance changes.\n\n### Layer 3 — VisualIntentChecker\nScreenshot-based element identity verification. For locators with `visualIntent: true`, captures element screenshots and diffs against baselines to detect visual regressions.\n\n---\n\n## AI Provider Configuration\n\nSet `AI_PROVIDER` in `.env`:\n\n| Provider | `AI_PROVIDER` value | Notes |\n|---|---|---|\n| OpenAI (gpt-4o) | `openai` | `AI_API_KEY` = OpenAI key |\n| Anthropic Claude | `anthropic` or `claude` | `AI_API_KEY` = Anthropic key |\n| Grok | `grok` | `AI_API_KEY` = xAI key |\n| Ollama (local) | `ollama` | `AI_BASE_URL=http://localhost:11434/v1` |\n| LM Studio | `local` | `AI_BASE_URL=http://localhost:1234/v1` |\n\nSelf-healing still works without an AI key — Layers 2 and 3 are purely algorithmic.\n\n---\n\n## Healing Dashboard\n\nStart a test run. The dashboard auto-opens at **http://localhost:7890** (or set `HEALING_DASHBOARD_PORT`).\n\n- **Live view**: watch heals in real time\n- **Summary**: counts by strategy\n- **Registry**: all healed locators + approval status\n\n### Post-run Review\n\n```bash\nnpm run healix:review\n# Opens review server at http://localhost:7891\n# Approve / reject individual heals, then click \"Apply + Create PR\"\n```\n\n### CI Auto-Apply\n\n```bash\nHEALIX_CI_AUTO_APPROVE=true npm run healix:apply-ci\n```\n\nApplies all heals to source files, pushes a branch, and opens a PR automatically (GitHub Actions or Azure Pipelines).\n\n---\n\n## Authentication Setup\n\nIf your app requires login:\n\n```bash\nnpm run auth:setup\n```\n\nThen set `USE_STORAGE_STATE=true` in `.env`. All subsequent scenarios reuse the saved session.\n\n---\n\n## Scripts Reference\n\n| Script | Purpose |\n|---|---|\n| `npm test` | Run all tests (parallel) |\n| `npm run test:smoke` | `@smoke` tag only |\n| `npm run test:regression` | `@regression` tag only |\n| `npm run test:headed` | Headed browser (debug) |\n| `npm run test:ci` | CI profile (4 workers, no retry) |\n| `npm run auth:setup` | Save browser auth state |\n| `npm run healix:review` | Start post-run review server |\n| `npm run healix:apply-ci` | CI headless heal applicator |\n| `npm run typecheck` | TypeScript type-check only |\n| `npm run lint` | ESLint check |\n| `npm run docker:up` | Run tests in Docker |\n\n---\n\n## Environment Variables\n\nSee [.env.example](.env.example) for the full reference.\n\n| Variable | Default | Description |\n|---|---|---|\n| `BASE_URL` | `https://example.com` | Application under test |\n| `BROWSER` | `chromium` | `chromium` / `firefox` / `webkit` |\n| `HEADLESS` | `true` | Headless mode |\n| `AI_PROVIDER` | `openai` | AI provider for self-healing |\n| `AI_API_KEY` | — | API key for chosen provider |\n| `HEALING_DASHBOARD_PORT` | `7890` | Live healing dashboard |\n| `HEALIX_REVIEW_PORT` | `7891` | Post-run review server |\n\n---\n\n## Locator Stability Guide\n\n| Stability | Selector type | Use when |\n|---|---|---|\n| 100 | `[data-test=\"x\"]` or `[data-testid=\"x\"]` | Ask devs to add test IDs |\n| 90 | `getByRole` with accessible name | Element has ARIA role |\n| 80 | `#id` | Stable, unique ID exists |\n| 70 | `aria-label` or `placeholder` | Form inputs |\n| 60 | CSS class | Last resort — breaks on redesign |\n\n---\n\n## Contributing\n\n1. Fork this repo\n2. Create a feature branch\n3. Add a failing test + implementation\n4. Submit a PR — Healix will self-heal any locator drift automatically\n",
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
+ "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
+ "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",
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
+ "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
+ "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",
25
+ "src/config/global-teardown.ts": "import { FullConfig } from \"@playwright/test\";\nimport { logger } from \"@utils/helpers/Logger\";\n\nasync function globalTeardown(_config: FullConfig) {\n logger.info(\"Starting global teardown...\");\n logger.info(\"Global teardown completed\");\n}\n\nexport default globalTeardown;\n",
26
+ "src/hooks/hooks.ts": "import { Before, After, BeforeAll, AfterAll, setDefaultTimeout, Status } from '@cucumber/cucumber';\nimport { chromium, Browser, BrowserContext, Page } from '@playwright/test';\nimport { PageFixture, setFixture } from './pageFixture';\nimport { LocatorRepository } from '@utils/locators/LocatorRepository';\nimport { LocatorHealer } from '@utils/locators/LocatorHealer';\nimport { HealingDashboard } from '@utils/locators/HealingDashboard';\nimport * as path from 'path';\nimport * as fs from 'fs';\n\nlet browser: Browser;\n\nsetDefaultTimeout(60 * 1_000);\n\nBeforeAll(async function () {\n browser = await chromium.launch({\n headless: process.env.HEADLESS !== 'false',\n slowMo: process.env.SLOW_MO ? parseInt(process.env.SLOW_MO) : 0,\n });\n\n for (const dir of [\n path.resolve('test-results', 'screenshots'),\n path.resolve('test-results', 'traces'),\n path.resolve('test-results', 'videos'),\n path.resolve('test-results', 'reports'),\n path.resolve('storage-state'),\n ]) {\n fs.mkdirSync(dir, { recursive: true });\n }\n\n await HealingDashboard.getInstance().start();\n});\n\nAfterAll(async function () {\n await browser?.close();\n await HealingDashboard.getInstance().stop();\n\n const store = path.resolve('storage-state', 'healed-locators.json');\n if (fs.existsSync(store)) {\n const heals = Object.keys(JSON.parse(fs.readFileSync(store, 'utf8') || '{}'));\n if (heals.length > 0) {\n console.log('');\n console.log(' ╔══════════════════════════════════════════════════════════════╗');\n console.log(' ║ 🩺 SELF-HEALING SUMMARY ║');\n console.log(` ║ ${heals.length} locator(s) were healed during this run`.padEnd(66) + '║');\n console.log(' ║ ║');\n console.log(' ║ Review & apply heals: npm run healix:review ║');\n console.log(' ║ Heal store: storage-state/healed-locators.json ║');\n console.log(' ╚══════════════════════════════════════════════════════════════╝');\n console.log('');\n }\n }\n});\n\nBefore(async function (this: PageFixture, scenario) {\n const context: BrowserContext = await browser.newContext({\n viewport: { width: 1440, height: 900 },\n recordVideo: process.env.RECORD_VIDEO === 'true'\n ? { dir: path.resolve('test-results', 'videos') }\n : undefined,\n ignoreHTTPSErrors: true,\n });\n\n await context.tracing.start({ screenshots: true, snapshots: true, sources: true });\n\n const page: Page = await context.newPage();\n\n const logger = {\n info: (msg: string) => { console.log(` ℹ ${msg}`); },\n warn: (msg: string) => { console.warn(` ⚠ ${msg}`); },\n error: (msg: string) => { console.error(` ✖ ${msg}`); },\n };\n\n const locatorRepository = new LocatorRepository();\n const locatorHealer = new LocatorHealer(page, logger, locatorRepository);\n locatorHealer.currentScenario = scenario.pickle.name;\n\n this.browser = browser;\n this.context = context;\n this.page = page;\n this.logger = logger;\n this.locatorRepository = locatorRepository;\n this.locatorHealer = locatorHealer;\n\n setFixture(this);\n\n logger.info(`[hooks] Before: \"${scenario.pickle.name}\"`);\n});\n\nAfter(async function (this: PageFixture, scenario) {\n const failed = scenario.result?.status === Status.FAILED;\n const scenarioName = scenario.pickle.name.replace(/[^a-z0-9]/gi, '_').toLowerCase();\n const timestamp = Date.now();\n\n if (failed) {\n try {\n const screenshotPath = path.resolve(\n 'test-results', 'screenshots', `${scenarioName}-${timestamp}.png`,\n );\n const screenshot = await this.page.screenshot({ fullPage: true, path: screenshotPath });\n await this.attach(screenshot, 'image/png');\n } catch (err) {\n this.logger.warn(`[hooks] Could not capture screenshot: ${String(err)}`);\n }\n\n try {\n const tracePath = path.resolve(\n 'test-results', 'traces', `${scenarioName}-${timestamp}.zip`,\n );\n await this.context.tracing.stop({ path: tracePath });\n } catch (err) {\n this.logger.warn(`[hooks] Could not save trace: ${String(err)}`);\n }\n\n try {\n const html = await this.page.content();\n await this.attach(`<!-- ${scenario.pickle.name} -->\\n${html}`, 'text/html');\n } catch { /* non-fatal */ }\n } else {\n try {\n await this.context.tracing.stop();\n } catch { /* non-fatal */ }\n }\n\n if (this.locatorRepository) {\n const summary = this.locatorRepository.summary();\n this.logger.info(\n `[hooks] LocatorRepository: ${summary.total} total, ${summary.healed} healed (${summary.healRate})`,\n );\n }\n\n await this.context?.close();\n\n this.logger.info(`[hooks] After: \"${scenario.pickle.name}\" — ${scenario.result?.status}`);\n});\n",
27
+ "src/hooks/pageFixture.ts": "import { Page, Browser, BrowserContext } from '@playwright/test';\nimport { IWorldOptions, World, setWorldConstructor } from '@cucumber/cucumber';\nimport { LocatorRepository } from '@utils/locators/LocatorRepository';\nimport { LocatorHealer } from '@utils/locators/LocatorHealer';\n\nexport interface HealixLogger {\n info(msg: string): void;\n warn(msg: string): void;\n error(msg: string): void;\n}\n\nexport interface FixtureContext {\n page: Page;\n browser: Browser;\n context: BrowserContext;\n logger: HealixLogger;\n locatorRepository: LocatorRepository;\n locatorHealer: LocatorHealer;\n}\n\nclass PageFixture extends World {\n page!: Page;\n browser!: Browser;\n context!: BrowserContext;\n locatorRepository!: LocatorRepository;\n locatorHealer!: LocatorHealer;\n logger!: HealixLogger;\n\n constructor(options: IWorldOptions) {\n super(options);\n }\n}\n\nlet _current: PageFixture | null = null;\n\nexport function setFixture(world: PageFixture): void {\n _current = world;\n}\n\nexport function fixture(): PageFixture {\n if (!_current) {\n throw new Error(\n '[PageFixture] fixture() called before Before hook initialised it. ' +\n 'Ensure hooks.ts is included in your cucumber require paths.',\n );\n }\n return _current;\n}\n\nsetWorldConstructor(PageFixture);\nexport { PageFixture };\n",
28
+ "src/locators/base.locators.ts": "/**\n * Base locators — shared across all pages.\n * Import specific locators from the locator file for each page.\n */\nexport const BASE_LOCATORS = {\n HAMBURGER_MENU: '[data-test=\"menu-button\"]',\n LOGOUT_LINK: '[data-test=\"logout-sidebar-link\"]',\n ERROR_MESSAGE: '[data-test=\"error-message\"]',\n SUCCESS_MESSAGE: '[data-test=\"success-message\"]',\n LOADING_SPINNER: '[data-test=\"loading-spinner\"]',\n SUBMIT_BUTTON: 'button[type=\"submit\"]',\n CANCEL_BUTTON: 'button:has-text(\"Cancel\")',\n INPUT_FIELD: 'input[type=\"text\"]',\n PASSWORD_INPUT: 'input[type=\"password\"]',\n EMAIL_INPUT: 'input[type=\"email\"]',\n} as const;\n\nexport type BaseLocatorKey = keyof typeof BASE_LOCATORS;\n",
29
+ "src/pages/BasePage.ts": "import { Page } from \"@playwright/test\";\nimport { WaitHelper } from \"@utils/helpers/WaitHelper\";\nimport { LocatorStrategy } from \"@utils/locators/LocatorStrategy\";\nimport { LocatorHealer } from \"@utils/locators/LocatorHealer\";\nimport { LocatorRepository } from \"@utils/locators/LocatorRepository\";\nimport { VisualIntentChecker } from \"@utils/locators/VisualIntentChecker\";\nimport { TimingHealer } from \"@utils/locators/TimingHealer\";\nimport { fixture } from \"@hooks/pageFixture\";\nimport { environment } from \"../config/environment\";\n\n/**\n * BasePage — Three-Layer Self-Healing Page Object Base Class\n *\n * All page objects MUST extend this class.\n *\n * HOW TO CREATE A NEW PAGE OBJECT:\n * 1. Copy src/templates/_template.locators.ts → src/locators/my-page.locators.ts\n * 2. Copy src/templates/_TemplatePage.ts → src/pages/MyPage.ts\n * 3. Copy src/templates/_template.steps.ts → src/test/steps/my-page.steps.ts\n * 4. Copy src/templates/_template.feature → src/test/features/my-page.feature\n *\n * SELF-HEALING LAYERS:\n * Layer 1 — LocatorHealer: selector → role → label → text → AI Vision\n * Layer 2 — TimingHealer: adaptive network-idle timeouts per action\n * Layer 3 — VisualIntentChecker: element screenshot diff against baseline\n *\n * LOCATOR STABILITY GUIDE:\n * 100 = data-test / data-testid attribute\n * 90 = aria-role + accessible name\n * 80 = id attribute\n * 70 = aria-label\n * 60 = placeholder text\n * 50 = CSS class (unstable — avoid)\n *\n * RULE: All page interactions MUST go through this.healer — never raw page.click/fill.\n */\nexport abstract class BasePage {\n protected waitHelper: WaitHelper;\n protected locatorStrategy: LocatorStrategy;\n protected healer: LocatorHealer;\n protected visual: VisualIntentChecker;\n protected timing: TimingHealer;\n protected repo: LocatorRepository;\n protected env = environment.getConfig();\n protected logger = fixture().logger;\n\n constructor(protected page: Page) {\n this.waitHelper = new WaitHelper(page);\n this.locatorStrategy = new LocatorStrategy(page);\n this.repo = fixture().locatorRepository ?? new LocatorRepository();\n this.healer = new LocatorHealer(page, this.logger, this.repo);\n this.visual = new VisualIntentChecker(page, this.logger);\n this.timing = new TimingHealer(page, this.logger);\n }\n\n async goto(url: string): Promise<void> {\n await this.page.goto(url, {\n timeout: this.env.navigationTimeout,\n waitUntil: \"domcontentloaded\",\n });\n await this.waitForPageLoad();\n }\n\n async waitForPageLoad(): Promise<void> {\n await this.waitHelper.waitForPageLoad();\n }\n\n async getTitle(): Promise<string> {\n return await this.page.title();\n }\n\n getUrl(): string {\n return this.page.url();\n }\n\n async reload(): Promise<void> {\n await this.page.reload({\n timeout: this.env.navigationTimeout,\n waitUntil: \"domcontentloaded\",\n });\n await this.waitForPageLoad();\n }\n\n async goBack(): Promise<void> {\n await this.page.goBack({\n timeout: this.env.navigationTimeout,\n waitUntil: \"domcontentloaded\",\n });\n }\n\n async goForward(): Promise<void> {\n await this.page.goForward({\n timeout: this.env.navigationTimeout,\n waitUntil: \"domcontentloaded\",\n });\n }\n\n async takeScreenshot(name: string): Promise<Buffer> {\n return await this.page.screenshot({\n path: `test-results/screenshots/${name}-${Date.now()}.png`,\n fullPage: this.env.fullPageScreenshots,\n });\n }\n\n async scrollToBottom(): Promise<void> {\n await this.page.evaluate(() =>\n window.scrollTo(0, document.body.scrollHeight),\n );\n }\n\n async scrollToTop(): Promise<void> {\n await this.page.evaluate(() => window.scrollTo(0, 0));\n }\n\n async waitForURL(urlPattern: string | RegExp, timeout?: number): Promise<void> {\n await this.page.waitForURL(urlPattern, {\n timeout: timeout ?? this.env.navigationTimeout,\n });\n }\n}\n",
30
+ "src/templates/_TemplatePage.ts": "/**\n * TEMPLATE PAGE OBJECT — Copy this file to create a new page object.\n *\n * STEP 1: Copy to src/pages/MyPage.ts\n * STEP 2: Replace all \"Template\" / \"template\" with your page name\n * STEP 3: Copy src/templates/_template.locators.ts → src/locators/my-page.locators.ts\n * and update locators to match your page's elements\n * STEP 4: Add page-specific methods following the pattern below\n * STEP 5: Create src/test/steps/my-page.steps.ts (copy _template.steps.ts)\n * STEP 6: Create src/test/features/my-page.feature (copy _template.feature)\n *\n * ─── THREE-LAYER SELF-HEALING ────────────────────────────────────────────────\n * Layer 1 — LocatorHealer: selector → role → label → text → AI Vision\n * Layer 2 — TimingHealer: adaptive network-idle timeouts per label\n * Layer 3 — VisualIntentChecker: element screenshot diff against baseline\n *\n * ─── RULES ───────────────────────────────────────────────────────────────────\n * ✅ Always use this.healer.clickWithHealing / fillWithHealing / assertVisibleWithHealing\n * ✅ Always call this.timing.waitForNetworkIdle after navigation or form submit\n * ✅ Always call this.visual.check for visualIntent=true locators at assertions\n * ❌ Never call this.page.click / this.page.fill / this.page.locator directly\n */\n\nimport { Page, expect } from '@playwright/test';\nimport { fixture } from '@hooks/pageFixture';\nimport { TemplateLocators } from '../templates/_template.locators';\nimport { BasePage } from './BasePage';\n\nexport default class TemplatePage extends BasePage {\n private readonly loc = TemplateLocators;\n\n constructor(page?: Page) {\n super(page ?? fixture().page);\n // Register all locators so LocatorHealer can track and heal them\n Object.entries(this.loc).forEach(([key, val]) =>\n this.repo.register(key, val.selector, val.intent));\n }\n\n // ── Navigation ─────────────────────────────────────────────────────────────\n\n async navigate(url: string): Promise<void> {\n this.logger.info(`Navigating to ${url}`);\n await this.page.goto(url, { waitUntil: 'networkidle' });\n await this.timing.waitForNetworkIdle('navigate');\n }\n\n async waitForPageLoad(): Promise<void> {\n await this.healer.assertVisibleWithHealing(\n 'pageContainer', this.loc.pageContainer.selector, this.loc.pageContainer.intent);\n }\n\n // ── Actions ────────────────────────────────────────────────────────────────\n\n async clickPrimaryButton(): Promise<void> {\n await this.healer.clickWithHealing(\n 'primaryButton', this.loc.primaryButton.selector, this.loc.primaryButton.intent);\n await this.timing.waitForNetworkIdle('clickPrimaryButton');\n }\n\n async submitForm(): Promise<void> {\n await this.healer.clickWithHealing(\n 'submitButton', this.loc.submitButton.selector, this.loc.submitButton.intent);\n await this.timing.waitForNetworkIdle('submitForm');\n }\n\n async clickCancel(): Promise<void> {\n await this.healer.clickWithHealing(\n 'cancelButton', this.loc.cancelButton.selector, this.loc.cancelButton.intent);\n }\n\n async fillPrimaryInput(value: string): Promise<void> {\n await this.healer.fillWithHealing(\n 'primaryInput', this.loc.primaryInput.selector, value, this.loc.primaryInput.intent);\n }\n\n // ── Assertions ─────────────────────────────────────────────────────────────\n\n async verifySuccessMessage(): Promise<void> {\n await this.healer.assertVisibleWithHealing(\n 'successMessage', this.loc.successMessage.selector, this.loc.successMessage.intent);\n await this.visual.check(\n 'successMessage', this.loc.successMessage.selector, this.loc.successMessage.intent);\n this.logger.info('✓ Success message verified');\n }\n\n async verifyErrorMessage(): Promise<void> {\n await this.healer.assertVisibleWithHealing(\n 'errorMessage', this.loc.errorMessage.selector, this.loc.errorMessage.intent);\n this.logger.info('✓ Error message verified');\n }\n\n async getErrorMessageText(): Promise<string> {\n return (await this.page.locator(this.loc.errorMessage.selector).textContent()) ?? '';\n }\n\n async verifyErrorMessageContains(text: string): Promise<void> {\n const actual = await this.getErrorMessageText();\n expect(actual).toContain(text);\n this.logger.info(`✓ Error message contains: \"${text}\"`);\n }\n\n async verifyPageLoaded(): Promise<void> {\n await this.healer.assertVisibleWithHealing(\n 'pageContainer', this.loc.pageContainer.selector, this.loc.pageContainer.intent);\n this.logger.info('✓ Page loaded');\n }\n}\n",
31
+ "src/templates/_template.feature": "# Feature: Template Feature\n# Copy this file to src/test/features/my-feature.feature\n# Replace all scenario titles, steps and tags with your use case.\n#\n# ─── GHERKIN RULES ──────────────────────────────────────────────────────────\n# ✅ Always quote values passed to {string} step params:\n# ✅ When I fill in the primary input with \"hello\"\n# ❌ When I fill in the primary input with hello\n# ✅ Use @smoke for critical path tests run on every deploy\n# ✅ Use @regression for full test suite runs\n# ✅ Use @flaky to enable auto-retry for known-flaky tests\n# ✅ One Feature per .feature file\n# ✅ Use Background for shared setup steps\n\nFeature: Template Feature\n As a user\n I want to interact with the application\n So that I can complete my goals\n\n Background:\n Given I navigate to \"https://your-app-url.com\"\n\n @smoke @regression\n Scenario: Happy path — primary action succeeds\n When I fill in the primary input with \"valid input\"\n And I click the primary button\n Then I should see a success message\n\n @regression\n Scenario: Validation error on empty input\n When I submit the form\n Then I should see an error message\n\n @regression\n Scenario: Validation error shows correct message\n When I submit the form\n Then I should see error message \"This field is required\"\n\n @regression\n Scenario: Cancel navigates back\n When I click cancel\n Then the current URL should contain \"/home\"\n",
32
+ "src/templates/_template.locators.ts": "/**\n * TEMPLATE LOCATORS — Copy this file to create locators for a new page.\n *\n * STEP 1: Rename this file, e.g. src/locators/dashboard.locators.ts\n * STEP 2: Rename the exported const, e.g. DashboardLocators\n * STEP 3: Define your locators using the pattern below\n * STEP 4: Import in your page object: import { DashboardLocators } from '@locators/dashboard.locators'\n *\n * ─── STABILITY GUIDE ────────────────────────────────────────────────────────\n * 100 [data-test=\"x\"] or [data-testid=\"x\"] ← always prefer; ask devs to add\n * 90 getByRole with accessible name (aria-role + name)\n * 80 #id attribute\n * 70 aria-label or placeholder text\n * 60 CSS class (avoid — breaks on UI redesigns)\n *\n * ─── LOCATOR SCHEMA ─────────────────────────────────────────────────────────\n * {\n * selector: CSS/XPath string used by Playwright locator()\n * intent: Human-readable description — used by AI Vision healing fallback\n * stability: 0–100 score (see guide above)\n * visualIntent: true → VisualIntentChecker screenshots this element on assertion\n * }\n *\n * ─── HOW SELF-HEALING USES THESE ────────────────────────────────────────────\n * LocatorHealer tries in order:\n * 1. selector (primary, from this file)\n * 2. role-based getByRole inferred from intent\n * 3. label-based getByLabel / getByPlaceholder\n * 4. text-based getByText\n * 5. AI Vision: identifies element using intent + full-page screenshot\n *\n * ─── INTENT STRING RULES ────────────────────────────────────────────────────\n * The intent string is parsed by LocatorHealer to infer role + accessible name.\n * TWO supported patterns:\n *\n * Pattern A — Role-noun (PREFERRED): \"{accessible-name} {role-keyword}\"\n * ✅ 'Username textbox' → getByRole('textbox', {name: 'Username'})\n * ✅ 'Login button' → getByRole('button', {name: 'Login'})\n * ✅ 'Remember me checkbox' → getByRole('checkbox', {name: 'Remember me'})\n *\n * Pattern B — Action-verb (supported):\n * ✅ 'Enter username for login' → getByRole('textbox', {name: 'username'})\n * ✅ 'Click login button' → getByRole('button', {name: 'login'})\n *\n * ❌ AVOID: ending intent with a visible button label when describing an input:\n * BAD: 'Enter username for login' → text healer may match Login button\n * GOOD: 'Username textbox' → role healer finds correct field immediately\n *\n * ─── NAMING CONVENTION ──────────────────────────────────────────────────────\n * File: src/locators/<page-name>.locators.ts\n * Export: <PageName>Locators\n * Key: camelCase verb+noun, e.g. submitButton, usernameInput\n */\n\nexport const TemplateLocators = {\n // ── Page container ──────────────────────────────────────────────────────\n pageContainer: {\n selector: '[data-test=\"page-container\"]',\n intent: 'Main page container',\n stability: 100,\n },\n\n // ── Primary actions ─────────────────────────────────────────────────────\n primaryButton: {\n selector: '[data-test=\"primary-button\"]',\n intent: 'Primary action button',\n stability: 100,\n visualIntent: true,\n },\n\n submitButton: {\n selector: '[data-test=\"submit-button\"]',\n intent: 'Submit form button',\n stability: 100,\n visualIntent: true,\n },\n\n cancelButton: {\n selector: '[data-test=\"cancel-button\"]',\n intent: 'Cancel or close button',\n stability: 100,\n },\n\n // ── Form inputs ─────────────────────────────────────────────────────────\n primaryInput: {\n selector: '[data-test=\"primary-input\"]',\n intent: 'Primary text input field',\n stability: 100,\n },\n\n // ── Feedback ──────────────────────────────────────────────────────────────\n successMessage: {\n selector: '[data-test=\"success-message\"]',\n intent: 'Success confirmation message',\n stability: 100,\n visualIntent: true,\n },\n\n errorMessage: {\n selector: '[data-test=\"error-message\"]',\n intent: 'Error or validation message',\n stability: 100,\n visualIntent: true,\n },\n\n loadingSpinner: {\n selector: '[data-test=\"loading-spinner\"]',\n intent: 'Loading spinner indicator',\n stability: 100,\n },\n} as const;\n\nexport type TemplateLocatorKey = keyof typeof TemplateLocators;\n",
33
+ "src/templates/_template.steps.ts": "/**\n * TEMPLATE STEP DEFINITIONS — Copy this file to create steps for a new page.\n *\n * STEP 1: Copy to src/test/steps/my-page.steps.ts\n * STEP 2: Replace \"Template\" / \"template\" with your page name\n * STEP 3: Import your page class and add steps matching your .feature file\n *\n * ─── CRITICAL RULES ──────────────────────────────────────────────────────────\n * ✅ ALWAYS import fixture() from '@hooks/pageFixture'\n * ✅ ALWAYS use fixture().logger.info/warn/error (not console.log)\n * → logs appear in the Cucumber HTML report against each step\n * ✅ ALWAYS instantiate page objects INSIDE steps (not at module level)\n * → ensures each scenario gets a fresh instance from the current fixture\n * ✅ ALWAYS use quoted strings for {string} params in .feature files:\n * ✅ And I should see \"Welcome\"\n * ❌ And I should see Welcome\n * ✅ Keep one step file per feature file for maintainability\n *\n * ─── PARAMETER TYPES ─────────────────────────────────────────────────────────\n * {string} → matches \"quoted text\" in .feature\n * {int} → matches integer numbers\n * {float} → matches decimal numbers\n * {word} → matches a single unquoted word\n */\n\nimport { Given, When, Then } from '@cucumber/cucumber';\nimport { expect } from '@playwright/test';\nimport { fixture } from '@hooks/pageFixture';\nimport TemplatePage from '@pages/_TemplatePage';\n\nlet templatePage: TemplatePage;\n\n// ── Navigation ───────────────────────────────────────────────────────────────\n\nGiven('I navigate to {string}', async function (url: string): Promise<void> {\n templatePage = new TemplatePage();\n await templatePage.navigate(url);\n fixture().logger.info(`Navigated to ${url}`);\n});\n\n// ── Actions ──────────────────────────────────────────────────────────────────\n\nWhen('I fill in the primary input with {string}', async function (value: string): Promise<void> {\n await templatePage.fillPrimaryInput(value);\n fixture().logger.info(`Filled in: \"${value}\"`);\n});\n\nWhen('I click the primary button', async function (): Promise<void> {\n await templatePage.clickPrimaryButton();\n fixture().logger.info('Clicked primary button');\n});\n\nWhen('I submit the form', async function (): Promise<void> {\n await templatePage.submitForm();\n fixture().logger.info('Submitted form');\n});\n\nWhen('I click cancel', async function (): Promise<void> {\n await templatePage.clickCancel();\n fixture().logger.info('Clicked cancel');\n});\n\n// ── Assertions ───────────────────────────────────────────────────────────────\n\nThen('I should see the page loaded', async function (): Promise<void> {\n await templatePage.verifyPageLoaded();\n});\n\nThen('I should see a success message', async function (): Promise<void> {\n await templatePage.verifySuccessMessage();\n});\n\nThen('I should see an error message', async function (): Promise<void> {\n await templatePage.verifyErrorMessage();\n});\n\nThen('I should see error message {string}', async function (expectedText: string): Promise<void> {\n await templatePage.verifyErrorMessageContains(expectedText);\n});\n\nThen('the current URL should contain {string}', async function (urlPattern: string): Promise<void> {\n const currentUrl = fixture().page.url();\n expect(currentUrl).toContain(urlPattern);\n fixture().logger.info(`URL \"${currentUrl}\" contains \"${urlPattern}\"`);\n});\n",
34
+ "src/test/features/example.feature": "@example\nFeature: Example — Hello World\n As a QA engineer\n I want to verify the framework is working\n So that I can start writing tests\n\n @smoke\n Scenario: Framework smoke test\n Given I navigate to \"https://example.com\"\n Then the page title should contain \"Example\"\n And the current URL should contain \"example.com\"\n",
35
+ "src/test/steps/common.steps.ts": "import { Given, Then } from '@cucumber/cucumber';\nimport { expect } from '@playwright/test';\nimport { fixture } from '@hooks/pageFixture';\n\nGiven('I navigate to {string}', async function (url: string): Promise<void> {\n await fixture().page.goto(url, { waitUntil: 'domcontentloaded' });\n fixture().logger.info(`Navigated to ${url}`);\n});\n\nThen('the page title should contain {string}', async function (expected: string): Promise<void> {\n const title = await fixture().page.title();\n expect(title).toContain(expected);\n fixture().logger.info(`Page title \"${title}\" contains \"${expected}\"`);\n});\n\nThen('the current URL should contain {string}', async function (urlPattern: string): Promise<void> {\n const currentUrl = fixture().page.url();\n expect(currentUrl).toContain(urlPattern);\n fixture().logger.info(`URL \"${currentUrl}\" contains \"${urlPattern}\"`);\n});\n\nThen('I should see an error message', async function (): Promise<void> {\n const errorLoc = fixture().page.locator('[data-test=\"error-message\"], [data-testid=\"error\"], .error-message');\n await errorLoc.waitFor({ state: 'visible', timeout: 5000 });\n fixture().logger.info('Error message is visible');\n});\n\nThen('I should see error message {string}', async function (expectedMessage: string): Promise<void> {\n const errorLoc = fixture().page.locator('[data-test=\"error-message\"], [data-testid=\"error\"], .error-message');\n const actual = await errorLoc.textContent();\n expect(actual).toContain(expectedMessage);\n fixture().logger.info(`Error message verified: \"${actual}\"`);\n});\n",
36
+ "src/test/steps/hooks.ts": "import '@hooks/hooks';\n",
37
+ "src/test/steps/world.ts": "export { PageFixture } from '../../hooks/pageFixture';\n",
38
+ "src/utils/ai-assistant/AILocatorGenerator.ts": "import { Page } from '@playwright/test';\nimport { logger } from '@utils/helpers/Logger';\nimport { environment } from '@config/environment';\n\nexport interface LocatorGenerationOptions {\n elementDescription: string;\n screenshot?: Buffer;\n context?: string;\n}\n\nexport interface GeneratedLocator {\n selector: string;\n strategy: 'testid' | 'role' | 'label' | 'text' | 'css';\n confidence: number;\n alternatives: string[];\n}\n\n/**\n * AILocatorGenerator\n *\n * Generates candidate Playwright locators from a natural-language element description.\n * Falls back to heuristic pattern matching when self-healing is disabled or API key absent.\n *\n * In production, extend `callAIService()` with your preferred provider.\n * The `LocatorHealer` class (Layer 1 healing) provides a fully-featured multi-provider\n * AI Vision implementation using screenshot-based analysis.\n */\nexport class AILocatorGenerator {\n private readonly env = environment.getConfig();\n\n constructor(private readonly page: Page) {}\n\n async generateLocator(options: LocatorGenerationOptions): Promise<GeneratedLocator> {\n logger.info(`Generating locator for: \"${options.elementDescription}\"`);\n\n if (!this.env.enableSelfHealing || !this.env.aiApiKey) {\n logger.warn('AI self-healing not enabled — using heuristic fallback');\n return this.generateFallbackLocator(options);\n }\n\n try {\n return await this.callAIService(options);\n } catch (err) {\n logger.error(`AI locator generation failed: ${String(err)}`);\n return this.generateFallbackLocator(options);\n }\n }\n\n /**\n * Override this method to integrate with your chosen AI provider.\n * See LocatorHealer.healByAiVision() for a full multi-provider implementation.\n */\n private async callAIService(options: LocatorGenerationOptions): Promise<GeneratedLocator> {\n logger.debug('AI service integration placeholder — falling back to heuristic');\n return this.generateFallbackLocator(options);\n }\n\n private generateFallbackLocator(options: LocatorGenerationOptions): GeneratedLocator {\n const desc = options.elementDescription.toLowerCase();\n const alternatives: string[] = [];\n\n if (desc.includes('button')) {\n const name = options.elementDescription;\n alternatives.push(`[role=\"button\"]:has-text(\"${name}\")`);\n alternatives.push(`[data-testid*=\"button\"]`);\n return { selector: `button:has-text(\"${name}\")`, strategy: 'role', confidence: 0.7, alternatives };\n }\n if (desc.includes('input') || desc.includes('field')) {\n const fieldName = desc.replace(/input|field/gi, '').trim();\n alternatives.push(`[data-testid=\"${fieldName}\"]`);\n alternatives.push(`[placeholder*=\"${fieldName}\"]`);\n return { selector: `input[name=\"${fieldName}\"]`, strategy: 'label', confidence: 0.7, alternatives };\n }\n if (desc.includes('link')) {\n const linkText = desc.replace(/link/gi, '').trim();\n alternatives.push(`[role=\"link\"]:has-text(\"${linkText}\")`);\n return { selector: `a:has-text(\"${linkText}\")`, strategy: 'role', confidence: 0.6, alternatives };\n }\n\n return { selector: `:has-text(\"${options.elementDescription}\")`, strategy: 'text', confidence: 0.5, alternatives: [] };\n }\n\n async validateLocator(selector: string): Promise<boolean> {\n try {\n await this.page.locator(selector).first().waitFor({ state: 'attached', timeout: 2_000 });\n return true;\n } catch { return false; }\n }\n\n async getBestLocator(alternatives: string[]): Promise<string | null> {\n for (const selector of alternatives) {\n if (await this.validateLocator(selector)) {\n logger.info(`Found working alternative: ${selector}`);\n return selector;\n }\n }\n return null;\n }\n}\n",
39
+ "src/utils/ai-assistant/AISelfHealing.ts": "import { Page } from '@playwright/test';\nimport { logger } from '@utils/helpers/Logger';\nimport { AILocatorGenerator } from './AILocatorGenerator';\nimport { environment } from '@config/environment';\nimport { getAILocatorRules, getContextRules } from '@utils/locators/LocatorRules';\n\nexport interface SelfHealingOptions {\n maxAttempts?: number;\n useAI?: boolean;\n saveHealing?: boolean;\n elementType?: string;\n context?: string;\n}\n\nexport interface HealingReport {\n success: boolean;\n originalSelector: string;\n healedSelector?: string;\n method: 'ai' | 'heuristic' | 'rule-based' | 'fallback';\n attempts: number;\n timestamp: Date;\n rulesUsed?: string[];\n}\n\n/**\n * AISelfHealing\n *\n * Orchestrates multiple healing strategies:\n * 1. Rule-based (LocatorRules engine + getContextRules)\n * 2. Heuristic (selector variation generation)\n * 3. AI Vision (AILocatorGenerator — delegates to LocatorHealer providers)\n * 4. Text-based (fallback :text() selectors)\n *\n * This is used by LocatorManager as the deep-healing fallback.\n * For per-action healing, see LocatorHealer (Layer 1).\n */\nexport class AISelfHealing {\n private readonly locatorGenerator: AILocatorGenerator;\n private readonly env = environment.getConfig();\n private readonly healingHistory: HealingReport[] = [];\n\n constructor(private readonly page: Page) {\n this.locatorGenerator = new AILocatorGenerator(page);\n }\n\n async heal(brokenSelector: string, elementDescription: string, options: SelfHealingOptions = {}): Promise<HealingReport> {\n const { maxAttempts = 3, useAI = this.env.enableSelfHealing, saveHealing = true, elementType = 'button', context } = options;\n\n logger.info(`Self-healing: \"${brokenSelector}\" for \"${elementDescription}\"`);\n\n const report: HealingReport = { success: false, originalSelector: brokenSelector, method: 'heuristic', attempts: 0, timestamp: new Date(), rulesUsed: [] };\n\n // Strategy 1: Rule-based\n try {\n report.attempts++;\n const r = await this.tryRuleBasedHealing(elementDescription, elementType, context);\n if (r.success && r.selector) {\n return this.finalize(report, r.selector, 'rule-based', r.rulesUsed, saveHealing);\n }\n } catch (err) { logger.warn(`Rule-based healing error: ${String(err)}`); }\n\n // Strategy 2: Heuristic\n try {\n report.attempts++;\n const s = await this.tryHeuristicHealing(brokenSelector, elementDescription);\n if (s) return this.finalize(report, s, 'heuristic', [], saveHealing);\n } catch (err) { logger.warn(`Heuristic healing error: ${String(err)}`); }\n\n // Strategy 3: AI Vision\n if (useAI && report.attempts < maxAttempts) {\n try {\n report.attempts++;\n const s = await this.tryAIHealing(elementDescription);\n if (s) return this.finalize(report, s, 'ai', [], saveHealing);\n } catch (err) { logger.warn(`AI healing error: ${String(err)}`); }\n }\n\n // Strategy 4: Text fallback\n if (report.attempts < maxAttempts) {\n try {\n report.attempts++;\n const s = await this.tryFallbackHealing(elementDescription);\n if (s) return this.finalize(report, s, 'fallback', [], saveHealing);\n } catch (err) { logger.warn(`Fallback healing error: ${String(err)}`); }\n }\n\n logger.error(`All healing attempts failed for \"${brokenSelector}\"`);\n return report;\n }\n\n private finalize(report: HealingReport, selector: string, method: HealingReport['method'], rulesUsed: string[], save: boolean): HealingReport {\n report.success = true;\n report.healedSelector = selector;\n report.method = method;\n report.rulesUsed = rulesUsed;\n logger.info(`Healed via ${method}: ${selector}`);\n if (save) this.healingHistory.push(report);\n return report;\n }\n\n private async tryRuleBasedHealing(description: string, elementType: string, context?: string): Promise<{ success: boolean; selector?: string; rulesUsed: string[] }> {\n const rules = getAILocatorRules()\n .filter(r => r.elementTypes.includes(elementType) || r.elementTypes.includes('*'))\n .sort((a, b) => a.priority - b.priority);\n\n const contextPrefs = context ? (getContextRules() as Record<string, { preferredStrategies: string[] }>)[context] : null;\n let rulesToTry = rules;\n if (contextPrefs?.preferredStrategies) {\n rulesToTry = rules.sort((a, b) => {\n const ai = contextPrefs.preferredStrategies.indexOf(a.pattern);\n const bi = contextPrefs.preferredStrategies.indexOf(b.pattern);\n return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);\n });\n }\n\n const rulesUsed: string[] = [];\n for (const rule of rulesToTry) {\n let selector = '';\n switch (rule.pattern) {\n case 'testid': selector = `[data-testid=\"${description.toLowerCase().replace(/\\s+/g, '-')}\"]`; break;\n case 'label': selector = `label:text-is(\"${description}\")`; break;\n case 'placeholder': selector = `[placeholder*=\"${description}\"]`; break;\n case 'text': selector = `:text(\"${description}\")`; break;\n case 'role': selector = `[role=\"button\"]:has-text(\"${description}\")`; break;\n case 'css': selector = `[class*=\"${description.toLowerCase()}\"]`; break;\n }\n if (selector) {\n try {\n await this.page.locator(selector).first().waitFor({ state: 'attached', timeout: 2_000 });\n rulesUsed.push(rule.id);\n return { success: true, selector, rulesUsed };\n } catch { rulesUsed.push(`${rule.id} (failed)`); }\n }\n }\n return { success: false, rulesUsed };\n }\n\n private async tryHeuristicHealing(selector: string, description: string): Promise<string | null> {\n const idMatch = selector.match(/#([\\w-]+)/);\n const classMatch = selector.match(/\\.([\\w-]+)/);\n const variations = [\n ...(idMatch ? [`[id=\"${idMatch[1]}\"]`, `[data-testid=\"${idMatch[1]}\"]`] : []),\n ...(classMatch ? [`[class*=\"${classMatch[1]}\"]`] : []),\n `[role=\"button\"]:has-text(\"${description}\")`,\n `button:has-text(\"${description}\")`,\n `a:has-text(\"${description}\")`,\n ];\n for (const v of variations) {\n try {\n await this.page.locator(v).first().waitFor({ state: 'attached', timeout: 2_000 });\n return v;\n } catch { /* try next */ }\n }\n return null;\n }\n\n private async tryAIHealing(description: string): Promise<string | null> {\n const screenshot = await this.page.screenshot();\n const result = await this.locatorGenerator.generateLocator({ elementDescription: description, screenshot });\n if (await this.locatorGenerator.validateLocator(result.selector)) return result.selector;\n return this.locatorGenerator.getBestLocator(result.alternatives);\n }\n\n private async tryFallbackHealing(description: string): Promise<string | null> {\n for (const s of [`:text(\"${description}\")`, `:text-is(\"${description}\")`, `*:has-text(\"${description}\")`]) {\n try {\n await this.page.locator(s).first().waitFor({ state: 'attached', timeout: 2_000 });\n return s;\n } catch { /* try next */ }\n }\n return null;\n }\n\n getHealingHistory(): HealingReport[] { return [...this.healingHistory]; }\n\n getHealingStats() {\n const total = this.healingHistory.length;\n const successful = this.healingHistory.filter(r => r.success).length;\n const methodBreakdown: Record<string, number> = {};\n this.healingHistory.filter(r => r.success).forEach(r => { methodBreakdown[r.method] = (methodBreakdown[r.method] ?? 0) + 1; });\n return { totalAttempts: total, successful, failed: total - successful, successRate: total > 0 ? (successful / total) * 100 : 0, methodBreakdown };\n }\n}\n",
40
+ "src/utils/ai-assistant/AITestGenerator.ts": "import { logger } from '@utils/helpers/Logger';\nimport { environment } from '@config/environment';\nimport OpenAI from 'openai';\n\nexport interface AITestGenerationOptions {\n gherkinScenario: string;\n targetFramework: 'playwright' | 'cypress';\n includePageObjects: boolean;\n includeAssertions: boolean;\n}\n\nexport interface GeneratedTest {\n code: string;\n filename: string;\n pageObjects?: string[];\n dependencies?: string[];\n}\n\n/**\n * AITestGenerator\n *\n * Converts BDD Gherkin scenarios into executable Playwright TypeScript tests.\n * Requires AI_PROVIDER=openai and AI_API_KEY to be set.\n *\n * Usage:\n * const gen = new AITestGenerator();\n * const test = await gen.generateTest({ gherkinScenario, targetFramework: 'playwright', ... });\n * await gen.saveGeneratedTest(test, 'tests/generated/');\n */\nexport class AITestGenerator {\n private client: OpenAI | null = null;\n private readonly env = environment.getConfig();\n\n constructor() {\n if (this.env.enableAITestGeneration && this.env.aiApiKey) {\n this.initAIClient();\n }\n }\n\n private initAIClient(): void {\n try {\n switch (this.env.aiProvider) {\n case 'openai':\n this.client = new OpenAI({ apiKey: this.env.aiApiKey });\n break;\n case 'anthropic':\n logger.info('AITestGenerator: Anthropic provider configured (extend initAIClient())');\n break;\n case 'local':\n logger.info('AITestGenerator: Local LLM provider configured (extend initAIClient())');\n break;\n default:\n logger.warn(`AITestGenerator: unknown AI provider \"${this.env.aiProvider}\"`);\n }\n } catch (err) {\n logger.error(`Failed to initialise AI client: ${String(err)}`);\n }\n }\n\n async generateTest(options: AITestGenerationOptions): Promise<GeneratedTest> {\n logger.info(`Generating Playwright test from Gherkin scenario`);\n if (!this.client) throw new Error('AI client not initialised. Set AI_PROVIDER and AI_API_KEY.');\n\n const prompt = this.buildPrompt(options);\n const response = await this.client.chat.completions.create({\n model: this.env.aiModel,\n messages: [\n { role: 'system', content: 'You are an expert Playwright + TypeScript test automation engineer.' },\n { role: 'user', content: prompt },\n ],\n temperature: this.env.aiTemperature,\n max_tokens: this.env.aiMaxTokens,\n });\n\n const rawCode = response.choices[0]?.message?.content ?? '';\n return {\n code: this.cleanCode(rawCode),\n filename: this.buildFilename(options.gherkinScenario),\n pageObjects: this.extractPageObjects(rawCode),\n dependencies: this.extractDependencies(rawCode),\n };\n }\n\n private buildPrompt(options: AITestGenerationOptions): string {\n return `\nGenerate a production-ready Playwright test in TypeScript from the following Gherkin scenario:\n\n\\`\\`\\`gherkin\n${options.gherkinScenario}\n\\`\\`\\`\n\nRequirements:\n- Use Playwright's modern locator strategies (getByRole, getByLabel, getByTestId)\n- Include proper TypeScript type annotations\n- Add meaningful comments\n- Use async/await properly\n${options.includePageObjects ? '- Create page object methods if needed' : ''}\n${options.includeAssertions ? '- Include comprehensive assertions using expect()' : ''}\n- Follow maintainability best practices\n- Use proper waits and timeouts\n\nGenerate ONLY the TypeScript code, no markdown or explanations.\n`.trim();\n }\n\n private cleanCode(code: string): string {\n return code.replace(/```typescript\\n?/g, '').replace(/```\\n?/g, '').trim();\n }\n\n private buildFilename(scenario: string): string {\n const match = scenario.match(/Feature:\\s*(.+)/);\n const name = match ? match[1] : 'generated';\n return name.toLowerCase().replace(/[^a-z0-9]+/g, '-') + '.spec.ts';\n }\n\n private extractPageObjects(code: string): string[] {\n const found: string[] = [];\n const re = /new\\s+(\\w+Page)\\(/g;\n let m: RegExpExecArray | null;\n while ((m = re.exec(code)) !== null) if (!found.includes(m[1])) found.push(m[1]);\n return found;\n }\n\n private extractDependencies(code: string): string[] {\n const found: string[] = [];\n const re = /import\\s+.+\\s+from\\s+['\"](.+)['\"]/g;\n let m: RegExpExecArray | null;\n while ((m = re.exec(code)) !== null) found.push(m[1]);\n return found;\n }\n\n async saveGeneratedTest(test: GeneratedTest, outputDir: string): Promise<void> {\n const fs = await import('fs');\n const path = await import('path');\n const filePath = path.join(outputDir, test.filename);\n const dir = path.dirname(filePath);\n if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });\n fs.writeFileSync(filePath, test.code, 'utf-8');\n logger.info(`Generated test saved: ${filePath}`);\n }\n}\n",
41
+ "src/utils/helpers/Logger.ts": "import winston from \"winston\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\nimport { environment } from \"../../config/environment\";\n\nclass Logger {\n private logger: winston.Logger;\n\n constructor() {\n const env = environment.getConfig();\n\n const logsDir = path.dirname(env.logFilePath);\n if (!fs.existsSync(logsDir)) {\n fs.mkdirSync(logsDir, { recursive: true });\n }\n\n const transports: winston.transport[] = [\n new winston.transports.Console({\n format: winston.format.combine(\n winston.format.colorize(),\n winston.format.timestamp({ format: \"YYYY-MM-DD HH:mm:ss\" }),\n winston.format.printf(({ timestamp, level, message, ...meta }) => {\n const metaStr = Object.keys(meta).length\n ? JSON.stringify(meta, null, 2)\n : \"\";\n return `${timestamp} [${level}]: ${message} ${metaStr}`;\n }),\n ),\n }),\n ];\n\n if (env.logToFile) {\n transports.push(\n new winston.transports.File({\n filename: env.logFilePath,\n format: winston.format.combine(\n winston.format.timestamp({ format: \"YYYY-MM-DD HH:mm:ss\" }),\n winston.format.json(),\n ),\n maxsize: 10485760, // 10MB\n maxFiles: 5,\n }),\n );\n }\n\n this.logger = winston.createLogger({\n level: env.logLevel,\n transports,\n });\n }\n\n public info(message: string, meta?: Record<string, unknown>): void {\n this.logger.info(message, meta);\n }\n\n public error(message: string, meta?: Record<string, unknown>): void {\n this.logger.error(message, meta);\n }\n\n public warn(message: string, meta?: Record<string, unknown>): void {\n this.logger.warn(message, meta);\n }\n\n public debug(message: string, meta?: Record<string, unknown>): void {\n this.logger.debug(message, meta);\n }\n\n public startTest(testName: string): void {\n this.logger.info(`Starting test: ${testName}`, {\n type: \"test-start\",\n timestamp: new Date().toISOString(),\n });\n }\n\n public endTest(\n testName: string,\n status: \"passed\" | \"failed\" | \"skipped\",\n duration?: number,\n ): void {\n this.logger.info(`Test ${status}: ${testName}`, {\n type: \"test-end\",\n status,\n duration,\n timestamp: new Date().toISOString(),\n });\n }\n}\n\nexport { Logger };\nexport const logger = new Logger();\n",
42
+ "src/utils/helpers/RetryHandler.ts": "import { logger } from \"./Logger\";\n\nexport interface RetryOptions {\n maxAttempts?: number;\n delay?: number;\n backoffMultiplier?: number;\n timeout?: number;\n retryableErrors?: string[];\n}\n\nexport class RetryHandler {\n private defaultOptions: Required<RetryOptions> = {\n maxAttempts: 3,\n delay: 1000,\n backoffMultiplier: 2,\n timeout: 30000,\n retryableErrors: [\"timeout\", \"detached\", \"disconnected\", \"navigation\"],\n };\n\n async retry<T>(\n fn: () => Promise<T>,\n options: RetryOptions = {},\n context?: string,\n ): Promise<T> {\n const opts = { ...this.defaultOptions, ...options };\n let lastError: Error | undefined;\n let currentDelay = opts.delay;\n\n for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {\n try {\n logger.debug(\n `Attempt ${attempt}/${opts.maxAttempts}${context ? ` - ${context}` : \"\"}`,\n );\n\n const result = await Promise.race([\n fn(),\n this.createTimeout(opts.timeout, `Operation timed out after ${opts.timeout}ms`),\n ]);\n\n if (attempt > 1) {\n logger.info(`Retry succeeded on attempt ${attempt}${context ? ` - ${context}` : \"\"}`);\n }\n\n return result as T;\n } catch (error: unknown) {\n const err = error as Error;\n lastError = err;\n\n const isRetryable = this.isRetryableError(err, opts.retryableErrors);\n const isLastAttempt = attempt === opts.maxAttempts;\n\n logger.warn(`Attempt ${attempt}/${opts.maxAttempts} failed${context ? ` - ${context}` : \"\"}`, {\n error: err.message,\n retryable: isRetryable,\n });\n\n if (!isRetryable || isLastAttempt) {\n throw err;\n }\n\n await this.sleep(currentDelay);\n currentDelay *= opts.backoffMultiplier;\n }\n }\n\n throw lastError || new Error(\"Retry failed without error\");\n }\n\n private isRetryableError(error: Error, retryableErrors: string[]): boolean {\n return retryableErrors.some((keyword) =>\n error.message.toLowerCase().includes(keyword.toLowerCase()),\n );\n }\n\n private createTimeout(ms: number, message: string): Promise<never> {\n return new Promise((_, reject) =>\n setTimeout(() => reject(new Error(message)), ms),\n );\n }\n\n private sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n}\n\nexport const retryHandler = new RetryHandler();\n",
43
+ "src/utils/helpers/WaitHelper.ts": "import { Page, Locator } from \"@playwright/test\";\nimport { logger } from \"./Logger\";\n\nexport interface WaitOptions {\n timeout?: number;\n state?: \"attached\" | \"detached\" | \"visible\" | \"hidden\";\n}\n\nexport class WaitHelper {\n constructor(private page: Page) {}\n\n async waitForElement(locator: Locator, options: WaitOptions = {}): Promise<void> {\n const { timeout = 10000, state = \"visible\" } = options;\n try {\n await locator.waitFor({ state, timeout });\n logger.debug(`Element ready in ${state} state`);\n } catch (error: unknown) {\n const err = error as Error;\n logger.error(`Failed to wait for element in ${state} state`, {\n error: err.message,\n timeout,\n });\n throw error;\n }\n }\n\n async waitForPageLoad(timeout = 30000): Promise<void> {\n try {\n await this.page.waitForLoadState(\"networkidle\", { timeout });\n logger.debug(\"Page fully loaded (networkidle)\");\n } catch {\n logger.warn(\"Timeout waiting for networkidle, falling back to domcontentloaded\");\n await this.page.waitForLoadState(\"domcontentloaded\", { timeout: 5000 });\n }\n }\n\n async waitForNavigation(timeout = 30000): Promise<void> {\n await this.page.waitForLoadState(\"domcontentloaded\", { timeout });\n logger.debug(\"Navigation completed\");\n }\n\n async waitForURL(urlPattern: string | RegExp, timeout = 30000): Promise<void> {\n try {\n await this.page.waitForURL(urlPattern, { timeout });\n logger.debug(`URL matched pattern: ${urlPattern}`);\n } catch (error: unknown) {\n const err = error as Error;\n logger.error(`URL did not match pattern: ${urlPattern}`, {\n currentUrl: this.page.url(),\n error: err.message,\n });\n throw error;\n }\n }\n\n async waitForSelector(selector: string, options: WaitOptions = {}): Promise<Locator> {\n const { timeout = 10000, state = \"visible\" } = options;\n const locator = this.page.locator(selector);\n await locator.waitFor({ state, timeout });\n return locator;\n }\n\n async waitForText(text: string | RegExp, timeout = 10000): Promise<void> {\n await this.page.getByText(text).waitFor({ state: \"visible\", timeout });\n logger.debug(`Text found: ${text}`);\n }\n\n async waitForCount(selector: string, count: number, timeout = 10000): Promise<void> {\n await this.page.locator(selector).nth(count - 1).waitFor({ state: \"visible\", timeout });\n logger.debug(`Found ${count} element(s) matching: ${selector}`);\n }\n\n async sleep(ms: number): Promise<void> {\n await this.page.waitForTimeout(ms);\n }\n}\n",
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
+ "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
+ "src/utils/locators/HealingDashboard.ts": "/**\n * HealingDashboard — real-time self-healing observability server\n *\n * Starts a lightweight HTTP server (default port 7890) that:\n * • Accepts healing events pushed by LocatorHealer during test runs\n * • Serves a live HTML dashboard at http://localhost:<port>\n * • Exposes JSON APIs at /api/events, /api/summary, /api/registry\n * • Auto-refreshes via Server-Sent Events (SSE)\n *\n * Usage:\n * BeforeAll: await HealingDashboard.getInstance().start();\n * AfterAll: await HealingDashboard.getInstance().stop();\n */\n\nimport * as http from 'http';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nconst HEAL_STORE_PATH = path.resolve(\n process.env.HEAL_STORE_PATH ?? 'storage-state/healed-locators.json',\n);\n\nfunction readHealStore(): Record<string, unknown> {\n try {\n if (!fs.existsSync(HEAL_STORE_PATH)) return {};\n return JSON.parse(fs.readFileSync(HEAL_STORE_PATH, 'utf8'));\n } catch { return {}; }\n}\n\nexport interface HealEvent {\n key: string;\n originalSelector: string;\n strategy: string;\n provider?: string;\n healedSelector?: string;\n intent: string;\n scenario?: string;\n timestamp: string;\n}\n\nconst cors = { 'Access-Control-Allow-Origin': '*' };\n\nfunction esc(str: string): string {\n return str\n .replace(/&/g, '&amp;').replace(/</g, '&lt;')\n .replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n}\n\nexport class HealingDashboard {\n private static _instance: HealingDashboard | null = null;\n\n static getInstance(): HealingDashboard {\n if (!HealingDashboard._instance) {\n HealingDashboard._instance = new HealingDashboard();\n }\n return HealingDashboard._instance;\n }\n\n static reset(): void { HealingDashboard._instance = null; }\n\n private readonly port: number;\n private server: http.Server | null = null;\n private events: HealEvent[] = [];\n private sseClients: http.ServerResponse[] = [];\n\n private constructor() {\n this.port = parseInt(process.env.HEALING_DASHBOARD_PORT ?? '7890', 10);\n }\n\n async start(): Promise<void> {\n if (this.server) return;\n this.server = http.createServer((req, res) => this.handleRequest(req, res));\n await new Promise<void>((resolve) => {\n this.server!.listen(this.port, '127.0.0.1', () => {\n console.log(` 🩺 HealingDashboard → http://localhost:${this.port}`);\n resolve();\n });\n this.server!.on('error', (err: NodeJS.ErrnoException) => {\n if (err.code === 'EADDRINUSE') {\n console.log(` 🩺 HealingDashboard already running on port ${this.port}`);\n this.server = null;\n } else {\n console.warn(` ⚠ HealingDashboard failed to start: ${err.message}`);\n this.server = null;\n }\n resolve();\n });\n });\n }\n\n async stop(): Promise<void> {\n for (const client of this.sseClients) { try { client.end(); } catch { /* ignore */ } }\n this.sseClients = [];\n await new Promise<void>((resolve) => {\n if (!this.server) { resolve(); return; }\n this.server.close(() => { this.server = null; resolve(); });\n });\n }\n\n record(event: HealEvent): void {\n this.events.push(event);\n this.pushSse(event);\n }\n\n private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {\n const url = req.url ?? '/';\n if (url === '/events' && req.headers.accept?.includes('text/event-stream')) {\n this.handleSse(res); return;\n }\n if (url === '/api/events') {\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify(this.events, null, 2)); return;\n }\n if (url === '/api/summary') {\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify(this.buildSummary(), null, 2)); return;\n }\n if (url === '/api/registry') {\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify(readHealStore(), null, 2)); return;\n }\n if (url === '/api/registry/clear' && req.method === 'POST') {\n try { fs.mkdirSync(path.dirname(HEAL_STORE_PATH), { recursive: true }); fs.writeFileSync(HEAL_STORE_PATH, '{}', 'utf8'); } catch { /* ignore */ }\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify({ ok: true })); return;\n }\n res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });\n res.end(this.buildHtml());\n }\n\n private handleSse(res: http.ServerResponse): void {\n res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', ...cors });\n res.write(':ok\\n\\n');\n this.sseClients.push(res);\n res.on('close', () => { this.sseClients = this.sseClients.filter(c => c !== res); });\n }\n\n private pushSse(event: HealEvent): void {\n const data = `data: ${JSON.stringify(event)}\\n\\n`;\n for (const client of this.sseClients) { try { client.write(data); } catch { /* client disconnected */ } }\n }\n\n private buildSummary() {\n const total = this.events.length;\n const byStrategy: Record<string, number> = {};\n const byKey: Record<string, number> = {};\n const byProvider: Record<string, number> = {};\n for (const e of this.events) {\n byStrategy[e.strategy] = (byStrategy[e.strategy] ?? 0) + 1;\n byKey[e.key] = (byKey[e.key] ?? 0) + 1;\n if (e.provider) byProvider[e.provider] = (byProvider[e.provider] ?? 0) + 1;\n }\n return { total, uniqueKeys: Object.keys(byKey).length, aiHeals: byStrategy['ai-vision'] ?? 0, byStrategy, byKey, byProvider };\n }\n\n private buildHtml(): string {\n const summary = this.buildSummary();\n const store = readHealStore();\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"UTF-8\"><title>Healix — Self-Healing Dashboard</title>\n<style>\n*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}\n:root{--bg:#0d1117;--surface:#161b22;--border:#30363d;--accent:#58a6ff;--green:#3fb950;--yellow:#d29922;--red:#f85149;--purple:#bc8cff;--text:#e6edf3;--muted:#8b949e;--radius:8px}\nbody{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.5;padding:24px}\nheader{display:flex;align-items:center;gap:12px;margin-bottom:28px}header h1{font-size:20px;font-weight:600}\n.badge{background:var(--green);color:#000;font-size:11px;font-weight:700;padding:2px 8px;border-radius:20px}\n.provider-pill{margin-left:auto;background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:4px 12px;font-size:12px;color:var(--accent)}\n.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:16px;margin-bottom:28px}\n.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px}\n.card .label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px}\n.card .value{font-size:28px;font-weight:700;margin-top:4px}\n.card.green .value{color:var(--green)}.card.blue .value{color:var(--accent)}.card.purple .value{color:var(--purple)}.card.yellow .value{color:var(--yellow)}\n.breakdown{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;margin-bottom:28px}\n.breakdown h2{font-size:13px;font-weight:600;margin-bottom:12px;color:var(--muted);text-transform:uppercase}\n.bars{display:flex;flex-direction:column;gap:8px}.bar-row{display:flex;align-items:center;gap:10px}\n.bar-label{width:100px;font-size:12px;color:var(--muted);text-align:right}\n.bar-track{flex:1;background:var(--border);border-radius:4px;height:10px;overflow:hidden}\n.bar-fill{height:100%;border-radius:4px;background:var(--accent)}.bar-fill.ai-vision{background:var(--purple)}.bar-fill.ax-tree{background:var(--yellow)}.bar-fill.role{background:var(--green)}\n.bar-count{width:28px;font-size:12px;text-align:right}\n.section-title{font-size:13px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;display:flex;align-items:center;gap:8px}\n.section-title .count{background:var(--accent);color:#000;font-size:11px;font-weight:700;padding:1px 7px;border-radius:20px}\ntable{width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}\nth{background:#21262d;text-align:left;padding:10px 14px;font-size:11px;color:var(--muted);text-transform:uppercase;border-bottom:1px solid var(--border)}\ntd{padding:10px 14px;border-bottom:1px solid var(--border);vertical-align:top;font-size:13px}\ntr:last-child td{border-bottom:none}tr:hover td{background:rgba(88,166,255,.04)}\n.key-chip{background:rgba(88,166,255,.12);color:var(--accent);border-radius:4px;padding:2px 7px;font-size:12px;font-family:monospace}\n.selector{font-family:monospace;font-size:12px;color:var(--muted);word-break:break-all}\n.new-selector{font-family:monospace;font-size:12px;color:var(--green);word-break:break-all}\n.badge-strategy{display:inline-block;border-radius:4px;padding:2px 8px;font-size:11px;font-weight:600}\n.badge-strategy.ai-vision{background:rgba(188,140,255,.15);color:var(--purple)}\n.badge-strategy.ax-tree{background:rgba(210,153,34,.15);color:var(--yellow)}\n.badge-strategy.role,.badge-strategy.label{background:rgba(63,185,80,.15);color:var(--green)}\n.badge-strategy.text{background:rgba(88,166,255,.1);color:var(--accent)}\n.badge-strategy.cached{background:rgba(139,148,158,.15);color:var(--muted)}\n.ts{font-size:11px;color:var(--muted);white-space:nowrap}.scenario{font-size:11px;color:var(--muted);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.empty{text-align:center;padding:48px;color:var(--muted);font-size:13px}\n.pulse{display:inline-block;width:8px;height:8px;background:var(--green);border-radius:50%;margin-right:6px;animation:pulse 2s infinite}\n@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}\nfooter{margin-top:32px;text-align:center;font-size:11px;color:var(--muted)}\n</style>\n</head>\n<body>\n<header>\n <span>🩺</span><h1>Healix — Self-Healing Dashboard</h1><span class=\"badge\">LIVE</span>\n <span class=\"provider-pill\">AI · ${process.env.AI_PROVIDER ?? 'openai'} / ${process.env.AI_MODEL ?? 'gpt-4o'}</span>\n</header>\n<div class=\"cards\" id=\"cards\">${this.renderCards(summary)}</div>\n<div class=\"breakdown\"><h2>Strategy breakdown</h2><div class=\"bars\" id=\"bars\">${this.renderBars(summary)}</div></div>\n<div class=\"section-title\">Healing events <span class=\"count\" id=\"total-count\">${summary.total}</span>\n <span style=\"margin-left:auto;font-size:12px;color:var(--muted)\"><span class=\"pulse\"></span>live</span>\n</div>\n<table><thead><tr><th>Time</th><th>Key</th><th>Strategy</th><th>Original selector</th><th>Healed selector</th><th>Intent</th><th>Scenario</th></tr></thead>\n<tbody id=\"events-body\">${this.renderRows(this.events)}</tbody></table>\n<div class=\"section-title\" style=\"margin-top:32px\">Stored heals <span class=\"count\" id=\"registry-count\">${Object.keys(store).length}</span>\n <span style=\"margin-left:auto;font-size:12px;color:var(--muted)\">\n <code style=\"color:var(--accent);font-size:11px\">storage-state/healed-locators.json</code>\n &nbsp;·&nbsp;<button onclick=\"clearRegistry()\" style=\"background:rgba(248,81,73,.1);color:var(--red);border:1px solid var(--red);border-radius:4px;padding:2px 10px;font-size:11px;cursor:pointer\">Clear all</button>\n </span>\n</div>\n<table><thead><tr><th>Key</th><th>Strategy</th><th>Original selector</th><th>Healed selector</th><th>Heals</th><th>Last healed</th></tr></thead>\n<tbody id=\"registry-body\">${this.renderRegistry(store)}</tbody></table>\n<footer>QA Framework Self-Healing Dashboard · port ${this.port}</footer>\n<script>\nconst es=new EventSource('/events');\nes.onmessage=e=>{const ev=JSON.parse(e.data);prependRow(ev);refreshSummary();refreshRegistry();};\nfunction esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;').replace(/'/g,'&#39;');}\nfunction stratCls(s){return['ai-vision','ax-tree','role','label','text','cached'].includes(s)?s:'';}\nfunction prependRow(ev){const tb=document.getElementById('events-body');const nd=tb.querySelector('.no-data');if(nd)nd.remove();const ts=new Date(ev.timestamp).toLocaleTimeString();const tr=document.createElement('tr');tr.innerHTML=\\`<td class=\"ts\">\\${esc(ts)}</td><td><span class=\"key-chip\">\\${esc(ev.key)}</span></td><td><span class=\"badge-strategy \\${stratCls(ev.strategy)}\">\\${esc(ev.strategy)}\\${ev.provider?' · '+esc(ev.provider):''}</span></td><td class=\"selector\">\\${esc(ev.originalSelector)}</td><td class=\"new-selector\">\\${esc(ev.healedSelector??'—')}</td><td>\\${esc(ev.intent)}</td><td class=\"scenario\">\\${esc(ev.scenario??'—')}</td>\\`;tb.insertBefore(tr,tb.firstChild);}\nasync function refreshSummary(){try{const d=await(await fetch('/api/summary')).json();document.getElementById('total-count').textContent=d.total;document.getElementById('cards').innerHTML=renderCards(d);document.getElementById('bars').innerHTML=renderBars(d);}catch{}}\nasync function refreshRegistry(){try{const d=await(await fetch('/api/registry')).json();const keys=Object.keys(d);document.getElementById('registry-count').textContent=keys.length;document.getElementById('registry-body').innerHTML=keys.length===0?'<tr><td colspan=\"6\" style=\"color:var(--muted);text-align:center;padding:16px\">No heals stored yet</td></tr>':keys.map(k=>{const r=d[k];return\\`<tr><td><code style=\"color:var(--accent)\">\\${esc(k)}</code></td><td><span class=\"badge-strategy \\${stratCls(r.strategy??'')}\">\\${esc(r.strategy??'?')}</span></td><td><code style=\"color:var(--muted);font-size:11px\">\\${esc(r.originalSelector??'')}</code></td><td><code style=\"color:var(--green);font-size:12px\">\\${esc(r.healedSelector??'')}</code></td><td style=\"text-align:center\">\\${r.healCount??0}</td><td style=\"font-size:11px;color:var(--muted)\">\\${r.lastHealedAt?new Date(r.lastHealedAt).toLocaleTimeString():''}</td></tr>\\`;}).join('');}catch{}}\nasync function clearRegistry(){if(!confirm('Clear all stored heals?'))return;await fetch('/api/registry/clear',{method:'POST'});await refreshRegistry();}\nfunction renderCards(s){return\\`<div class=\"card green\"><div class=\"label\">Total heals</div><div class=\"value\">\\${s.total}</div></div><div class=\"card blue\"><div class=\"label\">Unique keys</div><div class=\"value\">\\${s.uniqueKeys}</div></div><div class=\"card purple\"><div class=\"label\">AI Vision heals</div><div class=\"value\">\\${s.aiHeals}</div></div><div class=\"card yellow\"><div class=\"label\">Strategies</div><div class=\"value\">\\${Object.keys(s.byStrategy).length}</div></div>\\`;}\nfunction renderBars(s){const t=s.total||1;const e=Object.entries(s.byStrategy);return e.length===0?'<div style=\"color:var(--muted);font-size:12px\">No events yet</div>':e.map(([k,v])=>{const p=Math.round(v/t*100);return\\`<div class=\"bar-row\"><div class=\"bar-label\">\\${esc(k)}</div><div class=\"bar-track\"><div class=\"bar-fill \\${stratCls(k)}\" style=\"width:\\${p}%\"></div></div><div class=\"bar-count\">\\${v}</div></div>\\`;}).join('');}\nrefreshRegistry();setInterval(refreshRegistry,5000);\n</script>\n</body></html>`;\n }\n\n private renderCards(s: ReturnType<HealingDashboard['buildSummary']>): string {\n return `<div class=\"card green\"><div class=\"label\">Total heals</div><div class=\"value\">${s.total}</div></div>\n <div class=\"card blue\"><div class=\"label\">Unique keys</div><div class=\"value\">${s.uniqueKeys}</div></div>\n <div class=\"card purple\"><div class=\"label\">AI Vision heals</div><div class=\"value\">${s.aiHeals}</div></div>\n <div class=\"card yellow\"><div class=\"label\">Strategies used</div><div class=\"value\">${Object.keys(s.byStrategy).length}</div></div>`;\n }\n\n private renderBars(s: ReturnType<HealingDashboard['buildSummary']>): string {\n const total = s.total || 1;\n const entries = Object.entries(s.byStrategy);\n if (!entries.length) return '<div style=\"color:var(--muted);font-size:12px\">No events yet</div>';\n return entries.map(([k, v]) => {\n const pct = Math.round((v / total) * 100);\n const cls = ['ai-vision','ax-tree','role'].includes(k) ? k : '';\n return `<div class=\"bar-row\"><div class=\"bar-label\">${esc(k)}</div><div class=\"bar-track\"><div class=\"bar-fill ${cls}\" style=\"width:${pct}%\"></div></div><div class=\"bar-count\">${v}</div></div>`;\n }).join('');\n }\n\n private renderRows(events: HealEvent[]): string {\n if (!events.length) return `<tr class=\"no-data\"><td colspan=\"7\" class=\"empty\">No healing events yet.</td></tr>`;\n return [...events].reverse().map(e => {\n const ts = new Date(e.timestamp).toLocaleTimeString();\n const cls = ['ai-vision','ax-tree','role','label','text','cached'].includes(e.strategy) ? e.strategy : '';\n return `<tr><td class=\"ts\">${esc(ts)}</td><td><span class=\"key-chip\">${esc(e.key)}</span></td><td><span class=\"badge-strategy ${cls}\">${esc(e.strategy)}${e.provider ? ` · ${esc(e.provider)}` : ''}</span></td><td class=\"selector\">${esc(e.originalSelector)}</td><td class=\"new-selector\">${esc(e.healedSelector ?? '—')}</td><td>${esc(e.intent)}</td><td class=\"scenario\" title=\"${esc(e.scenario ?? '')}\">${esc(e.scenario ?? '—')}</td></tr>`;\n }).join('');\n }\n\n private renderRegistry(store: Record<string, unknown>): string {\n const keys = Object.keys(store);\n if (!keys.length) return `<tr><td colspan=\"6\" style=\"color:var(--muted);text-align:center;padding:16px\">No heals stored yet</td></tr>`;\n return keys.map(k => {\n const r = store[k] as Record<string, unknown>;\n const strat = String(r['strategy'] ?? 'unknown');\n const cls = ['ai-vision','ax-tree','role','label','text','cached'].includes(strat) ? strat : '';\n return `<tr><td><code style=\"color:var(--accent)\">${esc(k)}</code></td><td><span class=\"badge-strategy ${cls}\">${esc(strat)}</span></td><td><code style=\"color:var(--muted);font-size:11px\">${esc(String(r['originalSelector'] ?? ''))}</code></td><td><code style=\"color:var(--green);font-size:12px\">${esc(String(r['healedSelector'] ?? ''))}</code></td><td style=\"text-align:center\">${String(r['healCount'] ?? 0)}</td><td style=\"font-size:11px;color:var(--muted)\">${r['lastHealedAt'] ? new Date(String(r['lastHealedAt'])).toLocaleString() : ''}</td></tr>`;\n }).join('');\n }\n}\n",
47
+ "src/utils/locators/LocatorHealer.ts": "import { Page, Locator } from '@playwright/test';\nimport { LocatorRepository } from './LocatorRepository';\nimport { HealingDashboard } from './HealingDashboard';\n\nexport interface HealerLogger {\n info(msg: string): void;\n warn(msg: string): void;\n error(msg: string): void;\n}\n\n/**\n * LocatorHealer — Layer 1 Self-Healing\n *\n * Healing chain per action:\n * 1. Original selector (from LocatorRepository.getBestSelector)\n * 2. Role-based: getByRole inferred from intent\n * 3. Label-based: getByLabel / getByPlaceholder\n * 4. Text-based: getByText\n * 5. AI Vision: provider-agnostic vision API (openai | claude | grok | ollama | local)\n * 6. CDPSession AX tree walk (last resort)\n *\n * Provider is controlled by AI_PROVIDER in .env:\n * openai — OpenAI GPT-4o or gpt-4-vision-preview\n * claude — Anthropic Claude (claude-opus-4-6 or newer)\n * grok — xAI Grok vision API\n * ollama — Local Ollama with a vision model (e.g. llava, moondream2)\n * local — LM Studio or any OpenAI-compatible local server\n *\n * Healed selectors are persisted in LocatorRepository — zero overhead on repeat runs.\n */\nexport class LocatorHealer {\n private readonly TIMEOUT = 8_000;\n currentScenario?: string;\n\n constructor(\n private readonly page: Page,\n private readonly logger: HealerLogger,\n private readonly repo: LocatorRepository,\n ) {}\n\n async resolve(key: string, primarySelector: string, intent: string): Promise<Locator> {\n const cached = this.repo.getHealed(key);\n if (cached) {\n const loc = this.page.locator(cached);\n if (await this.isVisible(loc)) return loc;\n this.logger.warn(`[LocatorHealer] Cached heal stale for \"${key}\", re-healing`);\n }\n\n {\n const loc = this.page.locator(primarySelector);\n if (await this.isVisible(loc)) return loc;\n this.logger.warn(`[LocatorHealer] Primary selector failed for \"${key}\": ${primarySelector}`);\n }\n\n const aiLocator = await this.healByAiVision(key, intent, primarySelector);\n if (aiLocator) return aiLocator;\n\n const axLocator = await this.healByAxTree(key, intent, primarySelector);\n if (axLocator) return axLocator;\n\n throw new Error(\n `[LocatorHealer] All healing strategies exhausted for \"${key}\" (intent: \"${intent}\").\\n` +\n `Check HealingDashboard at http://localhost:${process.env.HEALING_DASHBOARD_PORT ?? 7890}`,\n );\n }\n\n async clickWithHealing(key: string, selector: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n await loc.click();\n this.logger.info(`[LocatorHealer] click \"${key}\"`);\n }\n\n async fillWithHealing(key: string, selector: string, value: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n\n const inputType = await loc.evaluate((el) => {\n const tag = (el as HTMLElement).tagName.toLowerCase();\n if (tag !== 'input' && tag !== 'textarea') return 'non-input';\n return (el as HTMLInputElement).type ?? 'text';\n });\n\n const nonFillableTypes = ['submit', 'button', 'reset', 'image', 'checkbox', 'radio', 'file'];\n if (nonFillableTypes.includes(inputType) || inputType === 'non-input') {\n this.logger.warn(`[LocatorHealer] Healed locator for \"${key}\" resolved to non-fillable element — re-healing`);\n this.repo.evict(key);\n const keyLabel = key.replace(/[_-]?(textbox|input|field|box)$/i, '').replace(/_/g, ' ').trim();\n const roleLoc = await (async () => {\n try {\n const l = this.page.getByRole('textbox', { name: keyLabel, exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n try {\n const l = this.page.getByPlaceholder(keyLabel, { exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n try {\n const l = this.page.getByLabel(keyLabel, { exact: false });\n if (await this.isVisible(l)) return l;\n } catch { /* continue */ }\n return null;\n })();\n if (!roleLoc) throw new Error(`[LocatorHealer] Cannot find fillable element for \"${key}\" (intent: \"${intent}\")`);\n await roleLoc.fill(value);\n this.persist(key, roleLoc, 'role');\n return;\n }\n\n await loc.fill(value);\n this.logger.info(`[LocatorHealer] fill \"${key}\" = \"${value}\"`);\n }\n\n async assertVisibleWithHealing(key: string, selector: string, intent: string): Promise<void> {\n const loc = await this.resolve(key, selector, intent);\n await loc.waitFor({ state: 'visible', timeout: this.TIMEOUT });\n this.logger.info(`[LocatorHealer] assertVisible \"${key}\" ✓`);\n }\n\n async assertHiddenWithHealing(key: string, selector: string, _intent: string): Promise<void> {\n const loc = this.page.locator(selector);\n await loc.waitFor({ state: 'hidden', timeout: this.TIMEOUT });\n this.logger.info(`[LocatorHealer] assertHidden \"${key}\" ✓`);\n }\n\n async getLocator(key: string, selector: string, intent: string): Promise<Locator> {\n return this.resolve(key, selector, intent);\n }\n\n /**\n * AI Vision healing — supports openai | claude | grok | ollama | local\n *\n * Configured via .env:\n * AI_PROVIDER = openai | claude | grok | ollama | local\n * AI_API_KEY = your API key (not needed for ollama/local)\n * AI_MODEL = model name override\n * LOCAL_LLM_ENDPOINT = base URL for ollama or LM Studio\n */\n private async healByAiVision(key: string, intent: string, originalSelector = ''): Promise<Locator | null> {\n const provider = (process.env.AI_PROVIDER ?? 'openai').toLowerCase();\n const apiKey = process.env.AI_API_KEY || process.env.ANTHROPIC_API_KEY;\n const needsKey = !['ollama', 'local'].includes(provider);\n if (needsKey && !apiKey) {\n this.logger.warn(`[LocatorHealer] AI Vision skipped for \"${key}\" — AI_API_KEY not set`);\n return null;\n }\n\n const prompt =\n `You are a Playwright test automation expert. Look at this screenshot.\\n` +\n `Find the element that matches: \"${intent}\".\\n` +\n `Return ONLY a valid CSS selector string. No explanation. No quotes. No code blocks.`;\n\n try {\n const screenshotBuffer = await this.page.screenshot({ fullPage: false });\n const base64Screenshot = screenshotBuffer.toString('base64');\n let selector: string | undefined;\n\n if (provider === 'openai') {\n const res = await fetch('https://api.openai.com/v1/chat/completions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'gpt-4o',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] OpenAI error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else if (provider === 'claude' || provider === 'anthropic') {\n const res = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey!, 'anthropic-version': '2023-06-01' },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'claude-opus-4-6',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image', source: { type: 'base64', media_type: 'image/png', data: base64Screenshot } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Claude error ${res.status}`); return null; }\n const data = await res.json() as { content: Array<{ type: string; text: string }> };\n selector = data.content.find(b => b.type === 'text')?.text?.trim();\n }\n\n else if (provider === 'grok') {\n const res = await fetch('https://api.x.ai/v1/chat/completions', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },\n body: JSON.stringify({\n model: process.env.AI_MODEL ?? 'grok-2-vision-latest',\n max_tokens: 256,\n messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}],\n }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Grok error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else if (provider === 'ollama') {\n const baseUrl = (process.env.OLLAMA_ENDPOINT || process.env.LOCAL_LLM_ENDPOINT || 'http://localhost:11434').replace(/\\/$/, '');\n const model = process.env.OLLAMA_MODEL || process.env.AI_MODEL || 'llava';\n const res = await fetch(`${baseUrl}/api/chat`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content: prompt, images: [base64Screenshot] }] }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] Ollama error ${res.status}`); return null; }\n const data = await res.json() as { message?: { content: string } };\n selector = data.message?.content?.trim();\n }\n\n else if (provider === 'local') {\n const baseUrl = (process.env.LM_STUDIO_ENDPOINT || process.env.LOCAL_LLM_ENDPOINT || 'http://localhost:1234').replace(/\\/$/, '');\n const model = process.env.LM_STUDIO_MODEL || process.env.AI_MODEL || 'local-model';\n const res = await fetch(`${baseUrl}/v1/chat/completions`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ model, max_tokens: 256, messages: [{ role: 'user', content: [\n { type: 'image_url', image_url: { url: `data:image/png;base64,${base64Screenshot}` } },\n { type: 'text', text: prompt },\n ]}] }),\n });\n if (!res.ok) { this.logger.warn(`[LocatorHealer] LM Studio error ${res.status}`); return null; }\n const data = await res.json() as { choices: Array<{ message: { content: string } }> };\n selector = data.choices[0]?.message?.content?.trim();\n }\n\n else {\n this.logger.warn(`[LocatorHealer] Unknown AI_PROVIDER \"${provider}\"`);\n return null;\n }\n\n if (!selector) return null;\n selector = selector.replace(/\\[([^\\]='\"\\s]+)='([^']*)'\\]/g, '[$1=\"$2\"]');\n this.logger.warn(`[LocatorHealer] AI Vision (${provider}) suggested: ${selector}`);\n const loc = this.page.locator(selector);\n if (await this.isVisible(loc)) {\n this.persist(key, loc, 'ai-vision', intent, originalSelector, selector);\n return loc;\n }\n } catch (err) {\n this.logger.warn(`[LocatorHealer] AI Vision failed for \"${key}\": ${String(err)}`);\n }\n return null;\n }\n\n private async healByAxTree(key: string, intent: string, originalSelector = ''): Promise<Locator | null> {\n const INTERACTIVE_ROLES = new Set([\n 'textbox', 'searchbox', 'spinbutton', 'combobox', 'listbox',\n 'button', 'link', 'checkbox', 'radio', 'menuitem', 'tab', 'switch',\n 'slider', 'option', 'treeitem', 'gridcell',\n ]);\n\n try {\n const client = await (this.page.context() as unknown as { newCDPSession(page: Page): Promise<{ send(cmd: string): Promise<{ nodes: unknown[] }>; detach(): Promise<void> }> }).newCDPSession(this.page);\n const { nodes } = await client.send('Accessibility.getFullAXTree');\n await client.detach();\n\n const intentWords = intent.toLowerCase().split(/\\s+/).filter(w => w.length > 2);\n let best: { node: Record<string, unknown>; score: number } | null = null;\n\n for (const n of (nodes as Record<string, Record<string, string>>[]) ) {\n const role = (n['role']?.['value'] ?? '').toLowerCase();\n const name = (n['name']?.['value'] ?? '').toLowerCase();\n if (!INTERACTIVE_ROLES.has(role) || !name) continue;\n const hasWordMatch = intentWords.some(word => new RegExp(`\\\\b${word}\\\\b`).test(name));\n if (!hasWordMatch) continue;\n const score = intentWords.filter(word => new RegExp(`\\\\b${word}\\\\b`).test(name)).length;\n if (!best || score > best.score) best = { node: n as unknown as Record<string, unknown>, score };\n }\n\n if (best?.node) {\n const role = (best.node['role'] as Record<string, string>)?.['value'];\n const name = (best.node['name'] as Record<string, string>)?.['value'];\n if (role && name) {\n const axLoc = this.page.getByRole(role as Parameters<Page['getByRole']>[0], { name, exact: false });\n if (await this.isVisible(axLoc)) {\n this.persist(key, axLoc, 'ax-tree', intent, originalSelector, `[role=\"${role}\"][name=\"${name}\"]`);\n return axLoc;\n }\n }\n }\n } catch (err) {\n this.logger.warn(`[LocatorHealer] AX tree healing failed: ${String(err)}`);\n }\n return null;\n }\n\n private async isVisible(loc: Locator): Promise<boolean> {\n try {\n return await loc.isVisible({ timeout: 2_000 });\n } catch {\n return false;\n }\n }\n\n private persist(key: string, _loc: Locator, strategy: string, intent = '', originalSelector = '', healedSelector?: string): void {\n const provider = strategy === 'ai-vision' ? (process.env.AI_PROVIDER ?? 'openai') : undefined;\n if (healedSelector) {\n try {\n if (!this.repo.getHealed(key) && originalSelector) {\n this.repo.register(key, originalSelector, intent);\n }\n this.repo.setHealed(key, healedSelector, strategy, provider);\n this.logger.info(`[LocatorHealer] 💾 Healed selector saved for \"${key}\" → ${healedSelector}`);\n } catch (err) {\n this.logger.warn(`[LocatorHealer] Could not persist heal for \"${key}\": ${String(err)}`);\n }\n }\n try {\n HealingDashboard.getInstance().record({\n key,\n originalSelector,\n strategy,\n provider,\n healedSelector,\n intent,\n scenario: this.currentScenario,\n timestamp: new Date().toISOString(),\n });\n } catch { /* dashboard may not be running */ }\n }\n}\n",
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
+ "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
+ "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",
51
+ "src/utils/locators/LocatorStrategy.ts": "import { Page, Locator } from '@playwright/test';\nimport { logger } from '@utils/helpers/Logger';\n\nexport type LocatorStrategyType = 'testid' | 'role' | 'label' | 'placeholder' | 'text' | 'css' | 'xpath';\n\nexport interface LocatorConfig {\n strategy: LocatorStrategyType;\n value: string;\n options?: Record<string, unknown>;\n}\n\n/**\n * LocatorStrategy\n *\n * Encapsulates all Playwright locator factory calls.\n * Keeps page objects free of strategy switch logic.\n *\n * Use via LocatorManager.getLocator(), or directly from a BasePage subclass:\n * const loc = this.locatorStrategy.getLocator({ strategy: 'testid', value: 'submit-btn' });\n */\nexport class LocatorStrategy {\n constructor(private readonly page: Page) {}\n\n getLocator(config: LocatorConfig): Locator {\n switch (config.strategy) {\n case 'testid': return this.page.getByTestId(config.value);\n case 'role': return this.page.getByRole(config.value as Parameters<Page['getByRole']>[0], config.options);\n case 'label': return this.page.getByLabel(config.value, config.options);\n case 'placeholder': return this.page.getByPlaceholder(config.value, config.options);\n case 'text': return this.page.getByText(config.value, config.options);\n case 'css':\n case 'xpath': return this.page.locator(config.value);\n default: throw new Error(`Unknown locator strategy: ${(config as LocatorConfig).strategy}`);\n }\n }\n\n async tryMultipleStrategies(configs: LocatorConfig[]): Promise<{ locator: Locator; config: LocatorConfig } | null> {\n for (const config of configs) {\n try {\n const locator = this.getLocator(config);\n await locator.waitFor({ timeout: 2_000 });\n logger.debug(`Locator found via ${config.strategy}: ${config.value}`);\n return { locator, config };\n } catch { /* try next */ }\n }\n return null;\n }\n\n async generateFallbackLocators(primary: LocatorConfig): Promise<LocatorConfig[]> {\n const fallbacks: LocatorConfig[] = [primary];\n if (primary.strategy === 'testid') {\n try {\n const el = this.page.getByTestId(primary.value).first();\n await el.waitFor({ state: 'attached', timeout: 1_000 });\n const role = await el.getAttribute('role');\n const ariaLabel = await el.getAttribute('aria-label');\n if (role) fallbacks.push({ strategy: 'role', value: role });\n if (ariaLabel) fallbacks.push({ strategy: 'label', value: ariaLabel });\n } catch { /* element not ready */ }\n }\n return fallbacks;\n }\n\n getByTextFuzzy(text: string, exact = false): Locator {\n return exact ? this.page.getByText(text, { exact: true }) : this.page.getByText(new RegExp(text, 'i'));\n }\n\n getButton(name: string | RegExp): Locator { return this.page.getByRole('button', { name }); }\n getLink(name: string | RegExp): Locator { return this.page.getByRole('link', { name }); }\n getTextbox(name: string | RegExp): Locator { return this.page.getByRole('textbox', { name }); }\n getHeading(name: string | RegExp, level?: 1|2|3|4|5|6): Locator { return this.page.getByRole('heading', { name, level }); }\n\n getCombinedLocator(strategies: string[]): Locator { return this.page.locator(strategies.join(', ')); }\n getNth(locator: Locator, index: number): Locator { return locator.nth(index); }\n getFirstVisible(locator: Locator): Locator { return locator.first(); }\n filterByText(locator: Locator, text: string | RegExp): Locator { return locator.filter({ hasText: text }); }\n filterByLocator(locator: Locator, inner: Locator): Locator { return locator.filter({ has: inner }); }\n getParent(locator: Locator): Locator { return locator.locator('..'); }\n\n buildRobustLocator(info: { testId?: string; role?: string; label?: string }): Locator {\n const strategies: string[] = [];\n if (info.testId) strategies.push(`[data-testid=\"${info.testId}\"]`);\n if (info.role) strategies.push(`[role=\"${info.role}\"]`);\n if (info.label) strategies.push(`[aria-label=\"${info.label}\"]`);\n if (!strategies.length) throw new Error('No valid locator strategies provided');\n return this.getCombinedLocator(strategies);\n }\n\n async isInteractable(locator: Locator): Promise<boolean> {\n try {\n await locator.waitFor({ state: 'visible', timeout: 2_000 });\n return await locator.isEnabled();\n } catch { return false; }\n }\n\n async getCount(locator: Locator): Promise<number> { return locator.count(); }\n}\n",
52
+ "src/utils/locators/PlaywrightHealerLogger.ts": "import { HealerLogger } from './LocatorHealer';\nimport { logger } from '@utils/helpers/Logger';\n\n/**\n * PlaywrightHealerLogger bridges the generic HealerLogger interface\n * used by LocatorHealer, TimingHealer, and VisualIntentChecker with\n * the project's Winston-backed Logger singleton.\n */\nexport class PlaywrightHealerLogger implements HealerLogger {\n info(msg: string): void { logger.info(msg); }\n warn(msg: string): void { logger.warn(msg); }\n error(msg: string): void { logger.error(msg); }\n}\n",
53
+ "src/utils/locators/TimingHealer.ts": "import { Page } from '@playwright/test';\nimport { HealerLogger } from './LocatorHealer';\n\ninterface TimingRecord {\n label: string;\n durationMs: number;\n timestamp: Date;\n}\n\n/**\n * TimingHealer — Layer 2 Self-Healing\n *\n * Measures network-idle wait time per labelled action and auto-adjusts\n * future waits using an Exponential Moving Average (EMA).\n * Flags drift to HealingDashboard when actual > baseline × threshold.\n *\n * Usage inside a page method:\n * await this.timing.waitForNetworkIdle('clickSubmit');\n */\nexport class TimingHealer {\n private static readonly DEFAULT_TIMEOUT = 10_000;\n private static readonly EMA_ALPHA = 0.3;\n private static readonly DRIFT_THRESHOLD = 1.5;\n\n private readonly history = new Map<string, TimingRecord[]>();\n private readonly emaMap = new Map<string, number>();\n\n constructor(\n private readonly page: Page,\n private readonly logger: HealerLogger,\n ) {}\n\n async waitForNetworkIdle(label: string, timeoutMs?: number): Promise<void> {\n const timeout = timeoutMs ?? this.getAdaptiveTimeout(label);\n const start = Date.now();\n\n try {\n await this.page.waitForLoadState('networkidle', { timeout });\n } catch {\n this.logger.warn(`[TimingHealer] networkidle timeout (${timeout}ms) for \"${label}\" — continuing`);\n }\n\n const elapsed = Date.now() - start;\n this.record(label, elapsed);\n this.updateEma(label, elapsed);\n this.checkDrift(label, elapsed, timeout);\n this.logger.info(`[TimingHealer] \"${label}\" settled in ${elapsed}ms (adaptive: ${timeout}ms)`);\n }\n\n getAdaptiveTimeout(label: string): number {\n const ema = this.emaMap.get(label);\n if (!ema) return TimingHealer.DEFAULT_TIMEOUT;\n return Math.ceil(ema * 1.5);\n }\n\n getHistory(label: string): TimingRecord[] {\n return this.history.get(label) ?? [];\n }\n\n getSummary(): Array<{ label: string; emaMs: number; adaptiveTimeoutMs: number }> {\n return Array.from(this.emaMap.entries()).map(([label, ema]) => ({\n label,\n emaMs: Math.round(ema),\n adaptiveTimeoutMs: this.getAdaptiveTimeout(label),\n }));\n }\n\n private record(label: string, durationMs: number): void {\n if (!this.history.has(label)) this.history.set(label, []);\n this.history.get(label)!.push({ label, durationMs, timestamp: new Date() });\n }\n\n private updateEma(label: string, durationMs: number): void {\n const prev = this.emaMap.get(label) ?? durationMs;\n this.emaMap.set(label, TimingHealer.EMA_ALPHA * durationMs + (1 - TimingHealer.EMA_ALPHA) * prev);\n }\n\n private checkDrift(label: string, elapsed: number, timeout: number): void {\n const baseline = timeout / 1.5;\n if (elapsed > baseline * TimingHealer.DRIFT_THRESHOLD) {\n this.logger.warn(\n `[TimingHealer] DRIFT detected for \"${label}\": actual=${elapsed}ms, baseline=${Math.round(baseline)}ms ` +\n `(${Math.round((elapsed / baseline) * 100)}%). ` +\n `Review at http://localhost:${process.env.HEALING_DASHBOARD_PORT ?? 7890}`,\n );\n }\n }\n}\n",
54
+ "src/utils/locators/VisualIntentChecker.ts": "import { Page } from '@playwright/test';\nimport { HealerLogger } from './LocatorHealer';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\n/**\n * VisualIntentChecker — Layer 3 Self-Healing\n *\n * At every assertion on an element flagged with `visualIntent: true` in locators.ts,\n * VisualIntentChecker:\n * 1. Takes an element-level screenshot\n * 2. Compares it against a stored baseline (size-based diff)\n * 3. If diff exceeds threshold → logs a warning for manual review\n *\n * First run: baseline is saved.\n * Subsequent runs: screenshot is diffed against baseline.\n *\n * Baselines are stored in: test-results/visual-baselines/{key}.png\n *\n * Usage inside a page object assertion:\n * await this.visual.check('submitButton', selector, intent);\n */\nexport class VisualIntentChecker {\n private static readonly BASELINE_DIR = path.resolve(\n process.cwd(), 'test-results', 'visual-baselines',\n );\n private static readonly DIFF_THRESHOLD_PCT = 2;\n\n constructor(\n private readonly page: Page,\n private readonly logger: HealerLogger,\n ) {\n fs.mkdirSync(VisualIntentChecker.BASELINE_DIR, { recursive: true });\n }\n\n async check(key: string, selector: string, _intent: string): Promise<void> {\n const baselinePath = path.join(VisualIntentChecker.BASELINE_DIR, `${key}.png`);\n\n try {\n const element = this.page.locator(selector).first();\n const isVisible = await element.isVisible({ timeout: 3_000 }).catch(() => false);\n\n if (!isVisible) {\n this.logger.warn(`[VisualIntentChecker] Element \"${key}\" not visible — skipping`);\n return;\n }\n\n const screenshot = await element.screenshot({ timeout: 5_000 });\n\n if (!fs.existsSync(baselinePath)) {\n fs.writeFileSync(baselinePath, screenshot);\n this.logger.info(`[VisualIntentChecker] Baseline saved for \"${key}\"`);\n return;\n }\n\n const baseline = fs.readFileSync(baselinePath);\n\n if (screenshot.length === baseline.length && screenshot.equals(baseline)) {\n this.logger.info(`[VisualIntentChecker] \"${key}\" visual match ✓`);\n return;\n }\n\n const diffPath = path.join(VisualIntentChecker.BASELINE_DIR, `${key}.diff.png`);\n fs.writeFileSync(diffPath, screenshot);\n\n const sizeDiffPct = Math.abs(screenshot.length - baseline.length) / baseline.length * 100;\n\n if (sizeDiffPct > VisualIntentChecker.DIFF_THRESHOLD_PCT) {\n this.logger.warn(\n `[VisualIntentChecker] VISUAL CHANGE detected for \"${key}\" ` +\n `(size diff: ${sizeDiffPct.toFixed(1)}%). Diff: ${diffPath}. ` +\n `Review at http://localhost:${process.env.HEALING_DASHBOARD_PORT ?? 7890}`,\n );\n } else {\n fs.writeFileSync(baselinePath, screenshot);\n this.logger.info(`[VisualIntentChecker] \"${key}\" minor diff (${sizeDiffPct.toFixed(1)}%) — baseline updated ✓`);\n }\n } catch (err) {\n this.logger.warn(`[VisualIntentChecker] Check failed for \"${key}\": ${String(err)}`);\n }\n }\n\n clearBaseline(key: string): void {\n const baselinePath = path.join(VisualIntentChecker.BASELINE_DIR, `${key}.png`);\n if (fs.existsSync(baselinePath)) {\n fs.unlinkSync(baselinePath);\n this.logger.info(`[VisualIntentChecker] Baseline cleared for \"${key}\"`);\n }\n }\n\n clearAllBaselines(): void {\n const files = fs.readdirSync(VisualIntentChecker.BASELINE_DIR)\n .filter((f: string) => f.endsWith('.png') && !f.endsWith('.diff.png'));\n files.forEach((f: string) => fs.unlinkSync(path.join(VisualIntentChecker.BASELINE_DIR, f)));\n this.logger.info(`[VisualIntentChecker] Cleared ${files.length} baselines`);\n }\n\n listBaselines(): string[] {\n return fs.readdirSync(VisualIntentChecker.BASELINE_DIR)\n .filter((f: string) => f.endsWith('.png') && !f.endsWith('.diff.png'))\n .map((f: string) => path.basename(f, '.png'));\n }\n}\n",
55
+ "src/utils/locators/healix-ci-apply.ts": "/**\n * healix-ci-apply.ts — headless heal applicator for CI/CD pipelines\n *\n * Run after tests complete in CI:\n * npm run healix:apply-ci\n *\n * Behaviour:\n * HEALIX_CI_AUTO_APPROVE=true → auto-approves all heals, applies to source, creates PR\n * HEALIX_CI_AUTO_APPROVE=false → prints summary only; human reviews via `npm run healix:review`\n *\n * Always exits 0 so the pipeline does not fail on healing activity.\n *\n * Environment variables:\n * HEALIX_CI_AUTO_APPROVE \"true\" to auto-approve (default: false)\n * HEALIX_CI_DRAFT_PR \"true\" for draft PR (default: true)\n * HEAL_STORE_PATH Path to healed-locators.json\n * HEAL_SEARCH_ROOTS Comma-separated dirs to search for selectors\n * HEAL_TARGET_REPO Git repo root\n * HEAL_PR_BASE_BRANCH Base branch (default: main)\n * ADO_PAT Azure DevOps PAT (ADO pipeline only)\n * GH_TOKEN / GITHUB_TOKEN GitHub token for `gh pr create`\n */\n\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { HealApplicator, HealRecord } from './HealApplicator';\n\nconst HEAL_STORE_PATH = path.resolve(process.env.HEAL_STORE_PATH ?? 'storage-state/healed-locators.json');\nconst AUTO_APPROVE = process.env.HEALIX_CI_AUTO_APPROVE === 'true';\nconst DRAFT_PR = process.env.HEALIX_CI_DRAFT_PR !== 'false';\nconst BASE_BRANCH = process.env.HEAL_PR_BASE_BRANCH ?? 'main';\n\nfunction readStore(): Record<string, HealRecord> {\n try { if (!fs.existsSync(HEAL_STORE_PATH)) return {}; return JSON.parse(fs.readFileSync(HEAL_STORE_PATH, 'utf8')); }\n catch { return {}; }\n}\n\nfunction writeStore(store: Record<string, HealRecord>): void {\n const dir = path.dirname(HEAL_STORE_PATH);\n if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });\n fs.writeFileSync(HEAL_STORE_PATH, JSON.stringify(store, null, 2), 'utf8');\n}\n\nfunction printSummaryTable(store: Record<string, HealRecord>): void {\n const keys = Object.keys(store);\n if (!keys.length) { console.log(' ℹ No heals in store — nothing to apply.'); return; }\n console.log('');\n console.log(' ┌──────────────────────────────────────────────────────────────────┐');\n console.log(' │ 🩺 HEALED LOCATORS SUMMARY │');\n console.log(' ├────────────────────┬─────────────┬──────────────────────────────');\n console.log(' │ Key │ Strategy │ Original → Healed');\n console.log(' ├────────────────────┼─────────────┼──────────────────────────────');\n for (const key of keys) {\n const r = store[key];\n console.log(` │ ${key.padEnd(18).slice(0,18)} │ ${(r.strategy ?? '?').padEnd(11).slice(0,11)} │ ${(r.originalSelector ?? '').slice(0,24)} → ${(r.healedSelector ?? '').slice(0,24)}`);\n }\n console.log(' └────────────────────┴─────────────┴──────────────────────────────');\n console.log('');\n}\n\nfunction emitCiAnnotations(\n applied: import('./HealApplicator').AppliedHeal[],\n store: Record<string, HealRecord>,\n prUrl: string,\n): void {\n const isAdo = !!process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI;\n const isGitHub = !!process.env.GITHUB_ACTIONS;\n\n if (isAdo) {\n console.log('##vso[build.addbuildtag]healix-healed');\n for (const a of applied) {\n console.log(`##vso[task.logissue type=warning]Healed \"${a.key}\": ${a.originalSelector} → ${a.healedSelector}`);\n }\n if (prUrl) console.log(`##vso[task.logissue type=warning]Heal PR: ${prUrl}`);\n console.log(`##vso[artifact.upload containerfolder=healix;artifactname=healed-locators]${HEAL_STORE_PATH}`);\n }\n\n if (isGitHub && process.env.GITHUB_STEP_SUMMARY) {\n const lines = [\n '## 🩺 Healix — Self-Healing Locator Report', '',\n `**${applied.length} locator(s) healed** during this test run.`,\n prUrl ? `\\n**PR:** ${prUrl}` : '', '',\n '| Key | Strategy | Original → Healed |', '|-----|----------|-------------------|',\n ...applied.map(a => `| \\`${a.key}\\` | ${store[a.key]?.strategy ?? '?'} | \\`${a.originalSelector}\\` → \\`${a.healedSelector}\\` |`),\n ];\n fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, lines.join('\\n') + '\\n');\n }\n}\n\nasync function main(): Promise<void> {\n console.log('');\n console.log(' 🩺 Healix CI — post-run heal processor');\n console.log(` Store: ${HEAL_STORE_PATH}`);\n console.log(` Auto-approve: ${AUTO_APPROVE}`);\n console.log('');\n\n const store = readStore();\n const total = Object.keys(store).length;\n if (total === 0) { console.log(' ✅ No heals recorded — all locators resolved natively.'); process.exit(0); }\n\n printSummaryTable(store);\n\n if (!AUTO_APPROVE) {\n console.log(' ℹ HEALIX_CI_AUTO_APPROVE not set — heals saved as pipeline artifact.');\n console.log(' To review locally:');\n console.log(' 1. Download healed-locators.json artifact');\n console.log(' 2. Place at storage-state/healed-locators.json');\n console.log(' 3. Run: npm run healix:review');\n console.log('');\n process.exit(0);\n }\n\n console.log(' 🔄 Auto-approving all heals…');\n for (const key of Object.keys(store)) store[key].approved = true;\n writeStore(store);\n\n const applicator = new HealApplicator();\n const result = applicator.apply(store);\n\n if (result.applied.length === 0) {\n console.log('');\n console.log(' ⚠ No selectors replaced in source files.');\n if (result.skipped.length) console.log(` Skipped: ${result.skipped.join(', ')}`);\n process.exit(0);\n }\n\n console.log(` ✅ Applied ${result.applied.length} heal(s):`);\n for (const a of result.applied) {\n console.log(` • ${a.key}: ${a.originalSelector} → ${a.healedSelector}`);\n console.log(` in ${path.relative(process.cwd(), a.file)}:${a.line}`);\n }\n\n let prUrl = '';\n try {\n const { execSync } = await import('child_process');\n const repo = process.env.HEAL_TARGET_REPO ?? process.cwd();\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: repo, stdio: 'pipe' }).toString().trim();\n\n try { run('git config user.email'); } catch {\n run('git config user.email \"healix-ci@pipeline\"');\n run('git config user.name \"Healix CI\"');\n }\n\n run(`git checkout -b ${branch}`);\n for (const f of result.changedFiles) { run(`git add \"${path.relative(repo, f)}\"`); }\n\n const prBody = [\n '## 🩺 AI-Healed Locator Fixes', '',\n '> Automatically generated by Healix. Review carefully before merging.', '',\n '| Key | Strategy | Original → Healed |', '|-----|----------|-------------------|',\n ...result.applied.map(a => `| \\`${a.key}\\` | ${store[a.key]?.strategy ?? '?'} | \\`${a.originalSelector}\\` → \\`${a.healedSelector}\\` |`),\n '', `**Files changed:** ${result.changedFiles.map(f => path.relative(repo, f)).join(', ')}`,\n ].join('\\n');\n\n run(`git commit -m \"${prTitle.replace(/\"/g, \"'\")}\"`);\n run(`git push origin ${branch}`);\n\n const isAdo = !!process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI;\n const isGitHub = !!process.env.GITHUB_ACTIONS;\n\n if (isAdo) {\n const orgUrl = (process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI ?? '').replace(/\\/$/, '');\n const project = process.env.SYSTEM_TEAMPROJECT ?? '';\n const repoId = process.env.BUILD_REPOSITORY_NAME ?? '';\n const pat = process.env.ADO_PAT ?? '';\n if (orgUrl && project && repoId && pat) {\n try {\n run(`az devops configure --defaults organization=\"${orgUrl}\" project=\"${project}\"`);\n const isDraftFlag = DRAFT_PR ? '--draft true' : '';\n prUrl = run(`az repos pr create --repository \"${repoId}\" --source-branch \"${branch}\" --target-branch \"${BASE_BRANCH}\" --title \"${prTitle.replace(/\"/g,\"'\")}\" --description \"${prBody.replace(/\"/g,\"'\").replace(/\\n/g,' ')}\" ${isDraftFlag} --query \"url\" --output tsv 2>&1`);\n if (!prUrl.includes('https://')) prUrl = '';\n } catch { /* skip PR creation */ }\n }\n } else if (isGitHub) {\n const isDraft = DRAFT_PR ? '--draft' : '';\n const esc = (s: string) => s.replace(/\"/g, '\\\\\"');\n prUrl = run(`gh pr create --title \"${esc(prTitle)}\" --body \"${esc(prBody).replace(/\\n/g,'\\\\n')}\" --base ${BASE_BRANCH} ${isDraft} 2>&1 || echo \"PR_FAILED\"`);\n if (prUrl.includes('PR_FAILED')) prUrl = '';\n }\n\n if (prUrl) console.log(` 🚀 PR created: ${prUrl}`);\n else { console.log(` ℹ Branch pushed: ${branch}`); console.log(' Create a PR manually.'); }\n\n } catch (err) {\n console.log(` ⚠ Files updated but PR creation failed: ${String(err)}`);\n }\n\n emitCiAnnotations(result.applied, store, prUrl);\n process.exit(0);\n}\n\nmain().catch(err => { console.error('Healix CI apply failed:', err); process.exit(1); });\n",
56
+ "src/utils/locators/index.ts": "export { LocatorRepository } from './LocatorRepository';\nexport type { LocatorEntry } from './LocatorRepository';\nexport { LocatorHealer } from './LocatorHealer';\nexport type { HealerLogger } from './LocatorHealer';\nexport { HealingDashboard } from './HealingDashboard';\nexport type { HealEvent } from './HealingDashboard';\nexport { HealApplicator } from './HealApplicator';\nexport type { HealRecord, AppliedHeal, ApplyResult } from './HealApplicator';\nexport { TimingHealer } from './TimingHealer';\nexport { VisualIntentChecker } from './VisualIntentChecker';\nexport { LocatorManager } from './LocatorManager';\nexport { ElementContextHelper } from './ElementContextHelper';\nexport { LocatorRules } from './LocatorRules';\nexport { LocatorStrategy } from './LocatorStrategy';\nexport { PlaywrightHealerLogger } from './PlaywrightHealerLogger';\n",
57
+ "src/utils/locators/review-server.ts": "/**\n * Healix Review Server — standalone post-run heal review tool\n *\n * Run after tests complete:\n * npm run healix:review\n *\n * Open http://localhost:7891 to:\n * • Review AI-healed selectors\n * • Approve / reject each one\n * • Apply approved heals to source files and create a GitHub / ADO PR\n *\n * Environment:\n * HEALIX_REVIEW_PORT Port (default: 7891)\n * HEAL_STORE_PATH Path to healed-locators.json\n * HEAL_SEARCH_ROOTS Comma-separated dirs to search for selectors\n * HEAL_TARGET_REPO Git repo root\n * HEAL_PR_TITLE PR title\n */\n\nimport * as http from 'http';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { HealApplicator, HealRecord } from './HealApplicator';\n\nconst PORT = parseInt(process.env.HEALIX_REVIEW_PORT ?? '7891', 10);\nconst HEAL_STORE_PATH = path.resolve(process.env.HEAL_STORE_PATH ?? 'storage-state/healed-locators.json');\n\nconst cors = {\n 'Access-Control-Allow-Origin': '*',\n 'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS',\n 'Access-Control-Allow-Headers': 'Content-Type',\n};\n\nfunction readStore(): Record<string, HealRecord> {\n try { if (!fs.existsSync(HEAL_STORE_PATH)) return {}; return JSON.parse(fs.readFileSync(HEAL_STORE_PATH, 'utf8')); }\n catch { return {}; }\n}\n\nfunction writeStore(store: Record<string, HealRecord>): void {\n const dir = path.dirname(HEAL_STORE_PATH);\n if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });\n fs.writeFileSync(HEAL_STORE_PATH, JSON.stringify(store, null, 2), 'utf8');\n}\n\nfunction readBody(req: http.IncomingMessage): Promise<string> {\n return new Promise(resolve => {\n const chunks: Buffer[] = [];\n req.on('data', c => chunks.push(c));\n req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));\n });\n}\n\nasync function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {\n const url = req.url ?? '/';\n const method = req.method ?? 'GET';\n\n if (method === 'OPTIONS') { res.writeHead(204, cors); res.end(); return; }\n\n if (url === '/api/store' && method === 'GET') {\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify(readStore(), null, 2)); return;\n }\n\n if (url === '/api/approve' && method === 'PATCH') {\n const { key, approved } = JSON.parse(await readBody(req)) as { key: string; approved: boolean };\n const store = readStore();\n if (store[key]) { store[key].approved = approved; writeStore(store); res.writeHead(200, { 'Content-Type': 'application/json', ...cors }); res.end(JSON.stringify({ ok: true, key, approved })); }\n else { res.writeHead(404, { 'Content-Type': 'application/json', ...cors }); res.end(JSON.stringify({ ok: false, error: `Key \"${key}\" not found` })); }\n return;\n }\n\n if (url === '/api/approve-all' && method === 'POST') {\n const { approved } = JSON.parse(await readBody(req)) as { approved: boolean };\n const store = readStore();\n for (const k of Object.keys(store)) store[k].approved = approved;\n writeStore(store);\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify({ ok: true, count: Object.keys(store).length })); return;\n }\n\n if (url === '/api/apply' && method === 'POST') {\n const store = readStore();\n const approved = Object.values(store).filter(r => r.approved);\n if (approved.length === 0) {\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify({ ok: false, message: 'No approved heals to apply' })); return;\n }\n const applicator = new HealApplicator();\n const result = applicator.apply(store);\n let prUrl = '', prError = '';\n if (result.changedFiles.length > 0) {\n try { prUrl = applicator.createPR(result.changedFiles, result.applied); }\n catch (err) { prError = String(err); }\n for (const a of result.applied) { if (store[a.key]) delete store[a.key].approved; }\n writeStore(store);\n }\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify({\n ok: result.errors.length === 0, applied: result.applied, skipped: result.skipped,\n skippedDetails: Object.fromEntries(result.skipped.filter(k => store[k]).map(k => [k, { originalSelector: store[k].originalSelector, healedSelector: store[k].healedSelector }])),\n errors: result.errors, prUrl, prError, changedFiles: result.changedFiles,\n }, null, 2)); return;\n }\n\n if (url === '/api/clear' && method === 'POST') {\n writeStore({});\n res.writeHead(200, { 'Content-Type': 'application/json', ...cors });\n res.end(JSON.stringify({ ok: true })); return;\n }\n\n res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });\n res.end(buildReviewHtml());\n}\n\nfunction escHtml(s: string): string {\n return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;').replace(/'/g,'&#39;');\n}\n\nfunction buildRow(record: HealRecord, key: string): string {\n const stratClass = ['ai-vision','ax-tree','role','label','text','cached'].includes(record.strategy) ? record.strategy : '';\n const rowClass = record.approved === true ? 'approved' : record.approved === false ? 'rejected' : '';\n const dotClass = record.approved === true ? 'approved' : record.approved === false ? 'rejected' : 'pending';\n const dateShort = record.lastHealedAt ? new Date(record.lastHealedAt).toLocaleString() : '';\n return `<tr class=\"${rowClass}\" data-key=\"${escHtml(key)}\" data-approved=\"${String(!!record.approved)}\">\n <td><span class=\"status-dot ${dotClass}\"></span></td>\n <td><code>${escHtml(key)}</code></td>\n <td><span class=\"badge ${stratClass}\">${escHtml(record.strategy)}</span></td>\n <td><code style=\"color:var(--muted)\">${escHtml(record.originalSelector)}</code></td>\n <td><code style=\"color:var(--green);font-weight:600\">${escHtml(record.healedSelector)}</code></td>\n <td style=\"font-size:12px;color:var(--muted);max-width:220px\">${escHtml(record.intent)}<br><span style=\"font-size:10px\">${dateShort}</span></td>\n <td style=\"text-align:center\">${record.healCount}</td>\n <td style=\"white-space:nowrap\">\n <button class=\"approve-btn\" onclick=\"setApproved('${escHtml(key)}', true)\">✓ Approve</button>\n &nbsp;<button class=\"reject-btn\" onclick=\"setApproved('${escHtml(key)}', false)\">✗ Reject</button>\n </td>\n </tr>`;\n}\n\nfunction buildReviewHtml(): string {\n const store = readStore();\n const keys = Object.keys(store);\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"UTF-8\"><title>Healix — Heal Review</title>\n<style>\n:root{--bg:#0d1117;--surface:#161b22;--border:#30363d;--accent:#58a6ff;--green:#3fb950;--red:#f85149;--yellow:#d2994a;--purple:#bc8cff;--muted:#8b949e;--text:#e6edf3}\n*{box-sizing:border-box;margin:0;padding:0}\nbody{background:var(--bg);color:var(--text);font:14px/1.5 'Segoe UI',system-ui,sans-serif;padding:24px}\nheader{display:flex;align-items:center;gap:16px;margin-bottom:32px;border-bottom:1px solid var(--border);padding-bottom:16px}\nheader h1{font-size:20px;font-weight:600}.subtitle{color:var(--muted);font-size:13px}\n.toolbar{display:flex;gap:12px;align-items:center;margin-bottom:20px;flex-wrap:wrap}\nbutton{cursor:pointer;border:none;border-radius:6px;padding:7px 16px;font:inherit;font-weight:600;font-size:13px;transition:opacity .15s}\nbutton:hover{opacity:.85}\n.btn-approve-all{background:rgba(63,185,80,.15);color:var(--green);border:1px solid var(--green)}\n.btn-reject-all{background:rgba(248,81,73,.1);color:var(--red);border:1px solid var(--red)}\n.btn-apply{background:var(--accent);color:#0d1117;font-size:14px;padding:9px 22px}\n.btn-apply:disabled{opacity:.4;cursor:not-allowed}\n.btn-clear{background:rgba(248,81,73,.1);color:var(--red);border:1px solid var(--red);margin-left:auto}\n.count-badge{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:2px 10px;font-size:12px;color:var(--muted)}\ntable{width:100%;border-collapse:collapse;font-size:13px}\nth{text-align:left;padding:10px 14px;border-bottom:2px solid var(--border);color:var(--muted);font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}\ntd{padding:10px 14px;border-bottom:1px solid var(--border);vertical-align:top}\ntr:last-child td{border-bottom:none}tr.approved td{background:rgba(63,185,80,.04)}tr.rejected td{background:rgba(248,81,73,.04);opacity:.6}\ncode{font-family:monospace;font-size:11px;background:rgba(255,255,255,.05);border-radius:4px;padding:2px 6px;word-break:break-all}\n.badge{display:inline-block;border-radius:4px;padding:2px 8px;font-size:11px;font-weight:600}\n.badge.ai-vision{background:rgba(188,140,255,.15);color:var(--purple)}.badge.ax-tree{background:rgba(210,153,34,.15);color:var(--yellow)}\n.badge.role,.badge.label,.badge.text{background:rgba(63,185,80,.1);color:var(--green)}\n.approve-btn{background:rgba(63,185,80,.12);color:var(--green);border:1px solid rgba(63,185,80,.3);padding:4px 12px;font-size:12px;border-radius:4px}\n.reject-btn{background:rgba(248,81,73,.1);color:var(--red);border:1px solid rgba(248,81,73,.3);padding:4px 12px;font-size:12px;border-radius:4px}\n.status-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px}\n.status-dot.approved{background:var(--green)}.status-dot.rejected{background:var(--red)}.status-dot.pending{background:var(--muted)}\n#pr-result{margin-top:24px;padding:16px;border-radius:8px;border:1px solid var(--border);font-size:13px;display:none}\n#pr-result.success{border-color:var(--green);background:rgba(63,185,80,.06)}#pr-result.error{border-color:var(--red);background:rgba(248,81,73,.06)}\n.empty{text-align:center;padding:48px;color:var(--muted)}\n</style>\n</head>\n<body>\n<header>\n <span style=\"font-size:28px\">🩺</span>\n <div><h1>Healix — Heal Review</h1><div class=\"subtitle\">Review AI-healed selectors, approve what's valid, then apply to the repo and create a PR</div></div>\n <span class=\"count-badge\" id=\"approved-count\">0 approved</span>\n</header>\n<div class=\"toolbar\">\n <button class=\"btn-approve-all\" onclick=\"approveAll(true)\">✓ Approve all</button>\n <button class=\"btn-reject-all\" onclick=\"approveAll(false)\">✗ Reject all</button>\n <button class=\"btn-apply\" id=\"apply-btn\" onclick=\"applyHeals()\" disabled>🚀 Apply approved &amp; create PR</button>\n <button class=\"btn-clear\" onclick=\"clearAll()\">🗑 Clear all heals</button>\n</div>\n<table>\n <thead><tr><th>Status</th><th>Key</th><th>Strategy</th><th>Original selector</th><th>Healed selector ✓</th><th>Intent</th><th>Heals</th><th>Actions</th></tr></thead>\n <tbody id=\"heal-body\">\n ${keys.length === 0 ? '<tr><td colspan=\"8\" class=\"empty\">No healed selectors in store — run your tests first</td></tr>' : keys.map(k => buildRow(store[k], k)).join('')}\n </tbody>\n</table>\n<div id=\"pr-result\"></div>\n<script>\nfunction escHtml(s){return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;').replace(/'/g,'&#39;');}\nfunction updateCount(){const rows=document.querySelectorAll('#heal-body tr[data-key]');let c=0;rows.forEach(r=>{if(r.dataset.approved==='true')c++;});document.getElementById('approved-count').textContent=c+' approved';document.getElementById('apply-btn').disabled=c===0;}\nasync function setApproved(key,approved){await fetch('/api/approve',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({key,approved})});const row=document.querySelector('tr[data-key=\"'+CSS.escape(key)+'\"]');if(row){row.dataset.approved=approved;row.className=approved?'approved':'rejected';const dot=row.querySelector('.status-dot');dot.className='status-dot '+(approved?'approved':'rejected');}updateCount();}\nasync function approveAll(approved){await fetch('/api/approve-all',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({approved})});location.reload();}\nasync function applyHeals(){const btn=document.getElementById('apply-btn');btn.disabled=true;btn.textContent='⏳ Applying…';const panel=document.getElementById('pr-result');panel.style.display='none';try{const d=await(await fetch('/api/apply',{method:'POST'})).json();if(d.prUrl){panel.className='success';panel.style.display='block';panel.innerHTML='✅ PR created: <a href=\"'+escHtml(d.prUrl)+'\" target=\"_blank\" style=\"color:var(--accent)\">'+escHtml(d.prUrl)+'</a>';setTimeout(()=>location.reload(),3000);}else if(d.changedFiles&&d.changedFiles.length>0){panel.className='success';panel.style.display='block';panel.innerHTML='✅ '+d.applied.length+' file(s) updated locally.'+(d.prError?'<br>⚠ PR failed: <code>'+escHtml(d.prError)+'</code>':'');setTimeout(()=>location.reload(),3000);}else if(d.skipped&&d.skipped.length>0){panel.className='error';panel.style.display='block';panel.innerHTML='⚠ '+d.skipped.length+' heal(s) skipped — original selector not found in source files:<br>'+d.skipped.map(k=>'<code>'+escHtml(k)+'</code>').join('<br>');btn.disabled=false;btn.textContent='🚀 Apply approved & create PR';}else{panel.className='error';panel.style.display='block';panel.innerHTML='⚠ '+(d.message??'Nothing was applied.');btn.disabled=false;btn.textContent='🚀 Apply approved & create PR';}}catch(err){panel.className='error';panel.style.display='block';panel.innerHTML='✖ '+escHtml(String(err));btn.disabled=false;btn.textContent='🚀 Apply approved & create PR';}}\nasync function clearAll(){if(!confirm('Clear all stored heals?'))return;await fetch('/api/clear',{method:'POST'});location.reload();}\nupdateCount();\n</script>\n</body></html>`;\n}\n\nconst server = http.createServer((req, res) => {\n handleRequest(req, res).catch(err => { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end(String(err)); });\n});\n\nserver.listen(PORT, '127.0.0.1', () => {\n console.log('');\n console.log(' ╔══════════════════════════════════════════════════════════════╗');\n console.log(` ║ 🩺 Healix Review Server → http://localhost:${PORT} ║`);\n console.log(' ║ Review AI-healed selectors, approve what is valid, ║');\n console.log(' ║ then click \"Apply approved & create PR\". ║');\n console.log(' ║ Ctrl+C to exit after reviewing. ║');\n console.log(' ╚══════════════════════════════════════════════════════════════╝');\n console.log('');\n const total = Object.keys(readStore()).length;\n if (total === 0) console.log(' ⚠ No heals in store yet — run your tests first.');\n else console.log(` 📋 ${total} healed selector(s) ready for review`);\n console.log('');\n});\n\nserver.on('error', (err: NodeJS.ErrnoException) => {\n if (err.code === 'EADDRINUSE') console.error(` ✖ Port ${PORT} in use. Set HEALIX_REVIEW_PORT.`);\n else console.error(` ✖ Server error: ${err.message}`);\n process.exit(1);\n});\n",
58
+ "src/utils/storage-state/AuthManager.ts": "import { Browser, BrowserContext, Page } from '@playwright/test';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { logger } from '@utils/helpers/Logger';\nimport { environment } from '@config/environment';\n\nexport interface AuthCredentials {\n username: string;\n password: string;\n role?: string;\n}\n\n/**\n * AuthManager — manages browser storage-state for authenticated sessions.\n *\n * Usage:\n * const auth = new AuthManager(browser);\n * await auth.login(); // saves storage-state to disk\n * const ctx = await auth.createAuthenticatedContext();\n *\n * Customize `performLoginActions()` and `waitForLoginSuccess()` to match your app's\n * login flow and post-login indicators.\n */\nexport class AuthManager {\n private storageStatePath: string;\n\n constructor(private readonly browser: Browser) {\n const env = environment.getConfig();\n this.storageStatePath = env.storageStatePath;\n const dir = path.dirname(this.storageStatePath);\n if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });\n }\n\n async login(credentials?: AuthCredentials): Promise<void> {\n const env = environment.getConfig();\n const creds = credentials ?? { username: env.authUsername, password: env.authPassword };\n logger.info(`Performing login for user: ${creds.username}`);\n\n const context = await this.browser.newContext();\n const page = await context.newPage();\n try {\n await page.goto(`${env.baseUrl}/login`, { timeout: env.navigationTimeout, waitUntil: 'domcontentloaded' });\n await this.performLoginActions(page, creds);\n await this.waitForLoginSuccess(page);\n await this.saveStorageState(context);\n logger.info('Login successful — storage state saved');\n } catch (err) {\n logger.error(`Login failed: ${String(err)}`);\n throw err;\n } finally {\n await context.close();\n }\n }\n\n /**\n * Customize this method for your application's login form.\n */\n private async performLoginActions(page: Page, credentials: AuthCredentials): Promise<void> {\n const env = environment.getConfig();\n await page.fill('input[name=\"username\"], input[type=\"email\"], #username', credentials.username, { timeout: env.actionTimeout });\n await page.fill('input[name=\"password\"], input[type=\"password\"], #password', credentials.password, { timeout: env.actionTimeout });\n await page.click('button[type=\"submit\"], button:has-text(\"Login\"), button:has-text(\"Sign in\")', { timeout: env.actionTimeout });\n logger.debug('Login form submitted');\n }\n\n /**\n * Customize this method to detect successful login in your application.\n */\n private async waitForLoginSuccess(page: Page): Promise<void> {\n const env = environment.getConfig();\n try {\n await Promise.race([\n page.waitForURL(/dashboard|home|app/, { timeout: env.authTimeout }),\n page.waitForSelector('[data-testid=\"user-menu\"], .user-profile, #dashboard', { timeout: env.authTimeout }),\n ]);\n } catch {\n if (page.url().includes('login')) throw new Error('Login failed — still on login page');\n throw new Error('Login success detection timed out');\n }\n }\n\n async saveStorageState(context: BrowserContext, customPath?: string): Promise<void> {\n const target = customPath ?? this.storageStatePath;\n await context.storageState({ path: target });\n logger.info(`Storage state saved: ${target}`);\n }\n\n async loadStorageState(customPath?: string): Promise<any> {\n const target = customPath ?? this.storageStatePath;\n if (!fs.existsSync(target)) { logger.warn(`Storage state not found: ${target}`); return null; }\n try {\n const state = JSON.parse(fs.readFileSync(target, 'utf-8'));\n logger.info(`Storage state loaded: ${target}`);\n return state;\n } catch (err) { logger.error(`Failed to load storage state: ${String(err)}`); return null; }\n }\n\n async isStorageStateValid(customPath?: string): Promise<boolean> {\n const target = customPath ?? this.storageStatePath;\n if (!fs.existsSync(target)) return false;\n try {\n const state = JSON.parse(fs.readFileSync(target, 'utf-8'));\n if (!state?.cookies?.length) return false;\n const now = Date.now();\n return state.cookies.some((c: any) => !c.expires || c.expires * 1_000 > now);\n } catch { return false; }\n }\n\n async createAuthenticatedContext(forceLogin = false): Promise<BrowserContext> {\n if (forceLogin || !(await this.isStorageStateValid())) {\n logger.info('Creating fresh login session');\n await this.login();\n }\n const state = await this.loadStorageState();\n const context = await this.browser.newContext({ storageState: state, viewport: { width: 1920, height: 1080 } });\n logger.info('Authenticated context created');\n return context;\n }\n\n async createAuthenticatedPage(forceLogin = false): Promise<Page> {\n const ctx = await this.createAuthenticatedContext(forceLogin);\n return ctx.newPage();\n }\n\n async verifyAuthentication(page: Page): Promise<boolean> {\n const indicators = [\n page.locator('[data-testid=\"user-menu\"]'),\n page.locator('.user-profile'),\n page.locator('#logout-button'),\n page.locator('button:has-text(\"Logout\")'),\n ];\n for (const loc of indicators) {\n try { await loc.waitFor({ timeout: 3_000 }); return true; } catch { /* try next */ }\n }\n if (page.url().includes('login')) { logger.warn('Not authenticated — on login page'); return false; }\n return false;\n }\n\n async logout(page: Page): Promise<void> {\n await page.click('button:has-text(\"Logout\"), #logout, [data-testid=\"logout\"]', { timeout: 5_000 });\n await page.waitForURL(/login/, { timeout: 10_000 });\n if (fs.existsSync(this.storageStatePath)) { fs.unlinkSync(this.storageStatePath); logger.info('Storage state cleared'); }\n }\n\n getStorageStatePath(): string { return this.storageStatePath; }\n setStorageStatePath(p: string): void { this.storageStatePath = p; }\n}\n",
59
+ "src/utils/storage-state/AuthSetup.ts": "import { chromium } from '@playwright/test';\nimport { AuthManager } from './AuthManager';\nimport { logger } from '@utils/helpers/Logger';\nimport { environment } from '@config/environment';\n\n/**\n * AuthSetup — CLI script to pre-authenticate and save browser storage state.\n *\n * Run once before your test suite to avoid logging in on every scenario:\n * npm run auth:setup\n *\n * The saved storage-state file is loaded automatically by hooks.ts via the\n * `STORAGE_STATE_PATH` environment variable.\n */\nasync function setupAuth(): Promise<void> {\n const env = environment.getConfig();\n logger.info('Starting authentication setup');\n logger.info(`Environment : ${env.environment}`);\n logger.info(`Base URL : ${env.baseUrl}`);\n\n const browser = await chromium.launch({ headless: false });\n try {\n const authManager = new AuthManager(browser);\n await authManager.login();\n logger.info('✅ Authentication setup completed');\n logger.info(` Storage state: ${authManager.getStorageStatePath()}`);\n\n const isValid = await authManager.isStorageStateValid();\n if (isValid) {\n logger.info('✅ Storage state validated');\n } else {\n logger.error('❌ Storage state validation failed');\n process.exit(1);\n }\n } catch (err) {\n logger.error(`❌ Authentication setup failed: ${String(err)}`);\n process.exit(1);\n } finally {\n await browser.close();\n }\n}\n\nif (require.main === module) {\n setupAuth()\n .then(() => process.exit(0))\n .catch(err => { logger.error(`Unhandled error: ${String(err)}`); process.exit(1); });\n}\n\nexport { setupAuth };\n",
60
+ "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"commonjs\",\n \"lib\": [\"ES2022\"],\n \"outDir\": \"./dist\",\n \"strict\": true,\n \"esModuleInterop\": true,\n \"skipLibCheck\": true,\n \"forceConsistentCasingInFileNames\": true,\n \"resolveJsonModule\": true,\n \"moduleResolution\": \"node\",\n \"declaration\": true,\n \"declarationMap\": true,\n \"sourceMap\": true,\n \"noUnusedLocals\": true,\n \"noUnusedParameters\": true,\n \"noImplicitReturns\": true,\n \"noFallthroughCasesInSwitch\": true,\n \"allowSyntheticDefaultImports\": true,\n \"experimentalDecorators\": true,\n \"emitDecoratorMetadata\": true,\n \"types\": [\"node\", \"@types/node\"],\n \"rootDir\": \"src\",\n \"baseUrl\": \".\",\n \"paths\": {\n \"@hooks/*\": [\"src/hooks/*\"],\n \"@pages/*\": [\"src/pages/*\"],\n \"@utils/*\": [\"src/utils/*\"],\n \"@test/*\": [\"src/test/*\"],\n \"@features/*\": [\"src/test/features/*\"],\n \"@steps/*\": [\"src/test/steps/*\"],\n \"@locators/*\": [\"src/locators/*\"],\n \"@fixtures/*\": [\"src/fixtures/*\"]\n }\n },\n \"include\": [\n \"src/**/*\",\n \"src/test/features/**/*\",\n \"src/test/steps/**/*.ts\"\n ],\n \"exclude\": [\"node_modules\", \"dist\", \"test-results\", \"src/templates\"],\n \"ts-node\": {\n \"require\": [\"tsconfig-paths/register\"],\n \"transpileOnly\": true,\n \"files\": true,\n \"compilerOptions\": {\n \"module\": \"commonjs\"\n }\n }\n}\n",
61
+ }
62
+
63
+ # fmt: on
64
+
65
+ # Subset used for infra scaffolding (src/utils/locators/)
66
+ INFRA_FILES = {
67
+ k.removeprefix("src/utils/locators/"): v
68
+ for k, v in BOILERPLATE.items()
69
+ if k.startswith("src/utils/locators/")
70
+ }