How to, including the half nobody writes about

How to self-healing tests when half the flake isn't a selector at all.

Every result on the first SERP page for this keyword equates self-healing with AI selector repair. That's one axis. The other axis is timing: streaming AI replies, lazy hydration, animated panels, React Suspense boundaries. A retry on a stale selector against a half-rendered page is still red. This guide walks the full two-axis heal, anchored on a 33-line MutationObserver kernel at assrt-mcp/src/core/agent.ts:962-994 that none of the top results mention.

M
Matthew Diakonov
11 min read
4.8from Assrt MCP users
Two heal kernels, 60 lines of MIT Node, one file
MutationObserver injected per call, torn down per call
Self-hosted, no SaaS, no stored locator history

The whole idea, one sentence

Self-healing isn't one technique. It's two kernels: a 6-line catch block that re-snapshots the page after a tool error, and a 33-line MutationObserver loop that waits for the DOM to stop changing before the next assertion fires.

Assrt ships both. They live next to each other in the same file, share the same browser.evaluate primitive, and add up to about 60 lines you can read in one sitting. No vendor SaaS, no proprietary YAML, no stored selector ledger to audit later.

The two axes most guides collapse into one

Search results for this keyword treat "self-healing" as a synonym for "AI selector rewrite". They are different problems with different fixes. If you only address the first, your CI is still red because the second silently fails: the click hit the right element, the assertion ran 80ms too early, the test was flaky.

What the SERP teaches vs. what actually flakes

Self-healing = AI rewrites broken CSS or XPath. A heal queue logs every rewrite for review. Vendor dashboards show locator drift charts.

  • Only addresses selector flake
  • Persists a growing ledger of AI rewrites
  • Says nothing about streaming or hydration
  • Often requires a cloud runner

How the two kernels fit together

Every action your test takes flows through one tool dispatcher loop. On the happy path the dispatcher returns the tool's output to the model. On the unhappy path it routes through one of two heal kernels first. Both kernels live in src/core/agent.ts; both are dispatched by the same try/catch and case handlers; both return state into the same tool_result string the next model turn reads.

One dispatcher, two heal kernels

Your #Case
Agent tool call
Live page state
agent.ts dispatcher
Selector heal
Timing heal
Next tool_result

The how-to, in three steps

You do not write either kernel by hand. The agent calls them on your behalf based on what your #Case asks for. Your job is to write a #Case that lets both kernels do their work.

Three things to do, in order

1

Step 1. Write a #Case in plain English

No locators, no waits, no asserts in code form. One paragraph per scenario. The agent compiles your English into Playwright MCP tool calls.

Save it as a Markdown file, or pass it inline. The grammar is the regex at agent.ts:621: split on '#Case', '#Test', or '#Scenario' headers. Each chunk becomes a scenario. The agent reads it, calls snapshot, dispatches click/type/assert.
2

Step 2. Trust the timing heal

After any async-triggering action (form submit, AI streaming, route navigation) the agent calls wait_for_stable. That triggers the 33-line MutationObserver kernel at agent.ts:962-994.

The kernel injects window.__assrt_observer, polls window.__assrt_mutations every 500ms, declares stable after 2s of zero new mutations (configurable to 10s max), caps at 30s (configurable to 60s max), and tears down both globals before returning. You don't write any of it.
3

Step 3. Trust the selector heal

If a click or type fails (stale ref, element gone, modal opened) the catch block at agent.ts:1012-1019 fires. It re-snapshots, slices to 2,000 chars, inlines the new tree into the next tool_result with one instruction.

The next model turn sees: error message + post-failure accessibility tree + 'Please call snapshot and try a different approach.' It picks a fresh ref and continues. Nothing is stored, nothing is logged, nothing is escalated to a human queue.

What it looks like when you run it

A #Case that exercises both kernels in one run. The timing-heal kicks in after the chat send (the AI reply is streaming). The selector-heal kicks in if a stale ref sneaks through. Both are visible in the run log.

A green run with the timing kernel doing its job
A near-failure rescued by the selector kernel

The 33-line timing-heal kernel, verbatim

This is the block other "how to self-healing tests" guides do not show. It is small enough to read in one screen, public enough to grep in your own node_modules, and self-contained enough to port to a vanilla Playwright spec (we'll do that further down).

assrt-mcp/src/core/agent.ts

Three things make this kernel land cleanly. First, it uses the page's own MutationObserver API rather than a Playwright-side wait, so it is correct for any modern JS framework. Second, the cleanup phase deletes both window globals so a long test session never accumulates observer state. Third, the hard caps at lines 957-958 (Math.min(..., 60) for the timeout, Math.min(..., 10) for the stability window) prevent a runaway scenario from stalling the run.

And the 6-line selector-heal catch block

If the first kernel handles the "assertion fired too early" class of flake, this one handles the "ref went stale between snapshot and click" class. Same file, different point in the dispatcher.

assrt-mcp/src/core/agent.ts

The decision that keeps this small: the post-failure tree and the error live in the same string. Most heal designs return the error and expect the agent to ask "what does the page look like now?" in a follow-up turn. That doubles the token budget and makes timeouts more likely. Inlining the slice means the very next turn already has both pieces.

60

Self-healing in Assrt is two ~30-line kernels in one MIT-licensed file.

agent.ts:962-994 (timing) and 1012-1019 (selector)

How to port the timing-heal back to your own Playwright spec

If you already have a Playwright TypeScript suite and you just want to steal the idea, the kernel ports cleanly to two functions. The mechanism is the same: install a MutationObserver on the page, poll for stability, tear it down. Drop this in tests/helpers/wait-for-stable.ts and call await waitForStable(page) after every async-triggering action.

tests/helpers/wait-for-stable.ts

That helper covers the timing-heal axis. The selector-heal axis is harder to port to a vanilla spec because Playwright assumes you wrote the locator yourself; the natural adaptation is to use page.getByRole(...) and page.getByLabel(...) consistently (they resolve against the same accessibility tree Assrt uses, so they survive DOM reshuffles much better than CSS selectors).

The numbers, all from the source

Every metric below comes from counting lines and reading constants in the public @assrt-ai/assrt package on npm. Install it and verify in your own node_modules.

0Lines in the timing-heal kernel (agent.ts:962-994)
0Lines in the selector-heal catch block (agent.ts:1012-1019)
0Char slice of the post-failure ARIA tree
0msMutation poll interval
0sDefault stable window (max 10)
0sDefault heal timeout (max 60)
0k charsSnapshot cap before truncation
$0/moCost of the open-source kernels

What the SERP tells you to install instead

Vendor self-healing offers more surface than the two kernels above, but most of that surface is paperwork around the heal, not the heal itself. The chips below are the standard vendor bundle. Each one is real value to a specific buyer (compliance, audit, multi-tenant), and each one is unrelated to whether your CI turns green.

AI selector rewrite logstored locator historyfallback selector cascadehuman-review heal queuecloud-runner dependencyper-seat dashboard licenceaudit-trail compliance bolt-onmonthly costvendor lock-inproprietary YAML config

None of those chips touches the timing-flake half of the problem. None of them removes a single line of test you need to maintain. Most of them add review burden you did not have before. If green CI is the goal, the two kernels in one file are the smaller answer.

Two-kernel self-heal vs. vendor self-heal

The honest grid. Vendors win on dashboards, audit, and enterprise procurement. Assrt's kernels win on timing flake, source-on-disk transparency, and total cost.

FeatureVendor self-healing (Mabl, Functionize, BrowserStack, Healenium)Assrt (two heal kernels)
Selector flake (broken CSS/XPath)AI proposes new selector; logged to heal queueCatch block re-snapshots; agent re-orients from new tree
Timing flake (streaming, hydration, animation)Usually unaddressed; vendor docs do not mention itMutationObserver injected per call; waits for DOM quiet
Where the heal logic livesVendor cloud, behind an API60 lines of MIT Node in src/core/agent.ts
What it stores on diskLocator history, AI rewrite log, audit trailNothing. Refs are ephemeral; observer is torn down
Where the test runsVendor cloud runner (some self-host options)Your machine, your Chromium, your /tmp
Compliance/audit dashboardNative fit; usually the main UINot included; out of scope
Per-seat licence$7,500/month and up at the high endFree, MIT, npm install
Lock-in surfaceHeal queue + selector ledger + dashboard schemaZero; you can fork the file

What you actually get in your repo

A bento view of the artefacts a self-healing run produces locally. None of these depend on a cloud account. All of them stay on your filesystem after the run completes.

Per-run results.json

Structured pass/fail report saved to /tmp/assrt/<runId>/results.json. Contains every step the agent took, every assertion fired, and every heal that ran. No cloud roundtrip required.

WebM video

Full screen recording of the run, saved to /tmp/assrt/<runId>/recording.webm. Open it in any browser; auto-opens by default.

Tool-call log

Every navigate/click/type/assert/wait_for_stable call in order, with timings. Grepable plain text.

No selector ledger

Notably absent: a file named locators.json, healed-selectors.log, or any other persistent selector state. The heal kernels are stateless across runs by design.

MIT source on disk

node_modules/@assrt-ai/assrt/dist/core/agent.js. Grep for 'MutationObserver' to confirm the timing kernel; for 'try a different approach' to confirm the selector kernel. The bytes that ship are the bytes you read.

Browser profile

Persisted to ~/.assrt/browser-profile by default. Cookies, localStorage, and auth state survive between runs.

Optional cloud sync

Opt-in. Pushes results to app.assrt.ai for sharing. Disabled by default; the kernels do not depend on it.

Anchor fact

The timing-heal kernel is exactly 33 lines of Node at src/core/agent.ts:962-994. It installs window.__assrt_observer, polls every 500ms, defaults to a 2-second stability window (cap 10s) and a 30-second timeout (cap 60s), and cleans up both globals before returning.

The selector-heal kernel is the 6-line catch block at agent.ts:1012-1019. The snapshot truncation cap that bounds both is one constant: SNAPSHOT_MAX_CHARS = 120_000 at src/core/browser.ts:523. Every number on this page is a line you can grep in your own node_modules within five minutes of installing @assrt-ai/assrt.

0 lines for timing. 0 lines for selector. $0/mo cost.

Honest trade-offs of the two-kernel approach

The two kernels are deliberately small. That bias buys transparency and portability and pays for it in enterprise polish. Worth knowing before you adopt.

What you give up by going small

  • No selector-drift dashboard. Vendors give you a chart of which selectors needed healing over time. Assrt's kernels store nothing; you can't graph what was never written down. If your team wants quarterly drift reports, that's real value Assrt does not ship.
  • No automated change-impact analysis. Some vendors map a heal back to the commit or PR that broke the selector. Assrt does not. The video recording usually tells you which UI change broke the test, but you correlate it to the commit yourself.
  • The timing kernel is not free of edge cases. A page that mutates the DOM continuously (live ticker, animated background, polling counter) will hit the 30s/60s timeout and return "page still changing". The right move there is a more specific wait step against the actual element you care about.
  • Heal happens at runtime, not pre-flight. Vendors with a stored selector ledger can warn you "your locator is going to fail before you run". Assrt only knows about a flake when it happens, then routes through the heal kernels. For a CI smoke run that's usually fine; for a pre-commit guardrail, you may want both.

See the two-kernel heal run live against your own URL.

15 minutes with the Assrt maintainer. Bring a flaky URL; we will run an English #Case against it and watch both heal kernels do their work in the log.

Book a call

How to self-healing tests: specific answers

What is the most common mistake people make when they ask 'how to self-healing tests'?

They assume self-healing means selector heal. Every top SERP result for this query (BrowserStack, Wopee, Healenium, Mabl, TestDino, Ministry of Testing) defines self-healing as 'an AI proposes a new CSS or XPath when the old one breaks'. That's only half the problem. The other half is timing flake: the click landed correctly, but the assertion fired before the React panel finished hydrating, or the streaming AI response was still arriving, or the toast had not animated in. A test that retries the selector against a half-rendered page is still red. Self-healing has to address both axes, and the second one rarely shows up in vendor copy because it doesn't sell licences.

What does Assrt actually do when a test goes flaky?

Two distinct kernels run in two distinct phases. Selector kernel: the catch block at assrt-mcp/src/core/agent.ts lines 1012-1019 catches every tool error, calls `await this.browser.snapshot()`, slices the result to 2,000 characters, and inlines that slice into the next tool_result string with the literal instruction 'Please call snapshot and try a different approach.' The model's next turn sees the failure cause and the updated page in the same message. Timing kernel: the wait_for_stable case at lines 956-1009 injects `new MutationObserver(...)` into the live page via `await this.browser.evaluate(...)`, polls `window.__assrt_mutations` every 500ms, declares the page stable after `stable_seconds` of no new mutations (default 2, max 10), hard-caps at `timeout_seconds` (default 30, max 60), and tears down both globals. Together: ~60 lines of MIT Node, one file, no SaaS.

Why a MutationObserver and not a simple `await page.waitForLoadState('networkidle')`?

networkIdle resolves on a network condition. It does not resolve on rendering. A SPA that keeps a websocket warm, an analytics beacon firing every 5s, or a long-poll never reach idle, so networkIdle either times out or resolves at the wrong moment. The MutationObserver resolves on a render condition. It counts every childList, subtree, and characterData mutation across `document.body` and waits for `stable_seconds` of zero new mutations. Streaming AI responses, hydration, framer-motion transitions, and React Suspense boundaries all stop mutating the DOM eventually; the network may never. The observer is wired with the exact options at agent.ts:968-970: `{ childList: true, subtree: true, characterData: true }`.

Show me the exact 33 lines that do the timing heal.

Open `node_modules/@assrt-ai/assrt/dist/core/agent.js` after `npm install @assrt-ai/assrt`, or read the source at `assrt-mcp/src/core/agent.ts` on GitHub. Lines 962-994 are: an evaluate call that installs `window.__assrt_observer = new MutationObserver(...)` listening on `document.body`; a 500ms poll loop that reads `window.__assrt_mutations`, resets `stableSince` whenever the count changes, and breaks when `stableSince` is older than `stable_seconds`; and a second evaluate call that disconnects the observer and deletes both globals. The whole thing is 33 lines of Node. No external service, no AI roundtrip, no stored state on disk after the call returns.

How do I trigger a timing-heal in my own #Case?

You usually don't have to. The system prompt at agent.ts lines 249-254 tells the agent to call `wait_for_stable` after triggering async actions, so a #Case that reads 'Send the message and verify the AI response contains "hello"' typically results in the agent calling `send → wait_for_stable → snapshot → assert`. If you want to be explicit, write `Wait for the page to stabilise.` as a step in your #Case and the model will dispatch wait_for_stable. If you want to override the defaults, the agent currently picks them from the tool input; the maximums are hard-coded at agent.ts:957-958 and cannot exceed 60s/10s respectively.

Is this open source? Can I read every line of the heal logic?

Yes. assrt-mcp is MIT-licensed on npm as `@assrt-ai/assrt` and on GitHub under the assrt-ai org. The heal logic lives in one file: `src/core/agent.ts`. The selector-heal catch block is 6 lines (1014-1019). The timing-heal kernel is 33 lines (962-994). The snapshot truncation cap that bounds both is one constant: `SNAPSHOT_MAX_CHARS = 120_000` at `src/core/browser.ts:523`. After install you can `grep -n 'MutationObserver' node_modules/@assrt-ai/assrt/dist/core/agent.js` and read the exact bytes that ship.

Vendors charge $7,500 a month for self-healing. Why is Assrt's free?

Because the heal is implementation, not IP. There is no proprietary algorithm to charge for; the timing kernel is a MutationObserver and a poll loop; the selector kernel is a catch block and a string concat. Vendors charge for the SaaS surface around it: dashboards, audit logs of selector rewrites, role-based access, multi-tenant cloud runners, support contracts. Those are valuable to large enterprises and irrelevant to a developer who wants their CI to stop being red. Assrt ships the heal kernels with a free MCP, runs in your own Chrome, writes results to /tmp on your machine. Cloud sync to app.assrt.ai is opt-in.

How do I port the timing-heal pattern back to a vanilla Playwright spec?

Two functions, ~30 lines. First, `installObserver(page)` runs a `page.evaluate` that creates `window.__assrt_observer = new MutationObserver(m => window.__assrt_mutations += m.length)` on `document.body` with `{childList:true, subtree:true, characterData:true}`. Second, `waitForStable(page, {stableMs=2000, timeoutMs=30000})` polls `await page.evaluate('window.__assrt_mutations')` every 500ms, records `stableSince` whenever the count changes, and resolves when `Date.now() - stableSince >= stableMs` or rejects on timeout. Add `await waitForStable(page)` after every async-triggering click in your spec and the timing-flake half of self-healing is solved without any vendor.

What's the difference between this and a fixed `await page.waitForTimeout(2000)`?

A fixed timeout pays the same wait cost on every run regardless of whether the page is fast or slow. The MutationObserver pays only the wait it needs: if the page settles in 600ms, the call returns in 600ms; if it takes 8s, the call returns in 8s; if it never settles, it caps at the timeout. On a 20-step scenario with 6 wait points, fixed timeouts add ~12s; the observer typically adds 3-5s. The bigger payoff is correctness, not speed: a fixed timeout silently swallows late-arriving content that mutates the DOM after the wait elapsed; the observer catches it.

Does the observer leak memory or interfere with the page under test?

No. The cleanup phase at agent.ts:990-994 disconnects the observer and deletes both `window.__assrt_observer` and `window.__assrt_mutations` globals via a second evaluate call. The whole timing-heal call returns the page to its pre-call state on `window`. Even if a test crashes mid-heal, the observer disconnects automatically when the page navigates or unloads. Net memory cost during the heal: one MutationObserver and one number on `window`. Net cost after: zero.

How did this page land for you?

React to reveal totals

Comments ()

Leave a comment to see what others are saying.

Public and anonymous. No signup.