# playwright-healer

[![PyPI version](https://img.shields.io/pypi/v/playwright-healer.svg)](https://pypi.org/project/playwright-healer/)
[![Python](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
[![Tests](https://img.shields.io/badge/tests-passing-brightgreen.svg)]()

**Production-grade self-healing locator engine for Playwright Python.**

Automatically heals broken element selectors caused by UI changes — with a
multi-tier pipeline that's **faster, cheaper, and more reliable** than any
other self-healing library.

---

## Why playwright-healer vs the competition?

| Feature | playwright-healer | Competition |
|---|---|---|
| **Heuristic pre-AI healing** | ✅ Free, instant, catches ~70% of renames | ❌ Calls AI immediately |
| **DOM fuzzy match** | ✅ Free, no API calls | ❌ Not present |
| **Provider fallback chain** | ✅ All configured providers used as fallbacks | ❌ Single provider — one outage = failure |
| **ARIA-first healed selectors** | ✅ Returns `get_by_role`, `get_by_text` — stable | ❌ Returns brittle CSS |
| **HealingPage wrapper** | ✅ Zero boilerplate, wrap the page object | ❌ Requires separate adapter class |
| **Playwright-native API** | ✅ Returns `Locator` objects | ❌ Returns raw elements |
| **Shadow DOM support** | ✅ Built-in | ❌ Not supported |
| **Adaptive cache TTL** | ✅ Stable selectors cached longer | ❌ Fixed TTL only |
| **Confidence scoring** | ✅ Ranked candidates per healing attempt | ❌ First match wins |
| **pytest plugin** | ✅ Zero conftest — `healing_page` fixture auto-registers | ❌ Manual conftest required |
| **Async context manager** | ✅ `async with HealingPage(page) as hp:` | ❌ Not supported |
| **Pure Playwright focus** | ✅ Playwright-only, first-class support | ❌ Shared codebase with Selenium |
| **Structured AI prompts** | ✅ JSON schema enforced, robust parsing | ❌ Regex-based extraction |

---

## How It Works

```
Test calls hp.click("#selector", "description")
           │
           ▼
┌─────────────────────┐
│  0. Try Original    │──── Found ──────────────────────► Return Locator
└─────────────────────┘
           │ NOT FOUND (quick_timeout_ms = 500ms)
           ▼
┌─────────────────────┐
│  1. Check Cache     │──── HIT ──► validate ───────────► Return Locator
└─────────────────────┘              │ broken (mark miss)
           │ miss                    └── continue →
           ▼
┌─────────────────────┐
│  2. Heuristic       │──── Found ────────────────────► Cache + Return
│  • ID suffix swaps  │   FREE  ~0ms     70% hit rate
│  • Class Jaccard    │
│  • Fuzzy attributes │
│  • Text match       │
│  • ARIA role match  │
└─────────────────────┘
           │ no candidates matched
           ▼
┌─────────────────────┐
│  3. DOM Fuzzy Match │──── Found ────────────────────► Cache + Return
│  • rapidfuzz score  │   FREE  ~50ms
│  • All attributes   │
│  • Fingerprinting   │
└─────────────────────┘
           │ none pass
           ▼
┌─────────────────────┐
│  4. AI — DOM        │──── Found ────────────────────► Cache + Return
│  • Structured prompt│   PAID  ~1-3s   Provider chain
│  • Ranked candidates│           ↑ auto-fallback
│  • Confidence scores│
└─────────────────────┘
           │ (FULL strategy only)
           ▼
┌─────────────────────┐
│  5. AI — Visual     │──── Found ────────────────────► Cache + Return
│  • Screenshot + AI  │   PAID  ~2-5s
│  • Vision model     │
└─────────────────────┘
           │ all failed
           ▼
    ElementNotFoundError
```

---

## Installation

```bash
# Core (Playwright required to be installed separately)
pip install playwright-healer

# With Redis cache support
pip install playwright-healer[redis]

# Install playwright browsers
playwright install chromium
```

---

## Zero-Config Quick Start

### Step 1 — Set one API key

```bash
# .env  (Groq is free — get key at console.groq.com)
GROQ_API_KEY=gsk_your_key_here
```

### Step 2 — Use HealingPage (zero boilerplate)

```python
from playwright.async_api import async_playwright
from playwright_healer import HealingPage, auto_config

async def test_something():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()

        async with HealingPage(page, auto_config()) as hp:
            await hp.goto("https://example.com/login")

            # Broken selectors — healed automatically
            await hp.fill("#user-name-BROKEN", "Username field", value="user")
            await hp.fill("#password-BROKEN",  "Password field", value="pass")
            await hp.click("#login-button-BROKEN", "Login button")

            assert "dashboard" in hp.url
```

### Step 3 — With pytest (no conftest.py required)

```python
# test_login.py — no conftest, no imports from playwright_healer needed

async def test_login(healing_page):           # ← fixture auto-provided
    await healing_page.goto("https://www.saucedemo.com")
    await healing_page.fill("#user-name-BROKEN", "Username field", value="standard_user")
    await healing_page.fill("#password-BROKEN",  "Password field", value="secret_sauce")
    await healing_page.click("#login-button-BROKEN", "Login button")
    assert "inventory" in healing_page.url    # ← passes even with broken selectors
```

Run with:

```bash
pytest tests/ -v
# Or with custom strategy:
pytest tests/ --ph-strategy DOM_ONLY -v
# Or disable AI (heuristic only):
pytest tests/ --ph-no-heal -v
```

---

## API Reference

### `HealingPage`

The primary interface — wraps a Playwright `Page`.

```python
from playwright_healer import HealingPage, auto_config

hp = HealingPage(page, auto_config(), test_name="my_test")

# Navigation
await hp.goto("https://example.com")
await hp.reload()
await hp.go_back()
await hp.wait_for_load_state("networkidle")
await hp.wait_for_url("**/dashboard")

# Element interactions (selector, description, ...kwargs)
await hp.click("#submit-btn", "Submit button")
await hp.fill("#email", "Email field", value="user@example.com")
await hp.type_text("#search", "Search input", text="playwright")
await hp.check("#agree-checkbox", "Terms checkbox")
await hp.select("#country", "Country dropdown", value="US")
await hp.hover("#menu", "Navigation menu")

# Queries
text    = await hp.get_text(".page-title", "Page title")
attr    = await hp.get_attribute("#link", "href", "Navigation link")
visible = await hp.is_visible("#banner", "Banner")
present = await hp.is_present("#promo", "Promo section")
count   = await hp.count(".card", "Product cards")

# Return HealingLocator (lazy resolve on first action)
locator = hp.locator("#submit", "Submit button")
await locator.click()
await locator.fill("value")

# ARIA convenience
role_loc = hp.get_by_role("button", name="Login", description="Login button")
await role_loc.click()

# More queries
all_cards = await hp.find_all(".card", "Product cards")    # returns list of HealingLocators
await hp.uncheck("#newsletter", "Newsletter checkbox")

# ARIA / semantic locators (no healing needed — already stable)
role_loc  = hp.get_by_role("button", name="Login", description="Login button")
text_loc  = hp.get_by_text("Sign in")
label_loc = hp.get_by_label("Password")
ph_loc    = hp.get_by_placeholder("Enter email")
tid_loc   = hp.get_by_test_id("submit-btn")

# Page utilities
await hp.go_forward()
await hp.screenshot(path="shot.png")
title = await hp.title()
val   = await hp.evaluate("document.title")
await hp.set_viewport_size(1920, 1080)
await hp.close()

# Internal access
raw  = hp.raw_page        # underlying Playwright Page
report = hp.session_report  # SessionReport object for custom processing

# Lifecycle
await hp.shutdown()      # generates reports, prints summary
```

### `HealingLocator`

A transparent wrapper that heals on first action and then forwards all Playwright Locator methods.

```python
locator = hp.locator("#submit", "Submit button")

# All standard Playwright Locator methods work
await locator.click()
await locator.fill("value")
await locator.check()
await locator.uncheck()
await locator.is_visible()
await locator.inner_text()
await locator.get_attribute("href")
await locator.count()
await locator.all()
await locator.screenshot()
await locator.evaluate("el => el.value")
await locator.wait_for()
await locator.nth(0)
```

---

## Configuration

### Environment Variables (Recommended)

```bash
# AI Providers — configure one or more (all become fallbacks)
GROQ_API_KEY=gsk_your_key                 # Free — recommended
GEMINI_API_KEY=AIza_your_key              # Free tier
OPENAI_API_KEY=sk-proj-your_key           # Paid
ANTHROPIC_API_KEY=sk-ant-your_key         # Paid
DEEPSEEK_API_KEY=your_key                 # Cheap
PH_API_URL=http://localhost:11434/v1/...  # Local (Ollama/LM Studio)

# Healing strategy
PH_STRATEGY=SMART          # SMART | HEURISTIC_ONLY | DOM_ONLY | VISUAL_ONLY | FULL | PARALLEL

# Timeouts
PH_QUICK_TIMEOUT_MS=500    # How long to try original selector before healing
PH_ELEMENT_TIMEOUT_MS=10000

# Cache
PH_CACHE_BACKEND=FILE      # FILE | MEMORY | REDIS
PH_CACHE_TTL_HOURS=48
PH_CACHE_FILE=.healer_cache.json

# Features
PH_PREFER_ARIA=true        # Heal to get_by_role/text over CSS (recommended)
PH_SHADOW_DOM=true         # Penetrate shadow roots
PH_ADAPTIVE_TTL=true       # Longer TTL for stable selectors

# Reporting
PH_REPORT_DIR=playwright-healer-reports
PH_CONSOLE_LOG=true

# Source auto-patcher (rewrites broken selectors in your test files automatically)
PH_AUTO_PATCH_SOURCE=false   # Set to true to enable
```

### Programmatic Configuration

```python
from playwright_healer import HealerConfig, AIProviderConfig, CacheConfig
from playwright_healer.config import AIProvider, CacheBackend, HealingStrategy

config = (
    HealerConfig.builder()
    .add_provider(
        AIProviderConfig.builder()
        .provider(AIProvider.GROQ)
        .api_key("gsk_...")
        .model("llama-3.3-70b-versatile")
        .build()
    )
    .add_provider(                              # ← fallback
        AIProviderConfig.builder()
        .provider(AIProvider.OPENAI)
        .api_key("sk-proj-...")
        .build()
    )
    .strategy(HealingStrategy.SMART)
    .quick_timeout_ms(500)
    .prefer_aria(True)
    .cache(
        CacheConfig.builder()
        .backend(CacheBackend.FILE)
        .ttl_hours(48.0)
        .build()
    )
    .report_dir("reports")
    .adaptive_ttl(True)
    .auto_patch_source(False)   # Set True to rewrite broken selectors in source files
    .build()
)
```

---

## Healing Strategies

| Strategy | Cost | Speed | When to use |
|---|---|---|---|
| `SMART` | Low | Fast | Default — heuristic → DOM fuzzy → AI DOM |
| `HEURISTIC_ONLY` | Zero | Instant | Local dev; when AI not configured |
| `DOM_ONLY` | Low | Fast | CI/CD with cost constraints |
| `VISUAL_ONLY` | Medium | Medium | Highly visual UIs |
| `FULL` | High | Slower | Maximum healing power |
| `PARALLEL` | High | Fast healing | Speed-critical scenarios |

---

## AI Providers

All discovered keys become a **fallback chain** — if Groq rate-limits or goes
down, `playwright-healer` automatically retries with the next provider.
This is **unique** to playwright-healer.

| Provider | Vision | Cost | Notes |
|---|---|---|---|
| Groq | ❌ | Free | Fast, recommended for start |
| Google Gemini | ✅ | Free/Low | Free tier available |
| OpenAI | ✅ | Medium | gpt-4o-mini cheapest; gpt-4o for vision |
| Anthropic | ✅ | Medium | Haiku for text; Sonnet for vision |
| DeepSeek | ❌ | Very Low | Good for DOM-only |
| Local (Ollama) | Depends | Free | Private, no data sent externally |

---

## Cache Configuration

```python
# File (default) — survives restarts
CacheConfig.builder().backend(CacheBackend.FILE).ttl_hours(48).build()

# Memory — fastest, ephemeral
CacheConfig.builder().backend(CacheBackend.MEMORY).max_size(1000).build()

# Redis — shared across parallel CI workers
CacheConfig.builder().redis(host="localhost", port=6379).build()
```

**Adaptive TTL** — a key feature that competitors lack:

- Selectors healed consistently (high hit ratio) → TTL extended up to 30 days
- Selectors that keep breaking (high miss ratio) → TTL shortened to 1 hour
- Prevents stale healings accumulating in CI

---

## Reports

After `shutdown()` or context manager exit, playwright-healer generates:

- **Rich terminal summary** with coloured output
- **HTML report** — dark-themed, self-contained
- **JSON report** — machine-readable, CI artifact friendly

Console output example:
```
  ✓ ORIGINAL  #user-name
  ✓ HEALED  HEURISTIC  #user-name-BROKEN → #user-name  12ms  conf=95%
  ✓ HEALED  CACHE      #password-BROKEN  → #password    2ms  conf=90%
  ✓ HEALED  AI_DOM     #btn-BROKEN       → button::Login  1240ms [820t]  conf=88%
  ✗ FAILED  #nonexistent

  ┌─────────────────────────────────────────────┐
  │     playwright-healer Session Summary        │
  ├──────────────────────┬──────────────────────┤
  │ Total lookups        │ 4                    │
  │ Healed (non-trivial) │ 3                    │
  │ From cache           │ 1                    │
  │ Failed               │ 1                    │
  │ AI tokens used       │ 820                  │
  │ Avg latency          │ 313 ms               │
  └──────────────────────┴──────────────────────┘
```

---

## pytest CLI Options

```bash
pytest tests/                                     # default SMART strategy
pytest tests/ --ph-strategy HEURISTIC_ONLY       # free, instant
pytest tests/ --ph-strategy DOM_ONLY             # AI with DOM context only
pytest tests/ --ph-strategy FULL                 # maximum healing
pytest tests/ --ph-no-heal                       # disable AI (heuristic only)
pytest tests/ --ph-report-dir ./reports          # custom report dir
pytest tests/ --ph-cache-file ./my.cache.json    # custom cache file
pytest tests/ --ph-auto-patch-source             # rewrite broken selectors in test files
```

---

## Project Structure

```
playwright_healer/
├── __init__.py          # Public API: HealingPage, HealingLocator, auto_config
├── config.py            # HealerConfig, AIProviderConfig, CacheConfig, enums
├── auto_config.py       # Zero-config auto-detection from env vars
├── healer.py            # HealingPage — primary user-facing class
├── locator.py           # HealingLocator — transparent Locator wrapper
├── pipeline.py          # HealingPipeline — orchestrates all stages
├── heuristic.py         # Stage 2: free heuristic mutations (ID/class/text/ARIA)
├── ai_providers.py      # Stage 4/5: OpenAI-compat, Gemini, Anthropic + ProviderChain
├── cache.py             # Memory, File, Redis cache backends + adaptive TTL
├── reporting.py         # Rich console, HTML, JSON reporters + data models
├── source_patcher.py    # Auto-patches broken selectors back into test source files
├── utils.py             # Selector detection, DOM stripping, fingerprinting
└── pytest_plugin.py     # pytest plugin — healing_page + healing_config fixtures
```

---

## Best Practices

**Use descriptive element descriptions** — the description is sent to the AI:

```python
# Vague — AI has little context
await hp.click("#btn-1", "button")

# Specific — AI understands exactly what to find
await hp.click("#btn-1", "Primary submit button on the checkout confirmation page")
```

**Configure multiple providers** as fallbacks:

```bash
GROQ_API_KEY=gsk_...      # primary (free)
OPENAI_API_KEY=sk-...     # fallback (if Groq rate-limits)
```

**Use `SMART` strategy in CI** — cost-effective, heuristic fires first:

```bash
PH_STRATEGY=SMART
```

**Scope `healing_config` to `session`** (default) — one cache per test run.

**Use `HEURISTIC_ONLY` for local dev** when you don't need AI:

```bash
pytest tests/ --ph-no-heal
```

---

## Auto-Patch Source (New)

`playwright-healer` can **automatically rewrite the broken selector strings in your test files** once a heal is found. This means you don't have to manually hunt down and fix stale locators — the library fixes them for you.

### How it works

1. A broken selector is healed at runtime.
2. The original source file and line number are captured from the call stack.
3. At the end of the session, the broken string literal on that line is replaced with the healed selector — in-place.

**Before:**
```python
await healing_page.click("#logout-btn", "Logout")
```
**After (automatic rewrite):**
```python
await healing_page.click("#logout-button", "Logout")
```

### Enabling auto-patch

```bash
# .env
PH_AUTO_PATCH_SOURCE=true
```

```bash
# pytest CLI
pytest tests/ --ph-auto-patch-source
```

```python
# Programmatic
config = HealerConfig.builder().auto_patch_source(True).build()
```

> **Safety notes:**
> - Only the exact broken string literal on the reported line is replaced — surrounding code is untouched.
> - All patches collected during a run are applied once at session end (no mid-run file writes).
> - Duplicate patches (same file + line + selector) are automatically deduplicated.
> - Files are overwritten in-place. Set `backup=True` on `SourcePatcher` directly if you want `.bak` backups.

---

## Troubleshooting

| Problem | Fix |
|---|---|
| `No AI provider configured` | Set at least one: `GROQ_API_KEY`, `GEMINI_API_KEY`, `OPENAI_API_KEY`, etc. |
| `All AI providers exhausted` | Check API keys are valid; add a fallback provider |
| Rate limit errors | Add a second provider as fallback; switch to `HEURISTIC_ONLY` temporarily |
| Healed selector also broken | Cache adapts via adaptive TTL; call `cache.invalidate(key)` manually |
| Reports in wrong folder | Use `PH_REPORT_DIR` env var or `report_dir` in config |
| Shadow DOM elements not found | Ensure `PH_SHADOW_DOM=true` (default) |
| Source not patched | Ensure `PH_AUTO_PATCH_SOURCE=true` and the test file is writable |

---

## License

MIT — see [LICENSE](LICENSE).
