Your script finds elements by how the page is built (CSS classes, DOM position, XPath, generated IDs), and those change on redesigns even when the page looks the same to a user. Find elements by what a user sees (role, label, text, or a test ID you own), let Playwright wait for them, and on sites you don't control, plan for drift with a repair step.
We make Browser Use (the last two options below). Playwright facts link to playwright.dev, checked October 27, 2026.
Brittle vs resilient
// Breaks when a designer renames a class or wraps the button in another div
await page.locator("#app > div:nth-child(2) > button.btn-x7f3").click();
await page.waitForTimeout(3000); // a guess at how long the page takes
// Finds the button the way a user does; waits until it can be clicked
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();Causes, symptoms, fixes
| Cause and symptom | Fix |
|---|---|
| Brittle CSS or XPath selectors. Timeout waiting for a locator after a redesign. | Playwright does not recommend CSS or XPath. Use getByRole, getByLabel, getByText. Codegen picks role, text and test ID locators for you |
| Generated class names, dynamic IDs. Works today, fails after the next deploy. | Never select on a generated value such as .css-1x2y3z or #input-4821. In your own app, add a test ID and use getByTestId, which Playwright calls the most resilient option |
| Timing and races. Passes locally, flaky in CI. | Act through locators: a click waits until the element is visible, stable, receives events and is enabled (auto-waiting). Replace waitForTimeout with web-first assertions such as toBeVisible() |
| A new element matches too. The action throws: locators are strict. | Narrow with filter() or chaining. Avoid first() and nth(): when the page changes, Playwright may click an element you did not intend |
| Copy changes. "Submit order" became "Place order". | Your app: a test ID survives text and role changes. Someone else's site: match the stable part of the name with a regex |
| A/B tests, layout variants. Fails on some runs only. | Locate by a role and name present in every variant. In your own app, force one variant in test setup |
| iframes, shadow DOM. Visible in the screenshot, not found by the locator. | iframes: frameLocator(). Shadow DOM: locators pierce open shadow roots, except XPath; closed roots are not supported |
| Session expired. The run lands on the login page. | Check the URL after goto and fail loudly. Playwright: "delete the stored state when it expires" (auth) |
| Cookie banners, popups. An overlay covers the target until timeout. | For overlays that appear unpredictably, page.addLocatorHandler() (since v1.42) runs your handler before an action when the overlay is visible. Dismiss predictable ones in your normal flow |
On sites you don't control you can't add test IDs, so expect drift: detect it, alert, repair.
When selectors drift
Your options, from manual to self-healing:
| Option | What it does, and the catch |
|---|---|
| Fix by hand | Open the trace, pick a new locator with codegen. Catch: developer time on every break |
| Playwright healer agent | Replays the failing steps, inspects the current UI, suggests a patch such as a locator update, and reruns until it passes or its guardrails stop it (docs). Catch: built for Playwright Test suites, run from an AI coding tool; review its patch |
| Agent fallback you write (Browser Use, ours) | Keep your Playwright steps. When one fails, an agent in the same Chrome re-finds the element from the task; both share one browser over CDP (example). Catch: model tokens on each fallback; check what it did |
| Rerunnable script (Browser Use Cloud, ours) | An agent saves a working script. Later runs reuse it and, when the task asks, repair it (self-heal) if the site changed (docs). Catch: every run still starts a model and uses tokens (pricing) |
The fallback in Python. Start Chrome with --remote-debugging-port=9222 --user-data-dir=/tmp/pw-profile; since Chrome 136 the port is ignored on your default profile. Browser(cdp_url=...) attaches to that Chrome (docs); ChatBrowserUse reads BROWSER_USE_API_KEY (docs).
import asyncio
from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeout
from browser_use import Agent, Browser, ChatBrowserUse
CDP = "http://localhost:9222"
URL = "https://example.com/reports"
async def main():
async with async_playwright() as p:
browser = await p.chromium.connect_over_cdp(CDP)
page = await browser.contexts[0].new_page()
await page.goto(URL)
try:
await page.get_by_role("button", name="Export CSV").click(timeout=10_000)
except PlaywrightTimeout:
print("selector drifted, falling back to the agent") # each one is a locator to fix
agent = Agent(task=f"On {URL}, export the table as CSV",
browser=Browser(cdp_url=CDP), llm=ChatBrowserUse())
await agent.run()
asyncio.run(main())More
- All Playwright locator types: Playwright docs
- Playwright, Selenium, Puppeteer and agents compared: browser automation tools
- Run the same Playwright script on a remote browser: /playwright

