Every broken selector in a test suite is the same bug wearing a different hat: something in the markup moved, and a string written months ago no longer points at it. The obvious 2026 fix is to hand the page and the failure to a model and ask for a new selector. I built the opposite of that, and the constraint turned out to be the whole point.

The service exposes one endpoint. POST a page source and whatever you know about the element — the locator that broke, a prose description, or both — and it returns exactly one locator, already proven to resolve to exactly one node in the page source you sent. It covers Selenium, Playwright and Appium across web, Android, iOS and React Native, including shadow DOM.

The model gets one closed question

The pipeline is deterministic on both ends and the model sits in a single step in the middle.

pipeline.py
parse → shortlist candidates → disambiguate → generate ladder → validate → answer
                              ↑
                        LLM sits only here

Deterministic code prunes thousands of nodes down to roughly eight candidates, then asks the model one question: which index is the target? A 5 MB page produces a ~3 KB prompt. The model never receives the page source, and it cannot emit an invalid selector because it never writes one — it returns an integer.

That single decision cascades into everything else. The privacy problem disappears: page source can carry customer data, and with the model seeing only candidate summaries there is far less to leak — set LLM_ENABLED=false and nothing leaves your network at all. The hallucination problem disappears too, because the output space is a list index, not a selector grammar. And the model is skipped entirely whenever the deterministic ranking is already clear, which is most of the time. The deterministic path answers in 0–2 ms and scores 12 of 13 on the mutation harness on its own.

Nothing is returned unvalidated

Every candidate is evaluated against the page source in that same request and must clear three bars: it compiles, it matches exactly one node, and it matches the node. Match count alone is not enough — a perfectly unique selector can point confidently at the wrong element. If nothing clears all three, the caller gets a 422 explaining why instead of a guess.

This is also why the cache can't poison anything. It sits in front of the disambiguation step and nowhere else; generation and validation run on every request, hit or miss. A cached answer is re-proven against the page in hand and dropped if it no longer resolves.

Ladders, and what they refuse to climb

Each platform has an ordered ladder of strategies. Web goes data-testid → role → stable id → aria-label → exact text → relative to a stable ancestor → semantic classes → short anchored XPath. Android starts at resource-id, iOS at accessibility id, React Native at testID. XPath is a last resort everywhere and never absolute.

More interesting is what gets rejected outright, because these all look stable in a single snapshot: generated ids (:r3:, ember1043, a bare UUID), build-hashed classes (css-1x2y3z), absolute paths, index chains, and data-dependent text like a price or a timestamp. Returning one of those is worse than returning nothing — it converts a loud failure now into a silent one next sprint.

React Native gets the strictest treatment. It renders real native views, so the hierarchy is generic ViewGroup and XCUIElementTypeOther, deeply nested, reshaping between renders and RN versions. When a component has no testID, no accessibilityLabel and no text, the service returns a 422 naming the element and telling you to add a testID. There is deliberately no structural fallback, because a ViewGroup[3]/ViewGroup[1] path would break on the next render.

Shadow DOM is a capture problem first

Most shadow DOM locator advice skips the actual failure. driver.page_source and page.content() return the light DOM only — shadow roots are simply absent, so the element you are hunting is not in the payload at all. No amount of clever selection recovers it. You have to serialize with declarative shadow DOM before you send anything.

capture.js
document.documentElement.getHTML({
  serializableShadowRoots: true,
  shadowRoots: [...allOpenRoots],
})

After that the answer depends on who is running it. Playwright's engine pierces open roots, so it gets plain CSS — validated for uniqueness across every scope, not just the target's own. Selenium and WebdriverIO get a >>> chain, one segment per boundary, each unique inside its own root. XPath is never emitted behind a boundary: it cannot cross one, and a caller who ran it would silently get zero matches. Closed roots are untraversable by any tool, so they return 422 rather than a guess.

Evidence that argues with itself

A broken locator and a description are better together than either alone, and not for the obvious reason. If a positional locator still resolves after a refactor, it may now point at a different element — a wrapper div was inserted, rows were reordered. So credit for a surviving locator is scaled by that locator's own robustness: a surviving data-testid is strong evidence, a surviving //div[1]//button is nearly none.

The sharper rule is that a surviving locator whose element answers none of the description earns no credit at all. An id that outlived the element it was written for is the ordinary way a locator ends up gripping the wrong thing, and averaging the scores lets it through — a zero description match dilutes rather than argues. So contradiction counts against, and no intact verdict is issued when the evidence disagrees with itself.

There is a known gap, and it stays in the repo as a failing test. When an id is not merely renamed but swapped onto a sibling, the stolen id corroborates itself: scoring reads the description against every attribute, so the wrong element matches the words through the very id that moved onto it. The stale evidence and the confirming evidence are the same string. It counts against the published heal rate rather than being deleted to protect the number.

The timeout that wasn't

The most useful thing I measured had nothing to do with locators. Testing against a hosted NVIDIA NIM endpoint, one index decision took 132 seconds on deepseek-v4-pro and blew past 300 seconds on glm-5.2. Slow providers are not news. What is news: a litellm.completion(timeout=120) against that endpoint took 363 seconds to raise. The SDK's own timeout did not bind.

So the budget is enforced here instead, on a daemon worker thread that gets abandoned when the deadline passes. The same request went from 363.2 s to 150.0 s under it, still returning the correct deterministic locator, because on timeout the pipeline simply keeps the answer it already had and sets llm_used: false. Two regression tests cover it. If you are wiring an LLM into anything latency-sensitive, do not assume your client library's timeout parameter is a real deadline — measure the raise, not the docs.

Why this shape generalises

The pattern here isn't specific to locators. Put the model where judgement is genuinely needed, give it the smallest possible decision, and let deterministic code own everything that can be proven. The model contributes a tie-break on an ambiguous minority; validation contributes the guarantee. A dead endpoint then costs you accuracy on a handful of hard cases instead of taking the service down — which is the difference between a demo and something you let near a release pipeline.

The code is on GitHub at github.com/utham9/self-healing — FastAPI, provider-agnostic through LiteLLM, and the mutation harness that keeps the heal rate honest.