Self-healing
Philosophy
Self-healing in razo is deterministic — no model runs inside your tests. Because every control already declares a type and a human name, razo can recover from locator drift by trying a short, ordered list of alternative locators, and nothing more. Two rules keep this safe:
- Healing only triggers when a locator resolves to nothing at all. An element that exists but fails an action or assertion fails normally.
- Healing never masks a product regression — it only ever recovers from a locator going stale, never from the app behaving differently.
Implicit role + accessible-name fallback
When a control's primary locator stops resolving, razo tries the control's explicit fallbacks (below) first, then an implicit role + exact accessible name derived from the control's type and its human name. The first candidate that resolves to exactly one element wins.
const button = new Button(page, 'export-v1', 'Export');
await button.click();If [data-testid="export-v1"] no longer matches anything but a <button> named "Export" still exists, razo heals through role=button[name="Export"] (exact match — a page with both "Export" and "Confirm export…" would otherwise match both and block the heal). This is a real captured StepEvent for that case:
{
"action": "click",
"controlType": "button",
"name": "Healed export",
"sentence": "Click button \"Healed export\"",
"selector": "role=button[name=\"Healed export\"]",
"healed": {
"from": "[data-testid=\"healing-export-v1\"]",
"to": "role=button[name=\"Healed export\"]"
},
"status": "passed",
"timestamp": "2026-07-17T02:44:12.292Z"
}The implicit fallback only exists for control types with an unambiguous role — button, link, checkbox, radio group, switch, field, text area, select, combobox, slider, dialog, menu, tabs, image, table. Types whose role is ambiguous (label, tooltip, editor, file input, ...) heal only through explicit fallbacks.
Explicit fallbacks
Pass fallbacks when you know a more specific alternative than the implicit role fallback — they're tried first, in order:
new Button(page, 'export-v1', 'Export', {
fallbacks: [{ text: 'Export' }], // tried before role=button[name="Export"]
});fallbacks accepts any LocatorSpec (see Controls & locators). A within-scoped control keeps its selector chain when it heals: [data-testid="confirm-dialog"] [data-testid="confirm-yes-v1"] heals to [data-testid="confirm-dialog"] role=button[name="Yes, export"], never dropping the parent.
RAZO_HEALING
| Value | Behavior |
|---|---|
| (unset, default) | pass — heal and let the step pass; the drift is recorded in healed |
fail | Heal only to diagnose: the step fails with a "locator drift" error naming the working locator |
off | Disable healing entirely; a missing element fails immediately |
RAZO_HEALING=fail npx playwright test # turn drift into a build-breaking diagnosisWith RAZO_HEALING=fail, the StepEvent still carries healed alongside status: "failed":
{
"action": "click",
"controlType": "button",
"name": "Healed export",
"sentence": "Click button \"Healed export\"",
"healed": {
"from": "[data-testid=\"healing-export-v1\"]",
"to": "role=button[name=\"Healed export\"]"
},
"selector": "role=button[name=\"Healed export\"]",
"status": "failed",
"error": "locator drift: [data-testid=\"healing-export-v1\"] no longer resolves; element found via role=button[name=\"Healed export\"] — update the locator",
"timestamp": "2026-07-17T02:44:12.231Z"
}Healed events in the artifact and dashboard
A healed step is still a passed step under the default pass mode — CI stays green — but healed: { from, to } travels into razo-steps.json regardless of mode, so the drift never goes unnoticed. The dashboard surfaces these the same way it surfaces failures: a run with healed steps is visibly different from a clean run, even though the test suite is green.
DOM candidates and what the analyzer proposes
When a locator is gone and nothing heals it — no explicit fallbacks and no implicit role match — the failed StepEvent includes domCandidates: up to five same-role elements found elsewhere on the page. This is real captured output for a button whose testid matches nothing:
{
"action": "click",
"controlType": "button",
"name": "No Such Button Anywhere",
"sentence": "Click button \"No Such Button Anywhere\"",
"domCandidates": [
"role=button name=\"\"",
"role=button name=\"Export\"",
"role=button name=\"Upload to cloud\"",
"role=button name=\"Confirm export…\"",
"role=button name=\"What is 3MF?\""
],
"selector": "[data-testid=\"gone-forever\"]",
"status": "failed",
"error": "locator.click: Timeout 5000ms exceeded.\nCall log:\n - waiting for getByTestId('gone-forever')\n",
"timestamp": "2026-07-17T02:44:17.527Z"
}razo-analyze reads domCandidates as evidence when a test fails on a missing element: instead of just reporting "element not found", it can propose which of the nearby same-role elements is the likely replacement locator.