Or press ESC to close.

Heal the Selector, Check the Click: Self-Healing Tests with WebdriverIO v10

Nov 8th 2026 6 min read
medium
webdriverio10.2.0
typescript7.0.2
nodejs26.13.0
ai/ml
ui

AI test steps usually come with two costs. The model is asked on every run, so the suite gets slower, spends tokens, and may not click the same thing twice. And when those steps "self-heal", the healing usually checks that some element was found, not that the test still does what it did before. WebdriverIO v10, released in October 2026, takes a different route with browser.act(). You write a step as intent, a model works it out once, and every run after that replays plain WebdriverIO commands with no model involved. When the page changes, a healed step only counts if it has the same effect as the original. This post builds one test around it, runs it against a small demo shop, and uses a local model through Ollama, so no API key is needed and nothing leaves the machine.

The Problem With Asking a Model Every Time

A step driven by a language model is attractive because it describes what the user does, not how the page is built. "Add the blue T-shirt to the cart" does not break when a class name changes. The catch is when the model runs. If it plans the step on every run, every run pays for it in time and tokens. And because the model can take a different path tomorrow, a green run today says little about which steps will run next time.

Self-healing locators sit at the other end. They are cheap, because a healer only looks for another element when the stored selector stops matching. But "found an element" is a weak check. If a button is renamed and no longer saves anything, a healer that finds it by its position will click it, and the test goes green on a broken feature.

Playwright readers will know the agent approach from Playwright's test agents, which generate tests and repair them while you are writing them. act() works at run time instead, and the difference shows in what it records: not only how to find each element, but what each step did.

Writing the Step as Intent

The demo app is a small shop with a product list, product pages with a size dropdown, and a cart that is saved through POST /api/cart. The test opens the home page and describes the whole flow in one sentence: open the product, pick a size, and add it to the cart. The id option gives the recording a stable name, so renaming the test later does not throw it away. act() never asserts anything, so the checks stay plain WebdriverIO:

                
await browser.url('/')
await browser.act('Open the Blue T-Shirt, pick size M and add it to the cart', { id: 'add-blue-shirt' })

await expect($('#cart-count')).toHaveText('Cart (1)')
await expect($('#cart-items')).toHaveText('1 × Blue T-Shirt (size M)')
                

The model is configured on the ai service from @wdio/ai-service. It accepts any LangChain chat model, which is how a local one gets in. The shorthand 'ollama:qwen3:8b' works, but it keeps Ollama's default context window of 4,096 tokens, and a page snapshot plus the tool definitions do not fit in it. Passing a ChatOllama instance lets us raise the window and turn off qwen3's thinking mode, which only slows down a task like this. The instructions file is covered in the last section.

                
services: [['ai', {
    model: new ChatOllama({
        model: 'qwen3:8b',
        numCtx: 16384,
        think: false,
        temperature: 0
    }),
    instructions: './ai-instructions.md'
}]]
                

The browser capabilities also set webSocketUrl: true, which opens a WebDriver BiDi session. BiDi is what lets the service watch requests and page changes while each step runs, and the next two sections depend on it.

Record Once, Replay for Free

On the first run, act() sends the instruction to the model together with a snapshot of the page: a tree of roles and accessible names, each with a short reference. The model picks an action, the service runs it as a real WebdriverIO command, and the loop repeats until the model reports that it is done. Every action is written to __act__/cart.e2e.ts.json next to the spec. Here is the last of the three recorded steps, trimmed to the fields that matter:

                
{
  "code": "await $('role/button[name=\"Add to cart\"]').click()",
  "target": {
    "candidates": [
      "role/button[name=\"Add to cart\"]",
      "aria/Add to cart",
      "#add-to-cart",
      "button=Add to cart",
      "main > button"
    ]
  },
  "effect": {
    "requests": ["POST /api/cart → 2xx"],
    "changed": ["banner", "complementary \"Cart\""]
  }
}
                

Three things are stored. code is the command that will be replayed, with a selector built from the element's role and accessible name instead of ids or classes. candidates are other selectors for the same element, kept for later. effect is what the click did: it sent a successful POST to the cart API and changed the header and the cart panel. That last field is what makes the healing in the next section trustworthy.

On the second run the model is not involved. The service finds the entry by its id and replays the commands. The summary it prints at the end of each run shows the difference. The runs below are from a laptop with a 6 GB GTX 1660 Ti, where the model does not fit in GPU memory and runs partly on the CPU, so the recording time will be shorter on a bigger GPU. The token count and the replay time do not depend on the hardware:

First run: 1 passing (1m 8.6s)
@wdio/ai-service: 1 act call · 0 from cache · 0 healed without the model ·
  0 healed by the model · 1 recorded by the model · 18.2k tokens

Second run: 1 passing (2.2s)
@wdio/ai-service: 1 act call · 1 from cache · 0 healed without the model ·
  0 healed by the model · 0 recorded by the model · 0 tokens

The recording file is meant to be committed, and reviewing it in a pull request shows exactly what the test will do. On CI, setting the cache option to 'locked' makes the service replay only, so a pipeline fails instead of quietly calling a model. And if you ever want to drop the service, npx wdio-ai eject rewrites each act() call into the recorded WebdriverIO lines, with the instruction kept as a comment.

Healing That Checks What the Click Did

To see how the recording holds up, the demo shop can serve four variants of the product page, each breaking the recording in a different way. The test and the recording stay the same. Only the page changes. The model is called in one row only, and that is the one run time that would change on other hardware:

What changed on the page Result Run time Model tokens
New ids, classes and wrapper elements Passed, no healing needed 2.2s 0
"Size" became "Choose a size", "Add to cart" became "Add to bag" Healed from recorded fallbacks 12.4s 0
New labels, ids and markup Healed by the model 43.3s 21.0k
Button renamed and no longer calls the cart API Failed 7.2s 0

The first row needs no healing at all. The recorded selectors use roles and names, so new ids and classes do not touch them. The second row is the cheap kind of healing. The role selectors stop matching, the service tries the recorded candidates in order, and #size and #add-to-cart still work. It logs each swap, for example role/button[name="Add to cart"] → #add-to-cart, and no model is called. In the third row nothing recorded matches, so the model gets the steps that already ran plus the one that failed, and continues from the current page. With the cache set to 'heal', the healed steps go to the output folder for review instead of overwriting the committed file.

The last row is the one that matters. The button is renamed and no longer calls the cart API. It only changes its own text to "Added!". The recorded fallback main > button still finds it, and the click works. A healer that only looks for an element would stop there and pass. act() compares what the click did with what it did when it was recorded, and fails the step without asking the model:

act("Open the Blue T-Shirt, pick size M and add it to the cart") failed:
cached step 3 (await $('role/button[name="Add to cart"]').click())
no longer finds its element, and the step ran on main > button,
which does not cause POST /api/cart → 2xx, a change in banner,
a change in complementary "Cart".
The app may have changed behavior, not just markup.

That is the whole difference in one message. The question is not whether there is still a button, but whether pressing it still does the same thing.

Where It Falls Short

Everything below came up while building the example with WebdriverIO 10.2.0 and qwen3:8b.

Takeaways

browser.act() keeps the intent in the test and the model out of the run. A model works the step out once, the result is a file of plain WebdriverIO commands you can review and commit, and a green run costs nothing. When the page changes, the cheap heals come from recorded fallback selectors, and a heal only counts if the step still causes what it caused when it was recorded. That is the check most self-healing tools skip. It runs with a local 8B model, given a larger context window and one line of instructions, and it is new enough that the limits above are worth knowing before you rely on it.

The full code example from this post is available on our GitHub repository.