Self-Healing Locators
Automatic locator repair via reference screenshots — applied once, remembered forever.
← Back to overview · 🇩🇪 Deutsch · ← API Reference · CI Integration →
What it does
When a Control’s locator fails to find its element at runtime — most commonly after an app UI update changes an automation ID, CSS class, or DOM position — the framework can attempt an automatic repair instead of failing the test immediately:
- It compares a reference screenshot (captured once, when the TestStep was authored) against a fresh screenshot of the current UI.
- It sends both images plus the current element tree to a Vision model and asks for the element that is semantically equivalent to the one marked in the reference.
- If a candidate locator is returned, the framework retries the failed lookup with it.
- If the retry succeeds, the healed locator is written back into the Control source file at the end of the test run — so the next run finds it directly, with no Vision call needed for that locator again.
The mechanism has two independent halves: discovery (steps 1–3, active on every run where a reference screenshot is bound) and writeback (step 4, opt-in via environment variable — see below). You can use discovery without writeback if you’d rather review and apply changes yourself.
💡 When is this useful? Locator breakage typically comes from two sources: Desktop/Appium automation IDs changing after an app update, and Web frontend refactors renaming CSS classes or restructuring the DOM. Self-Healing turns “test fails, someone manually re-inspects and fixes the locator” into “test heals itself once, then keeps working unattended.”
Healing overlays with no stable name
A dialog or overlay that exposes a semantic marker — role="dialog", an aria-label, a matching title — heals like any other element: the framework has a name to search Vision with. A typical cookie-consent banner or custom modal often has none of those; its container is recognized generically instead, from common ARIA/HTML overlay signals (role="dialog"/"alertdialog", aria-modal, the <dialog> tag, or a class/id containing modal, overlay, dialog, banner, cookie, or consent). When a step’s checkTitleFromPageExists/checkTitleFromDialogExists guard is on, the dialog’s own title text is used as the Vision search hint; otherwise a generic “the modal/overlay/dialog/cookie-banner currently open on screen” hint resolves the one overlay actually in front of the user.
Setup: binding a reference screenshot
Self-Healing only activates for a TestStep call when a reference screenshot is bound for that specific call. Binding is one line at the top of the TestStep body:
// 2_Apps/<YourApp>/2_Steps/TS_Main.ts
import * as Core from '@meintest/cc-testframework';
export const TS_Main_Button_Click = async (refId: string, label: string): Promise<void> => {
Core.Inspector.bindReference(refId);
// ... rest of the TestStep body, unchanged
};
refIdis a short, kebab-case identifier and is always the first parameter of the TestStep. Pass''when no reference is needed for a given call — the step then behaves exactly as it did before Self-Healing existed, with zero overhead.
Giving a TestCase a stable id
Reference PNGs are keyed by a TestCase id, not by the TestCase’s Playwright test title. Set one at the top of a TC_*.spec.ts file — inside the test(...) callback, or in a test.beforeEach if the file declares several tests:
// 3_Cases/TC_<YourScenario>.spec.ts
import * as Project from '@GlobalRef';
Project.Core.test('TC_<YourScenario>', async () => {
Project.Core.setTestCaseId('tc_<your-scenario>');
Project.Core.Step.setCurrentTestCaseName('TC_<YourScenario>');
// ... rest of the TestCase body, unchanged
});
- A uid is a stable, machine-friendly identifier — distinct from
Step.setCurrentTestCaseName, which only affects the human-readable name shown in reports. Renaming the test description or the spec file does not orphan reference assets bound under a uid, because the id and the title are independent of each other. - Recommended slug format:
tc_[a-z0-9-]{4,}— lowercase, hyphen-separated, filesystem-safe. Examples:tc_login-happy-path,tc_dashboard-grid-sort. - Read the currently set id back with
Core.getTestCaseId(): string | null—nullif none has been set for the current TestCase yet. - If your TestCases are generated for you by a scaffolding tool, the
Core.setTestCaseId(...)call is added automatically; add it by hand when authoring aTC_*.spec.tsfile yourself.
Where the reference PNG lives
pm/
├── 3_Cases/
│ └── TC_<YourScenario>.spec.ts
└── 7_Assets/
└── tc_<your-scenario>/
├── login-btn.png
└── dashboard-grid.png
7_Assets/is a sibling of3_Cases/inside your project’spm/folder — not nested inside3_Cases/— and follows the framework’s numbered-folder convention. See Concepts — The full project layout for all 11 numbered folders.- Path:
pm/7_Assets/<uid>/<assetId>.png, where<assetId>is the samerefIdstring passed as a TestStep’s first argument. Removing a TestCase’s7_Assets/<uid>/subfolder removes its references too — no orphaned files.
Path-resolution order
- If a uid is set for the current TestCase, the framework resolves
pm/7_Assets/<uid>/<refId>.png. - If no uid is set, it falls back to the legacy, title-based path
pm/3_Cases/Assets/<TestCase-title>/<refId>.png, resolved from your project’s Playwright config root (pm/itself, since v0.25.0 — see Migrating to v0.25.0 below), and prints a one-time deprecation warning for that TestCase, pointing at the steps above. - If neither location has a matching file, no reference is bound for that call and Self-Healing is silently skipped for that step — exactly as when
refIdis''. This is never a hard failure.
Calling a TestStep with a bound reference:
await Project.<YourApp>.TS_Main_Button_Click('login-btn', 'Sign in');
Calling it without one — unaffected, exactly like any TestStep today:
await Project.<YourApp>.TS_Main_Button_Click('', 'Sign in');
💡 Where do reference PNGs come from? Capture them interactively during test authoring with
Inspector.pause(), or supply them together with a generated TestCase from an external tool or AI-agent. Either way, the PNG is a plain file you can open directly in a file browser or review in a GitHub diff — no special encoding involved.
Migrating reference-asset paths from v0.15 to v0.16
v0.16 replaced the previous, title-keyed reference-asset path with the uid-keyed convention described above, and fixed how the fallback path itself is resolved:
- The legacy path used to be hardcoded with a
tests/prefix (tests/3_Cases/Assets/<TestCase-title>/<refId>.png) — a layout that only matched a project with all code nested under atests/folder. A project with3_Cases/directly at its root never found its reference assets. - The legacy path is also keyed by the TestCase’s Playwright test title — renaming a
test(...)description or a spec file silently orphaned its reference PNGs.
To migrate an existing project:
- Add
Core.setTestCaseId('tc_<slug>')at the top of eachTC_*.spec.tsfile that binds references (see above). - Move the matching PNGs from their previous location to
7_Assets/<uid>/, keeping the same file names. - Leave a TestCase’s id unset if you’d rather migrate it later — the legacy fallback keeps working in the meantime and prints a one-time reminder per TestCase, pointing at the same two steps.
⚠ Breaking for
tests/-nested projects. The legacy fallback path in v0.16 no longer includes thetests/prefix. If your reference assets previously lived attests/3_Cases/Assets/<TestCase-title>/<refId>.png, that exact path no longer resolves — Self-Healing silently stops finding those references, the same way it would for arefIdwith no matching file at all. Either move the assets up one level to3_Cases/Assets/<TestCase-title>/<refId>.png(same legacy convention, without thetests/prefix), or — recommended — set a uid and move them to7_Assets/<uid>/<refId>.pnginstead.
Migrating to v0.24.0 (project layout)
v0.24.0 wraps every scaffolded project folder — 2_Apps/, 3_Cases/, 7_Assets/, and the rest of the numbered folders — inside a single pm/ root. See Concepts — Migrating to v0.24.0 for the full folder renumbering; for reference-asset resolution specifically:
- The recommended, uid-keyed path becomes
pm/7_Assets/<uid>/<refId>.png(was7_Assets/<uid>/<refId>.png). - At the time of this migration, the legacy, title-based fallback path was not affected by the
pm/wrapper — it still resolved at3_Cases/Assets/<TestCase-title>/<refId>.pngrelative to your project’s Playwright config root, which was still your repo’s outer root back then, not nested underpm/. See Migrating to v0.25.0 below — this asymmetry closes once your Playwright config root itself moves intopm/.
This is a breaking, hard cutover with no backward-compat shim — the framework is still in active development and does not read pre-v0.24.0 asset locations automatically.
Migrating to v0.25.0 (self-contained pm/ project)
v0.25.0 moves playwright.config.ts (along with package.json, tsconfig.json, .npmrc) from your repo’s outer root into pm/ itself — see Concepts — Migrating to v0.25.0. This changes where the framework’s repo-root detection lands: it now resolves to pm/ — the directory carrying playwright.config.ts and package.json — instead of the outer folder containing pm/. Both reference-asset paths described above are resolved relative to this same root, so both now land inside pm/ without any extra configuration:
- The recommended, uid-keyed path stays
pm/7_Assets/<uid>/<refId>.png.Core.Constant.assetsDirin the shippedGlobalConfig.tschanges from'pm/7_Assets'back to'7_Assets'— nopm/-prefix needed anymore, since the resolved root already ispm/. If you setassetsDirby hand, drop thepm/prefix to avoid double-nesting topm/pm/7_Assets. - The legacy, title-based fallback path — described just above as unaffected by the
pm/wrapper — now resolves atpm/3_Cases/Assets/<TestCase-title>/<refId>.pngtoo, closing that asymmetry. This is a side-effect of the repo-root change, not a rewrite of the fallback path itself — it was already relative to “your project’s Playwright config root,” and that root now sits insidepm/.
This is a breaking, hard cutover with no backward-compat shim.
Setup: enabling the writeback reporter
Discovery works as soon as a reference is bound — no additional configuration needed. Writeback — persisting a successful heal into your Control source file — needs one more thing: opting in via environment variable.
export SELF_HEALING_WRITEBACK=true
If your playwright.config.ts spreads the framework’s baseConfig (see API Reference — baseConfig), that’s the entire setup. baseConfig auto-appends SelfHealingWritebackReporter to its reporter array whenever SELF_HEALING_WRITEBACK or SELF_HEALING_WRITEBACK_DISCOVERY is set to true — no playwright.config.ts edit needed at all:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import { baseConfig } from '@meintest/cc-testframework';
export default defineConfig({
...baseConfig,
// no reporter[] override needed — SELF_HEALING_WRITEBACK is read when this file loads
});
The pre-existing ['html'] default reporter is always kept alongside it.
Registering the reporter manually
Skip the env-var-only path above and register SelfHealingWritebackReporter yourself when your playwright.config.ts does not spread baseConfig, or your project already has its own reporter[] array and you’d rather add it explicitly than rely on auto-wiring:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import { SelfHealingWritebackReporter } from '@meintest/cc-testframework';
export default defineConfig({
reporter: [
['list'],
[SelfHealingWritebackReporter],
],
});
If your own reporter[] array is built without importing the package’s full barrel, reference the reporter through its dedicated sub-path export instead — it resolves to the exact same class:
reporter: [
['list'],
['@meintest/cc-testframework/reporter/self-healing-writeback'],
],
The older, wildcard-derived path (@meintest/cc-testframework/Reporter/SelfHealingWritebackReporter, uppercase) still resolves for existing configs — the lowercase sub-path above is the canonical form to use going forward.
Manual registration set up before baseConfig auto-wiring existed keeps working unchanged — nothing about it is deprecated, it’s simply no longer the only option.
Then choose a mode with an environment variable:
| Env-var | Effect |
|---|---|
| (unset) | Discovery mode. Heals are attempted and reported in .self-healing-report.json; nothing is written to source files. |
SELF_HEALING_WRITEBACK_DISCOVERY=true | Same effect as unset — an explicit opt-in for CI jobs that want the report without relying on an env-var simply not being set. |
SELF_HEALING_WRITEBACK=true | Apply mode. Every successful heal in this run is written into the Control source file that owns the affected locator. |
SELF_HEALING_WRITEBACK_EMIT_ONLY=true | Emit-only mode. Every successful heal is captured and written to a durable, machine-readable file — nothing is applied. See Emit-Only mode below. |
Apply mode is off by default so a CI run never mutates your repository unexpectedly. Turn it on for a local run, or for a dedicated CI job that opens a review diff / pull request for you to inspect.
How far Apply mode goes: writebackMode
Apply mode writes a heal — but “write the file,” “commit it,” and “push it” are three different levels of trust, and the framework lets you choose independently how far it goes:
SELF_HEALING_WRITEBACK_MODE | What happens |
|---|---|
working-tree (default) | The healed file(s) — and any per-instance override-store entry — are written and left dirty in your working tree. Nothing is committed, nothing is pushed; you review and commit it yourself. |
commit | A coordinated write, plus a local git commit — no push. |
commit-push | The full cycle: coordinated write, commit, and push to origin — the previous, always-publish behavior, now a conscious opt-in. |
Set it the same way as any other Self-Healing choice — an environment variable, or selfHealing.writebackMode in the persisted config file (see below):
export SELF_HEALING_WRITEBACK_MODE=commit-push
This setting applies to BOTH writeback destinations — an ordinary Control source-file edit and a per-instance override-store write — under the exact same rule. It has no effect in Emit-Only mode, which never applies anything regardless of writebackMode.
⚠ Behavior change:
SELF_HEALING_WRITEBACK=truealone no longer commits or pushes. Before this setting existed, enabling Apply mode with no further configuration would coordinate, commit, and push tooriginon your behalf. The default is nowworking-tree— “persist the heal” and “publish it” are now separate, deliberately-chosen trust levels. If you run Self-Healing in CI and relied on the old always-push behavior, addSELF_HEALING_WRITEBACK_MODE=commit-push(orselfHealing.writebackMode: 'commit-push'in your persisted config) to keep that pipeline working exactly as before — otherwise its heals now land as an uncommitted working-tree change nobody looks at.
Not to be confused with the report file’s own writebackMode field ("applied" / "discovery" / "emitted", describing which top-level mode produced the report — see The report file below): this is a different setting, controlling how far an already-applied heal is taken toward git.
Persisting the setup across sessions
Setting SELF_HEALING_WRITEBACK (and SELF_HEALING_AGENT_IDENTITY, see Commit identity below) as a plain environment variable is easy to lose — a new terminal, a switch from a Windows shell to a Linux one, or restarting your editor’s integrated terminal all reset it. As an alternative, npx cc-testframework config self-healing <action> persists the same choices into a small, project-level .cc-testframework.local.json file inside pm/ (your project’s repo-root, as resolved by the framework — see Concepts — Migrating to v0.25.0):
npx cc-testframework config self-healing enable # selfHealing.enabled = true
npx cc-testframework config self-healing disable # selfHealing.enabled = false
npx cc-testframework config self-healing discovery # selfHealing.discoveryMode = true
npx cc-testframework config self-healing set-identity "Jane Doe-agent <jane+agent@example.com>"
npx cc-testframework config self-healing show # print the merged, effective config
Every setting resolves through the same priority chain: an environment variable, when present, always wins over the config file, which wins over the built-in default. This means an existing export SELF_HEALING_WRITEBACK=... setup keeps working unchanged, and a one-off override (e.g. SELF_HEALING_WRITEBACK=false for a single CI run) always takes priority over whatever is persisted in the file — even the literal value "false" counts as “present” and wins.
$ npx cc-testframework config self-healing show
Self-Healing configuration (merged: env-var > config-file > default):
enabled: true (source: config)
discoveryMode: false (source: default)
agentIdentity: cc-testframework-agent <agent@your-domain> (source: config)
config-file: /path/to/your-project/.cc-testframework.local.json
The file holds no secrets — just the three booleans/strings above — but it’s a personal/local runtime preference rather than something a whole team should share identical values for; add it to your project’s .gitignore.
⚠ The config file alone isn’t enough if you rely on
baseConfig’s automatic reporter registration.baseConfigdecides whether to appendSelfHealingWritebackReporterto itsreporterarray by checking the environment variable directly — the config file’sselfHealing.enableddoesn’t (yet) factor into that specific decision. If yourplaywright.config.tsspreadsbaseConfigand you haven’t registered the reporter manually, keepSELF_HEALING_WRITEBACK=trueset as an environment variable too for any run that needs the reporter present. Registering the reporter manually removes this restriction entirely — once it’s registered (however you did that), it reads the merged config (env-var or file) correctly on every run.npx cc-testframework self-healing statussurfaces exactly this — see Diagnosing pipeline issues below.
For writeback to locate the right array element to update, locators need to use the Core.xpath tagged template instead of a raw template-literal string:
// 1_Controls/Control_DataGrid.ts
import * as Core from '@meintest/cc-testframework';
export async function findRow(page: Core.Page, rowIndex: number) {
return Core.SearchEngine.findLocators(page, [
Core.xpath`(//*[@ControlType='ControlType.DataItem'])[${rowIndex}]`,
]);
}
Core.xpath produces the exact same string as an equivalent raw template literal — it is purely additive and doesn’t require rewriting Controls that don’t need Self-Healing. Only tag the locators you want to make writeback-eligible.
When a heal is applied, the healed locator is inserted as the first element of the array; your original locator(s) remain further down as fallbacks. Nothing else in the file is touched — indentation, comments, and surrounding code stay exactly as they were.
Emit-Only mode: external orchestration
For a workflow where a separate automation tool — an external orchestrator, a CI pipeline, or any process integrating with this framework — reviews and applies heals under its own version-control flow instead of letting the framework commit directly, turn on Emit-Only mode:
export SELF_HEALING_WRITEBACK_EMIT_ONLY=true
This flag is self-sufficient: setting it alone captures heals during the run, with no need to also set SELF_HEALING_WRITEBACK or SELF_HEALING_WRITEBACK_DISCOVERY. The framework captures every successful heal but applies nothing — no Control source file is rewritten, and no override-store entry is written either. Instead, it writes a durable .self-healing-emit.jsonl — one JSON record per heal, overwritten fresh each run — alongside the usual .self-healing-report.json (whose entries carry status: "emitted"). Each record identifies where the heal belongs (target: "source" for an ordinary Control locator, target: "override-store" for a shared-template heal), plus the declared locator block, the healed XPath, the interpolated value, and the bound refId — everything an external consumer needs to apply the write itself, either as a source-file edit or as an override-store entry, under its own review/commit flow. The pending queue file is preserved rather than deleted, so nothing is lost if the consuming process runs later than the test run itself.
| Mode | Applies heals? | Durable per-heal record? | Who applies |
|---|---|---|---|
| Discovery (default, unset) | No | No — report only; pending queue deleted at end of run | Nobody — human review only |
Apply (SELF_HEALING_WRITEBACK=true) | Yes | N/A — the write itself is the record | The framework, directly |
Emit-only (SELF_HEALING_WRITEBACK_EMIT_ONLY=true) | No | Yes — .self-healing-emit.jsonl, one record per heal | An external tool, under its own flow |
Persist the setting the same way as any other Self-Healing choice — see Persisting the setup across sessions — and override the emit file’s location with SELF_HEALING_EMIT_FILE if .self-healing-emit.jsonl at your project root doesn’t fit your pipeline.
Applying an emit file: self-healing apply
Emit-Only mode writes the file; it’s npx cc-testframework self-healing apply that actually applies it:
npx cc-testframework self-healing apply
This reads .self-healing-emit.jsonl and applies every captured heal to the working tree — the same Control source-file edit or override-store write Apply mode itself would make, just callable separately, after the run. It completes the two-step decoupled flow: run with SELF_HEALING_WRITEBACK_EMIT_ONLY=true (nothing applied, everything captured to the emit file) — then, possibly after an external orchestrator has locked the affected Controls under its own flow, run self-healing apply to actually write the changes.
It’s also a genuine local, single-developer workflow on its own: emit locally, run apply (the default working-tree mode never touches git), review the resulting diff with your own git diff, and commit yourself when you’re happy with it.
| Flag | Default | Effect |
|---|---|---|
--emit <path> | .self-healing-emit.jsonl in the current directory, or SELF_HEALING_EMIT_FILE | The emit file to apply. |
--mode working-tree\|commit\|commit-push | working-tree | How far to apply — the exact same three levels as writebackMode: write only (caller commits), local commit only, or the full commit-and-push cycle. |
--report <path> | (none) | Write the machine-readable summary (below) to a file. |
--json | (off) | Print the summary to stdout instead of the human-readable text. |
It applies both record kinds from the emit file the same way Apply mode does: target: "source" records get rewritten in their Control file, target: "override-store" records get an entry in the owning app’s per-instance override store.
The summary — printed as text by default, or as JSON via --report/--json — looks like this:
{
"records": [
{
"file": "pm/2_Apps/<YourApp>/1_Controls/Control_DataGrid.ts",
"line": 12,
"control": "Control_DataGrid",
"value": "",
"target": "source",
"originalXPath": "//*[@AutomationId='row-3']",
"healedXPath": "(//*[@ControlType='ControlType.DataItem'])[3]",
"status": "applied"
}
],
"appliedCount": 1,
"skippedCount": 0,
"errorCount": 0,
"affectedFiles": ["pm/2_Apps/<YourApp>/1_Controls/Control_DataGrid.ts"],
"mode": "working-tree"
}
💡 Reading
skippedCountvs.errorCount.skippedCountcounts every record that wasn’t applied, for any reason — including ones that also show up inerrorCount(a coordinated-write failure, e.g. a rejected push, counts every record in that group as both skipped and errored).appliedCount + skippedCountalways equals the total number of records; treaterrorCountas a drill-down into why some of those were skipped, not a third bucket to add on top.
apply is idempotent and tolerant by design: a missing emit file, or one with no records left in it, is a clean no-op — exit code 0, a message telling you there was nothing to apply. This matters for an orchestrator that re-runs apply defensively; it should see success, not failure, when there’s genuinely nothing left to do. Exit code 1 is reserved for a genuine unexpected failure in the apply logic itself; exit code 2 is a --mode value that isn’t one of the three above.
Multi-user coordination
Apply mode is safe to enable in a shared repository. When your project has an origin git remote, every writeback is coordinated through a lock so a colleague working on the same Control file — in their own IDE, or through any tool that participates in the same coordination protocol — never ends up with a conflicting commit against the agent’s change:
- Before writing, the framework tries to acquire a lock for each file it is about to touch.
- If a file is already locked by someone else, that file is skipped for this run — nothing is written, nothing is committed — and the report records who holds the lock and since when (see the report schema below).
- If the lock is free, the framework pulls the latest remote state, applies the writeback, commits, and pushes, then releases the lock.
- A lock expires automatically after 2 hours of inactivity, so a crashed process can never block a file indefinitely.
Projects without an origin remote (a purely local checkout) are unaffected — writeback runs exactly as in a single-user setup, with zero coordination overhead.
💡 What if a colleague is editing the same file without any coordinating tool? The lock only protects against another participant that actively holds it (another agent run, a teammate’s compatible tooling, or a CI job). A plain, uncoordinated local edit in someone’s editor is invisible to the lock — the usual working-tree hygiene applies: commit or stash before running a job with
SELF_HEALING_WRITEBACK=true, and heed the reporter’s warning if it detects a dirty working tree (see below).
Commit identity
The agent commits and pushes on its own behalf, not silently as you. Set the SELF_HEALING_AGENT_IDENTITY environment variable to control how that shows up in your git log:
| Env-var | Effect |
|---|---|
| (unset) | Identity is derived from your own git config user.name / user.email, with -agent appended to the name and +agent inserted into the email — e.g. Jane Doe-agent <jane+agent@example.com> — clearly distinguishable from your own commits. |
SELF_HEALING_AGENT_IDENTITY="Name <email>" | Uses exactly that name and email as the commit author. |
SELF_HEALING_AGENT_IDENTITY="bare-email@example.com" | Uses the email as-is; the name is derived from the part before @. |
Every auto-commit also carries a Co-Authored-By: <your name> <your email> trailer, using your own git identity unchanged — so the log always shows both who ran the job and who the change is really for.
💡 Recommended for CI: set
SELF_HEALING_AGENT_IDENTITYto something recognizable, e.g."cc-testframework-agent <agent@your-domain>", so automated commits are easy to filter out ofgit blame/ authorship searches.
Stable locators: what a heal avoids writing
When a heal proposes a replacement locator, it’s judged for stability, not just for whether it currently finds the element:
- It prefers a meaningful, stable attribute already on the element itself —
aria-label, a descriptiveid, or adata-testid-style attribute on Web;Name/AutomationIdon Desktop. - It avoids attribute values that look machine-generated or session-volatile — an
idwith a trailing run of digits or a hash-like suffix, or a CSS class that only encodes transient UI state (open,active,expanded,selected, and similar) rather than identity. - When the element itself carries no stable attribute at all, the healed locator anchors on the nearest ancestor that does have one, then descends structurally from there — e.g.
.//div[@aria-label='Settings']/div/divfor a target two levels below a stable container that itself has no attribute worth anchoring on.
This matters because a locator built on a volatile value works today and breaks again the next time that value changes — the same failure the heal was supposed to fix, just deferred. It’s also what lets a nameless, custom-styled cookie-consent banner (see above) heal onto a stable class selector instead of a page-generated element id.
Keep healed locators on your XPath style
When a heal is applied, the healed locator is inserted as the first element of the Control’s locator array (see above) — a semantic, non-destructive edit that preserves your existing fallbacks. That healed XPath itself comes straight out of a Vision call, and can occasionally come back @id-, [data-testid]-, or otherwise non-structural even when the rest of your Control follows the framework’s structural style. By default, this isn’t checked — the XPath-style rule (R4 in Skeleton Conventions) only runs against the Skeleton, so a drifting healed locator still works, but its style can diverge from the rest of your Controls over time, one heal at a time.
Turn on strictConventions (see Skeleton Conventions — Enforce all 6 rules on your own Apps) to close that gap: a pre-commit hook or CI step running with --strict / STRICT_CONVENTIONS=true then validates every changed file, including the one a writeback just touched, and blocks the commit if the healed locator matches a forbidden XPath pattern instead of letting it land silently. You then intervene manually — accept the finding and adjust the XPath by hand, trigger another heal in the hope of a structural candidate, or re-run discovery mode and review before applying.
Recognize when a healed value already fits a parametric locator
Some Controls use a parametric Core.xpath template — one that interpolates a runtime value instead of hardcoding it:
// 1_Controls/Control_DataGrid.ts
Core.xpath`.//*[@class='${identifier}']`
When a heal for one of these Controls comes back, writeback checks whether the healed value is really just an instance of the template you already have. If so, nothing is written — the entry lands in the report with status: "parametric-instantiation" (see below), since your existing parametric locator already covers it. If the healed XPath uses a different attribute or structure but the value still appears in it unambiguously, writeback re-emits it as a new parametric tagged-template instead of a one-off literal, so the new locator strategy covers every value your test suite ever passes through this Control — not just the one that failed this run.
💡 When you still see a literal. For a short value, a value that occurs more than once in the healed XPath, or a value that’s a substring of another interpolated value, writeback falls back to writing the concrete value as a plain string literal — the same behavior as for a Control without a parametric template. This is a deliberate safety choice: an ambiguous match is skipped rather than risked. Occasionally seeing a literal entry appended even though a parametric locator was involved means writeback declined to guess rather than guessed wrong.
Per-instance override store: healing shared, parametrized locators
Some Controls — commonly a View/Section wrapper — use a parametric locator not because one Control instance needs different values over time, but because the SAME Control code is reused across many different screens, e.g.:
// 1_Controls/Section.ts
Core.xpath`.//*[@aria-label='${viewName}']`
A heal against one of these can never be written into that shared line — doing so would change every OTHER instance built on the same template, breaking screens the heal never even ran against. Writeback recognizes this case and skips the source edit — but without somewhere else to persist the fix, the healed locator would be lost on the next run, forcing a fresh Vision call every single time. Instead, the framework persists the whole replacement locator block for that ONE instance in a separate, checked-in file: 2_Apps/<App>/.self-healing-overrides.json.
On a later run, before searching, the framework checks this file for the exact instance — matched by the origin call-site, the interpolated value, and the bound refId. When an entry exists, the search runs against ITS locator block instead of the block declared in your Control — a full replace, never blended or merged with the declared block. The healed locator sits at the bottom of that block, so a still-correct declared locator ahead of it keeps priority; no Vision/API call happens on a repeat run once an entry exists.
The file is an ordinary JSON array, one entry per healed instance:
[
{
"kind": "override",
"control": "Section",
"origin": "1_Controls/Section.ts:12",
"value": "Settings",
"refId": "settings-panel",
"xpathBlock": [
".//*[@aria-label='Settings']",
"//div[contains(@class,'panel-settings')]"
],
"confidence": "high",
"healedAt": "2026-08-14T09:12:00.000Z"
}
]
Commit this file like any other source file — it’s how your team shares healed locators for shared templates, exactly the way in-source writeback shares them for everything else. A later heal against the same instance overwrites its entry (last-writer-wins); every other entry in the file is left untouched.
This is a separate mechanism from in-source writeback described above — a Control with a non-parametrized or genuinely unique locator still gets its heal written directly into the source file as before; only the shared-template case routes to this file.
The report file
Every run using SelfHealingWritebackReporter writes .self-healing-report.json inside pm/, whether or not any heal actually occurred:
{
"schemaVersion": 3,
"runTimestamp": "2026-07-14T10:03:21.000Z",
"writebackMode": "applied",
"totalHeals": 1,
"appliedCount": 1,
"skippedCount": 0,
"commitSha": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
"entries": [
{
"file": "pm/2_Apps/<YourApp>/1_Controls/Control_DataGrid.ts",
"line": 12,
"originalXPath": "//*[@AutomationId='row-3']",
"healedXPath": "(//*[@ControlType='ControlType.DataItem'])[3]",
"confidence": "high",
"status": "applied"
}
]
}
writebackModereflects the top-level mode the run used —applied,discovery, oremitted(see Emit-Only mode) — not to be confused with theSELF_HEALING_WRITEBACK_MODEsetting described above, which controls how far anappliedrun goes toward git.statusper entry is one ofapplied,would-apply(discovery mode),skipped(the write wasn’t possible — e.g. the file changed shape since the heal was recorded, or the file was locked by someone else; theskipReasonfield explains which),parametric-instantiation(the healed value already matches an existing parametricCore.xpathtemplate one-for-one, so nothing needed writing to your Control file — see above), orr4-rejected(the healed locator failed the XPath-style check described in Keep healed locators on your XPath style above and was never written — thediagnosticsfield on that entry names the violated rule).inputTokens/outputTokens/apiCalls/model/estimatedUsd— present per entry when that heal called Vision and its token usage was captured, see Seeing what a heal actually costs below.estimatedUsdisnullwhen the tokens are known butmodelisn’t in the resolved price table (tokens-only); all five fields are entirely absent when no Vision usage was captured at all (e.g. a pre-existing report, or a heal that never reached Vision).commitSha— present whenever at least one file was actually committed through the coordination layer, i.e.writebackModeresolved tocommitorcommit-push(see above) and the commit succeeded. Omitted forworking-treeruns (nothing is committed), for purely local projects, and for runs where nothing was applied.-
lockedByHumans— present only when the run had to skip one or more files because another lock-holder was already coordinating a write to them:"lockedByHumans": [ { "file": "pm/2_Apps/<YourApp>/1_Controls/Control_DataGrid.ts", "owner": "Jane Doe-agent (jane+agent@example.com)" } ] -
diagnostics— a machine-parseable summary of the whole run, present in every report, for a script or an external automation tool to check without re-deriving it fromentries[]itself:"diagnostics": { "reporterInvoked": true, "pendingJsonlPath": ".self-healing-pending.jsonl", "entriesRead": 1, "applied": 1, "skipped": 0, "errors": 0, "gitStatus": { "branch": "main", "remoteReachable": true, "uncommittedCount": 0 }, "refLockStatus": { "activeLocks": [] } }diagnostics.errorscounts a distinct failure mode from an ordinary locked-file skip: it increments when the coordinated commit/push itself failed after the Control file had already been rewritten locally (e.g. the push was rejected or the network dropped). That entry’s ownstatusis stillskipped— writeback is all-or-nothing for the files in one run — but itsskipReasoncarries the underlying git error text instead of a lock-owner description. Treat a nonzerodiagnostics.errorsas “check your working tree for an already-applied local change before re-running,” not as “nothing happened.” schemaVersion: 1reports (runs before this coordination layer existed) never includecommitShaorlockedByHumans; reports written before thediagnosticsfield existed never include it either — treat all three as optional if you parse this file in a CI script (e.g. to post a PR comment).- Review this file together with the Control diffs it produced when it lands in a pull request, or add it to
.gitignoreif you don’t want the transient report tracked in version control.
💡 Working-tree hygiene: the reporter warns — but never blocks — if your git working tree has uncommitted changes before it writes. Mixing automated writebacks with your own in-progress edits makes the eventual diff harder to review; commit or stash first if you want a clean, writeback-only diff.
Diagnosing pipeline issues
Every run using the reporter prints its own status lines to the console — [SelfHealingReporter] Enabled — pending JSONL: <path> at the start when a writeback mode is active, and a one-line applied/skipped/errors summary at the end. For a deeper, on-demand check — including outside of a test run — npx cc-testframework self-healing status runs six checks against your current environment:
| Check | What it verifies |
|---|---|
| Writeback mode | SELF_HEALING_WRITEBACK or SELF_HEALING_WRITEBACK_DISCOVERY is set to true — as an environment variable, or persisted in the config file |
| Anthropic API key | Resolvable via env-var or the OS credential store |
| Agent identity | SELF_HEALING_AGENT_IDENTITY is set — environment variable or the persisted config file, see Commit identity |
| Reporter registration | SelfHealingWritebackReporter is registered — explicitly, or via baseConfig auto-wiring (which currently only reacts to the environment variable, not the config file alone — see the note in Persisting the setup across sessions) |
| Git origin | An origin remote is configured and reachable (a local-only project without one passes trivially) |
| Ref-lock status | Any active coordination locks on the remote, for visibility |
💡 Using only
AI_API_KEYorset-ai-key? The Anthropic API key check above resolves the same way as the OS credential store’s priority chain — it recognizesANTHROPIC_API_KEY(env-var or the OS credential store) but does not yet look atAI_API_KEYor a key persisted withset-ai-key. If Vision itself is working (see Cost and BYOK above) but this specific check still reports the key as missing, that’s expected — runnpx cc-testframework set-ai-keyon its own to confirm what Vision actually resolves.
A healthy setup — here, everything is driven by the persisted config file plus a manually registered reporter, no environment variables needed at all:
$ npx cc-testframework self-healing status
Self-Healing Pipeline Status
✔ Writeback mode: ACTIVE (source: config)
✔ ANTHROPIC_API_KEY: available (source: store)
✔ Agent identity: "cc-testframework-agent <agent@your-domain>" (source: config)
✔ Reporter registration: registered (manual-registration)
✔ Git origin: reachable (https://github.com/<you>/<repo>.git)
✔ Ref-lock status: no active locks
ℹ Pending queue: empty (no unpersisted heals)
ℹ Last report: .self-healing-report.json (1 applied, 0 skipped, 0 errors, 42s ago)
Status: HEALTHY — ready for self-healing runs
A misconfigured one fails one or more checks and explains why — here, Self-Healing was enabled purely via the persisted config file (npx cc-testframework config self-healing enable, no environment variable set), which is enough for the Writeback mode and Agent identity checks, but not for Reporter registration: this project’s playwright.config.ts spreads baseConfig without registering the reporter manually, and baseConfig’s auto-wiring still only reacts to the environment variable:
$ npx cc-testframework self-healing status
Self-Healing Pipeline Status
✔ Writeback mode: ACTIVE (source: config)
✔ ANTHROPIC_API_KEY: available (source: store)
✔ Agent identity: "cc-testframework-agent <agent@your-domain>" (source: config)
✖ Reporter registration: playwright.config.ts spreads baseConfig, but neither SELF_HEALING_WRITEBACK nor _DISCOVERY is set to "true" — auto-wiring will not append the reporter.
✔ Git origin: reachable (https://github.com/<you>/<repo>.git)
✔ Ref-lock status: no active locks
ℹ Pending queue: empty (no unpersisted heals)
ℹ Last report: none yet
Status: MISCONFIGURED — see failing checks above
Either keep SELF_HEALING_WRITEBACK=true set as an environment variable for runs that need the reporter present, or register the reporter manually once — both close this specific gap.
Pass --format json for the same six checks plus info.pendingQueue / info.lastReport as a single JSON object — suitable for a script, or an external test-management/automation tool polling pipeline health before and after a run, without parsing human-formatted text.
| Exit code | Meaning |
|---|---|
0 | Healthy — every check passed. |
1 | Misconfigured — see the failing checks in the output. |
2 | User error — e.g. an invalid --format value. |
Clearing the pending-heals queue
If heals were queued but never applied (writeback was off, or the run crashed before the reporter’s onEnd ran) and you don’t want them retried on the next writeback-enabled run, clear the queue:
npx cc-testframework self-healing clear
This asks for interactive confirmation before deleting .self-healing-pending.jsonl. In a non-interactive context (CI, a script), pass --force — omitting it there exits with code 2 rather than deleting anything, so an unattended pipeline never silently discards pending heals.
| Exit code | Meaning |
|---|---|
0 | Cleared — or you declined the interactive confirmation, in which case nothing changed. |
1 | No pending-heals file found — nothing to clear. |
2 | User error — invalid arguments, or a non-interactive context without --force. |
Cost and BYOK
The Vision step calls the Anthropic API directly from your machine or CI runner, using your own API key (BYOK) — there is no proxy or hosted service in between; the framework never sees or forwards your key anywhere else. It resolves the key from, in this order:
AI_API_KEY— a provider-agnostic environment variable, checked first. Convenient for injecting a secret per run (a CI job, a wrapper script) without any local state.- A key persisted with
npx cc-testframework set-ai-key— see below. ANTHROPIC_API_KEY— the original, Anthropic-specific environment variable. Still fully supported as a fallback; an existingexport ANTHROPIC_API_KEY=...setup keeps working unchanged.
The OS credential store (npx cc-testframework config set anthropic-api-key) remains available too, and reaches Vision indirectly — its value is bridged into ANTHROPIC_API_KEY for you before a test run starts (see Credential Management — Priority chain).
Persisting a key with set-ai-key
npx cc-testframework set-ai-key sk-ant-<your-key>
# ✔ Set aiApiKey (sk-ant-1****...**890) in /path/to/your-project/.cc-testframework.local.json
npx cc-testframework set-ai-key
# aiApiKey = sk-ant-1****...**890 (source: config)
npx cc-testframework set-ai-key --clear
# ✔ Cleared aiApiKey from /path/to/your-project/.cc-testframework.local.json
The key is written to the per-machine, gitignored .cc-testframework.local.json — it is never committed. Run the command with no argument to see the currently resolved value (masked) and which tier it came from, without ever printing the raw key; add --json for the same information as {"key":"aiApiKey","value":"sk-ant-****...**890","source":"config"}, suitable for scripts. --clear removes the persisted value — the AI_API_KEY environment variable still takes priority whenever it’s set, both before and after clearing.
💡
AI_API_KEYorset-ai-key? UseAI_API_KEYif you already inject secrets per run (CI, a wrapper script) — it needs no local state and always wins. Useset-ai-keyfor a one-time, per-machine setup you don’t want to repeat in every shell session. Both feed the exact same Vision pipeline;ANTHROPIC_API_KEYkeeps working underneath either choice.
No command or log line ever prints the raw key in full — only a masked form (sk-ant-1****...**xyz) ever appears, in set-ai-key’s own output as well as anywhere else the framework might reference it.
Each Vision call costs roughly 1–3 US-cents, comparable to a single image-analysis request against Claude. Because writeback persists the healed locator after its first successful application, a given locator triggers at most one paid Vision call in its entire lifetime — every subsequent run finds the healed locator directly in the Control file and never invokes Vision for it again.
Seeing what a heal actually costs
After each Vision-based heal attempt — whether it succeeds or not — the console prints a line with that heal’s actual token usage:
[SelfHealing] Heal cost: 1842 in + 96 out tokens (~$0.0350 est., claude-opus-4-7).
The token counts (in/out) are exact — read from the Anthropic API’s own usage report, summed across every internal retry attempt for that one heal (Self-Healing may retry a suggestion a few times before giving up). The dollar amount is always an estimate (~... est.) — never presented as a precise figure — derived from a small built-in price table for the default vision model (claude-opus-4-7). That built-in rate is an example value at the time of writing, not a live lookup against Anthropic’s own pricing page, and it can drift out of date.
To use your own rate — your negotiated tier, or a different vision model — set CC_AI_PRICE_JSON to a JSON object mapping model name to USD per million tokens:
export CC_AI_PRICE_JSON='{"claude-opus-4-7":{"inputPerMTok":16,"outputPerMTok":80}}'
An entry needs both inputPerMTok and outputPerMTok; anything malformed is ignored (with a console warning) rather than silently distorting the estimate. If a heal ran on a model that’s in neither the built-in table nor CC_AI_PRICE_JSON, the line shows tokens only — no dollar figure is ever guessed.
The same fields — inputTokens, outputTokens, apiCalls, model, estimatedUsd — are written into that heal’s own entry in the report file, for scripts and dashboards. At the end of a run with one or more heals, the reporter also prints an aggregate line:
[SelfHealingReporter] 3 heals — 5210 in + 288 out tokens (~$0.1010 est.)
💡 Set
CC_AI_PRICE_JSONonce (e.g. in your CI environment or shell profile) rather than per run — every heal, in every subsequent run, picks it up automatically.
Where to go next
- API Reference —
Core.xpath,SelfHealingWritebackReporter, and the rest of the curated exports - CI Integration — wiring
SELF_HEALING_WRITEBACK,AI_API_KEY, and the rest into a pipeline - Custom Steps — the
@customconvention for steps without an automated implementation yet - Credential Management — storing
ANTHROPIC_API_KEYin your OS credential store instead of a plain env-var - FAQ — troubleshooting Self-Healing setup, reference screenshots, and API-key configuration
📧 Questions? Contact: jens.szelag@itsbusiness.ch
itsbusiness AG · Bern · Switzerland