Implementing Dynamic Self-Healing Locators with Playwright Fixtures (2026)
Build self-healing locators in Playwright using custom fixtures and an LLM fallback hook - catch broken selectors, propose a fix from the ARIA snapshot, and log every heal.
If you have read our tools roundup, Self-Healing Test Automation and AI-First Tools, you know the what and the why. This post is the implementation companion to that overview. Here we build self-healing locators ourselves, in plain Playwright and TypeScript, using a custom fixture and an LLM fallback hook. No commercial platform, no per-seat lock-in. You own the code, you own the triage flow, and you decide exactly when a heal is trusted.
The goal is narrow and honest: when a developer renames a class or restructures the DOM and a selector breaks mid-run, we want the test to recover once, keep going, and then loudly tell a human what it did. We are not trying to make failures disappear. We are trying to separate cosmetic churn from real regressions, and the logging is the part that matters most.
What are we actually building?
We are extending Playwright’s test.extend() to inject a resilient locator factory. Instead of calling page.locator(selector) directly, tests call heal.locator(selector, { intent }). Under the hood that helper tries the selector normally. If it resolves to an element, nothing special happens and there is zero overhead. If it resolves to zero elements, the helper does the following:
- Captures the page’s ARIA accessibility snapshot as compact context.
- Sends the failed selector, the stated intent, and the snapshot to an LLM.
- Receives a candidate replacement locator.
- Retries the action with the candidate.
- Logs the heal - original selector, healed selector, URL, intent, and timestamp - to a heal ledger for triage.
The heal ledger is not an afterthought. It is the product. A self-healing suite without a triage trail is a suite that has quietly stopped testing.
Why the ARIA snapshot instead of raw HTML?
Because it is smaller, cleaner, and describes user intent. The accessibility tree gives you role-labeled nodes like button "Submit" and textbox "Email address". That is exactly the signal an LLM needs to reason about which element a broken selector meant to target. Raw HTML, by contrast, is token-heavy, full of framework noise (hashed class names, wrapper divs, inline styles), and pushes the model toward the same brittle attributes that broke in the first place.
Playwright exposes this two ways. locator.ariaSnapshot() gives you a scoped YAML-like tree for a subtree, and the lower-level accessibility API gives you the whole page. For healing we usually want the region around where the element should be, but a page-level snapshot is a fine starting point. Read more on this signal in Accessibility-Tree Locators for Resilient Playwright Tests.
The core fixture
Here is the fixture. It wraps a HealingLocator helper and exposes it to every test. Model calls go through a thin proposeLocator function you can point at any provider - keep it generic so you are not coupled to one vendor.
// fixtures/healing.ts
import { test as base, expect, Page, Locator } from '@playwright/test';
import { appendHeal } from './heal-ledger';
import { proposeLocator } from './llm-client';
export interface HealOptions {
// A short, human description of what this locator targets.
// This becomes the LLM's primary instruction, so make it meaningful.
intent: string;
// Hard cap so one broken page cannot trigger dozens of model calls.
maxHealsPerTest?: number;
}
export interface HealResult {
original: string;
healed: string;
intent: string;
url: string;
testTitle: string;
timestamp: string;
}
class HealingContext {
public healCount = 0;
constructor(
public readonly page: Page,
public readonly testTitle: string,
public readonly maxHeals: number,
) {}
async locator(selector: string, opts: HealOptions): Promise<Locator> {
const primary = this.page.locator(selector);
// Fast path: selector still matches. Zero overhead, no model call.
if ((await primary.count()) > 0) {
return primary;
}
if (this.healCount >= this.maxHeals) {
throw new Error(
`Heal budget exhausted (${this.maxHeals}) before "${selector}" ` +
`[intent: ${opts.intent}]. Refusing to heal further - this is likely ` +
`a real regression, not cosmetic drift.`,
);
}
return this.heal(selector, opts);
}
private async heal(selector: string, opts: HealOptions): Promise<Locator> {
this.healCount += 1;
// Compact, role-based context beats raw HTML for both cost and accuracy.
const ariaSnapshot = await this.page.locator('body').ariaSnapshot();
const candidate = await proposeLocator({
brokenSelector: selector,
intent: opts.intent,
ariaSnapshot,
url: this.page.url(),
});
const healed = this.page.locator(candidate);
if ((await healed.count()) === 0) {
throw new Error(
`Self-heal failed for "${selector}" [intent: ${opts.intent}]. ` +
`LLM proposed "${candidate}" which also matched nothing. ` +
`Treat as a genuine failure.`,
);
}
// The load-bearing line: record the heal for human triage BEFORE returning.
const record: HealResult = {
original: selector,
healed: candidate,
intent: opts.intent,
url: this.page.url(),
testTitle: this.testTitle,
timestamp: new Date().toISOString(),
};
await appendHeal(record);
console.warn(
`[SELF-HEAL] "${selector}" -> "${candidate}" (${opts.intent}). ` +
`Logged for triage. Do NOT assume this heal is correct.`,
);
return healed;
}
}
type HealFixtures = { heal: HealingContext };
export const test = base.extend<HealFixtures>({
heal: async ({ page }, use, testInfo) => {
const ctx = new HealingContext(page, testInfo.title, 3);
await use(ctx);
// Surface heal count on the test result so CI can flag noisy tests.
testInfo.annotations.push({
type: 'self-heal-count',
description: String(ctx.healCount),
});
},
});
export { expect };
Two design choices carry their weight here. First, the fast path returns immediately when the selector still matches, so healthy tests pay nothing. Second, there is a hard heal budget (maxHealsPerTest, default 3). If a page needs more than a handful of heals in a single test, that is not cosmetic drift - the page is broken, and we want the test to fail loudly rather than quietly reconstruct a passing run.
The LLM fallback hook
Keep the model client thin and provider-agnostic. It takes the broken selector, the intent, and the accessibility snapshot, and returns a single locator string. The prompt does the real work.
// fixtures/llm-client.ts
interface ProposeInput {
brokenSelector: string;
intent: string;
ariaSnapshot: string;
url: string;
}
const SYSTEM_PROMPT = `You repair broken Playwright locators.
You are given a selector that matched ZERO elements, a short description of
what it was meant to target, and the page's ARIA accessibility snapshot.
Return ONE Playwright locator string that targets the intended element.
Rules:
- Strongly prefer role- and text-based locators, e.g.
getByRole('button', { name: 'Submit' }) expressed as a selector,
or a text= / [aria-label=] selector.
- Never invent an element that is not present in the snapshot.
- If nothing in the snapshot plausibly matches the intent, return the token
NO_MATCH so the test fails honestly instead of healing to the wrong element.
- Output only the selector or NO_MATCH. No prose, no code fences.`;
export async function proposeLocator(input: ProposeInput): Promise<string> {
const user = [
`Intent: ${input.intent}`,
`Broken selector: ${input.brokenSelector}`,
`URL: ${input.url}`,
`ARIA snapshot:`,
input.ariaSnapshot,
].join('\n');
// Use whatever provider SDK you standardize on. Keep temperature low so
// the same broken page yields a stable proposal across runs.
const raw = await callModel({
system: SYSTEM_PROMPT,
user,
temperature: 0,
});
const candidate = raw.trim();
if (candidate === 'NO_MATCH' || candidate.length === 0) {
throw new Error(
`LLM found no plausible match for intent "${input.intent}". ` +
`Failing honestly rather than healing to a wrong element.`,
);
}
return candidate;
}
// Replace with your provider's client. Signature kept intentionally generic.
declare function callModel(args: {
system: string;
user: string;
temperature: number;
}): Promise<string>;
Notice the NO_MATCH escape hatch. Giving the model an explicit way to say “nothing here fits” is what stops it from confidently healing your Submit test onto a Cancel button just to produce an answer. Low temperature keeps proposals stable so a flaky selector does not heal to a different element on every run. [verify] the exact SDK call against your provider’s current API.
The heal ledger
The ledger is where triage lives. Append-only, structured, easy to diff. A JSON Lines file is enough to start; graduate to a database or a posted PR comment when volume grows.
// fixtures/heal-ledger.ts
import { appendFile, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import type { HealResult } from './healing';
const LEDGER_PATH = process.env.HEAL_LEDGER ?? 'test-results/heals.jsonl';
export async function appendHeal(record: HealResult): Promise<void> {
await mkdir(dirname(LEDGER_PATH), { recursive: true });
await appendFile(LEDGER_PATH, JSON.stringify(record) + '\n', 'utf8');
}
A tiny reporter turns the ledger into a triage summary at the end of a run:
// scripts/summarize-heals.ts
import { readFile } from 'node:fs/promises';
async function main() {
const path = process.env.HEAL_LEDGER ?? 'test-results/heals.jsonl';
const lines = (await readFile(path, 'utf8').catch(() => ''))
.split('\n')
.filter(Boolean);
if (lines.length === 0) {
console.log('No heals this run. Locators held.');
return;
}
console.log(`\n=== SELF-HEAL TRIAGE (${lines.length} heals) ===`);
console.log('Each heal below needs human eyes. A heal is a QUESTION, not a fix.\n');
for (const line of lines) {
const h = JSON.parse(line);
console.log(`- [${h.testTitle}] ${h.intent}`);
console.log(` was: ${h.original}`);
console.log(` now: ${h.healed}`);
console.log(` at: ${h.url}\n`);
}
// Non-zero exit if you want heals to fail the build until reviewed.
if (process.env.HEALS_FAIL_BUILD === '1') process.exit(1);
}
main();
Using it in a test
The test surface stays clean. You pass an intent string, which doubles as documentation and as the LLM’s instruction.
// tests/checkout.spec.ts
import { test, expect } from '../fixtures/healing';
test('user can submit the checkout form', async ({ page, heal }) => {
await page.goto('/checkout');
const email = await heal.locator('#email-input', {
intent: 'the email address field in the checkout form',
});
await email.fill('[email protected]');
const submit = await heal.locator('button.checkout-submit', {
intent: 'the primary Submit button that places the order',
});
await submit.click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});
If button.checkout-submit disappears because a developer renamed the class, the fixture heals it once from the accessibility tree, logs the change, and the test continues. But the run does not pass silently: the heal shows up in the triage summary and as a test annotation.
Triage before repair: the caveat that makes this safe
Say it plainly, because it is the difference between a mature adoption and a dangerous one: healing can mask real bugs. If the Submit button genuinely broke - moved off-screen, lost its handler, got disabled by a regression - a naive self-healer will happily find something button-shaped, re-point the test, and report green. You have now automated the concealment of the exact defect you were trying to catch.
The pattern that keeps this honest is triage before repair:
- Log every heal with before and after selectors, intent, and URL. We did this in the ledger.
- Cap heals per test so a broken page fails loudly instead of reconstructing a fake pass. We did this with the heal budget.
- Give the model a NO_MATCH exit so it fails honestly rather than healing to the wrong element.
- Never auto-commit healed selectors to your main branch. Run in report-only mode, then let a reviewed nightly PR propose updates.
- Treat heal rate as a smell. A suite that heals constantly is a suite whose locators are wrong, or whose UI is genuinely unstable. Either way, that is information, not success.
Pixel of caution: where this belongs in the pipeline
| Concern | This fixture | When to reach past it |
|---|---|---|
| Cosmetic DOM churn (renamed class, moved node) | Handles it, heals once, logs | This is the sweet spot |
| Genuine functional regression | Fails after budget / NO_MATCH | Correct - do not heal past it |
| Auto-committing selector fixes | Deliberately does not | Use a reviewed nightly PR job |
| High-volume heal noise | Surfaced in triage summary | Fix the underlying locators |
| Non-code, plain-English authoring | Out of scope | See the tools roundup |
This fixture is the engineering-led counterpart to the platforms we cover in Self-Healing Test Automation and AI-First Tools. If you want the full agentic picture - planning, generation, and healing as cooperating agents - see Tri-Agent Testing Pipeline: Planner, Generator, Healer and the pillar, Building a Self-Operating QA Agent with Playwright MCP.
The takeaway
Self-healing locators are not magic and they are not a product you have to buy. Fifty lines of fixture code, a thin LLM hook, an accessibility snapshot, and a heal ledger get you a resilient suite you fully control. But the code that keeps your tests running is worth less than the code that tells a human what it changed. Build the fixture, absolutely - and build the triage flow first. Heal quietly and you have automated a lie. Heal loudly, and you have bought your suite time while keeping every real bug visible.
Frequently Asked Questions
How do you implement self-healing locators in Playwright?
You extend test.extend() to wrap the page with a resilient locator helper. When the primary selector resolves to zero elements, the fixture captures the ARIA accessibility snapshot, sends it plus the original selector's intent to an LLM, gets back a candidate locator, retries with it, and logs the heal for human triage. The healed selector is never silently trusted - it is recorded and surfaced in a report so an engineer confirms the change was cosmetic, not a real regression.
Is this different from a self-healing tools roundup?
Yes. This is the hands-on implementation companion to our tools overview, Self-Healing Test Automation and AI-First Tools. The roundup compares commercial and open-source platforms; this post shows the actual TypeScript fixture code so an engineering-led team can build and own the behavior instead of buying it.
Can self-healing locators hide real bugs?
Yes, and this is the central risk. If a Submit button genuinely moved or broke, a naive heal re-points the test so it still passes and buries a real UX regression. The rule is triage before repair: log every heal with before and after selectors, cap the number of heals per run, and require a human to review healed locators before they are committed. A high heal rate is a smell, not a win.
What signal does the LLM use to propose a new locator?
The ARIA accessibility snapshot from Playwright's page.accessibility or the locator.ariaSnapshot() output. It is a compact, role-based tree that describes what a user perceives - button 'Submit', textbox 'Email' - so the model reasons about intent rather than brittle CSS. Feeding the raw HTML instead is token-heavy and noisier, so prefer the accessibility tree.
Should self-healing run in CI or only locally?
Run it in a report-only mode in CI first. Let heals happen, log them, but do not auto-commit the new selectors. Once you trust the triage flow, you can let a nightly maintenance job open a PR with proposed selector updates for review. Never let an LLM edit locators in your main branch unattended.
Complementary NomadX Services
Related Articles
Test automation, engineered.
Book a free 30-minute call. We assess your test automation gaps and show you how a modern SDET practice ships faster with fewer escapes.
Talk to an Expert