Recorder internals

Why the Playwright Chrome extension generates brittle selectors

Direct answer (verified 2026-05-13)

The recorder picks selectors in a strict priority order: data-testid, then getByRole with an accessible name, then getByLabel, getByText, and only then a CSS chain. A brittle CSS locator in the output is the recorder telling you every higher-priority signal was missing from your DOM. The picker logic lives in packages/playwright-core/src/server/injected/selectorGenerator.ts. Fix is upstream: add the signal, re-record.

M
Matthew Diakonov
6 min read

If you have ever clicked through a flow with the Playwright Chrome extension or the VSCode recorder and watched it spit out page.locator('.css-1abc23 > div:nth-child(3) > button'), you have hit this exact situation. The fix is not better recorder settings. The recorder is doing what it was written to do. The fix is to give it something better to pick.

The picker has a priority list, and CSS is at the bottom

The function that decides what locator to emit is generateSelector in packages/playwright-core/src/server/injected/selectorGenerator.ts. It walks candidate selectors for the clicked element in this order:

  1. 1. data-testid. If the element (or its closest annotated ancestor) carries data-testid, emit getByTestId('...') and stop. This is the cheapest signal and always wins.
  2. 2. Role + accessible name. A real <button>Save changes</button> emits getByRole('button', { name: 'Save changes' }). Role plus name is the recorder's favourite combination because it survives DOM restructuring and reads like a sentence.
  3. 3. Label, placeholder, text, title, alt. For inputs and links, the recorder will try getByLabel, getByPlaceholder, getByText, getByTitle, and getByAltText in roughly that order, picking the first that resolves to exactly one element on the live page.
  4. 4. CSS chain. Only if nothing above worked does the recorder emit a CSS path. The chain is built by walking the DOM tree from the element up, preferring stable-looking classes, then ids, then tag plus :nth-child. There is no way for the recorder to know which classes are hash-built and which are author-written; both look like strings.

The diagnostic

Every CSS chain in your generated file is a missing-annotation report against your DOM. Treat the recorder output as a checklist of elements that need a role, a label, or a test-id, not as a list of tests to hand-patch.

What happens when you click an unannotated div

The recorder runs through every signal and falls through to CSS only after every higher-priority option misses. This is the typical path for a clickable <div> with an onClick handler and no other attributes, which is what most React component libraries ship by default.

Recorder selector resolution

Recorded clickInjected selectorGeneratorLive DOMOutput fileUser clicks elementTry data-testidNot present, missTry getByRole + accessible nameNo role or name, missTry getByLabel, getByText, getByTitleAll missFall back to CSS path.css-1abc23 > div:nth-child(3)Write brittle locator to file

Three shapes of brittle output, and what each one means

Shape 1

page.locator('.css-1abc23')

A hashed class from CSS-in-JS (Emotion, styled-components, vanilla extract). It is unique today and gone after the next build. The recorder picked it because it was the only thing that resolved to one element. The element has no role, no test-id, no useful text. Add data-testid at the component source and re-record.

Shape 2

page.locator('div:nth-child(3) > div > button')

A positional CSS path with :nth-child. The element is a real <button>, but it has no text content (icon-only) and no aria-label. Role alone is not unique, so role-plus-name failed and the recorder fell to position. Add aria-label="Close dialog" and you get getByRole('button', { name: 'Close dialog' }) instead.

Shape 3

page.getByText('Save').nth(2)

The recorder did get to a high-priority signal, but the text Save is not unique on the page (three Save buttons in three modals). The .nth(2) suffix is positional and breaks the moment one of those modals renders in a different order. Scope the locator to the right container: page.getByRole('dialog', { name: 'Edit profile' }).getByRole('button', { name: 'Save' }) .

How to stop generating brittle locators

None of these are recorder settings. They are DOM changes plus one CLI flag. The recorder picks the best signal it can find; the work is making sure a better signal exists.

1

Read the recorder output as a diagnostic

A long CSS chain in your generated file is not a recorder defect. It is the recorder telling you the element has no test-id, no accessible role, no label, no useful text. Treat each brittle locator as a missing-annotation report against your DOM.

2

Add the cheapest stable signal the element can carry

For interactive elements: a real <button>, <a>, or <input> with text content or an aria-label gets you getByRole. For non-interactive: data-testid='primary-cta' is fine and the recorder will prefer it. Pick whichever is closer to the component you control.

3

Set --test-id-attribute if your codebase already uses a non-default name

npx playwright codegen --test-id-attribute=qa-id https://your-app.com tells the recorder to look for qa-id (or data-cy, or whatever convention you adopted years ago). Without this flag the recorder ignores your existing attributes and falls back to CSS.

4

Re-record the flow, do not hand-patch the file

Once the DOM carries the new signal, run the recorder again on the same flow. The generated locators will move up the priority list and the next person who records something on the same page will get the better locators too. Hand-patching one file fixes one test; fixing the DOM fixes every future recording.

5

For elements you cannot annotate, pin the parent

Third-party widgets, hashed class names from CSS-in-JS, and SVG icons cannot always be fixed at the source. Wrap them in a div with a stable data-testid and use page.getByTestId('chart-wrapper').locator('canvas') instead of a long CSS path. The locator is two parts but the test-id half is stable across rebuilds.

What this means for AI-driven test generation

Any tool that generates Playwright tests by capturing user actions inherits the same locator-priority problem, because the underlying API surface is the same: page.getByRole, page.getByTestId, page.locator. The interesting differences are in what feeds the picker.

Assrt is open source and generates real Playwright code; the output is a plain .spec.ts file you can read, edit, and run in any CI. Because the agent works from a semantic scenario ("click the primary CTA in the pricing card") rather than from a single recorded click, it can choose a role-based locator even when the recorder would have fallen to CSS, and it can flag in the scenario notes when the DOM has no good signal at all. That signal is a clearer fix list than a wall of brittle locators in a generated file.

The recorder is still the right tool for a quick capture, especially on apps you do not own. For an app you do own, the long-term move is to invest in the DOM signals once and let every future capture (manual recorder, agentic, or written by hand) benefit from it.

Want a second pair of eyes on your test suite?

Short call, real diagnosis: walk through one brittle locator with me, decide whether the fix is in the DOM, the recorder config, or the test code itself.

Frequently asked questions

Why does the Playwright Chrome extension keep generating selectors like div > div:nth-child(3) > button?

Because the recorder fell to the bottom of its priority list. Playwright's selector generator (packages/playwright-core/src/server/injected/selectorGenerator.ts) walks elements in this order: data-testid, getByRole with an accessible name, getByLabel, getByPlaceholder, getByText, getByTitle, getByAltText, and only then a CSS fallback. If you see a CSS chain, every higher signal failed for that element. The fix is upstream in the DOM, not downstream in the test: give the element a role, a label, an accessible name, or a stable test-id. The recorder will then pick that on the next pass.

Where exactly is the recorder's selector-picking logic in the Playwright source?

packages/playwright-core/src/server/injected/selectorGenerator.ts. The function generateSelector walks candidate selectors in roughly the order listed above and picks the first one that is unique on the page. The companion file packages/playwright-core/src/server/codegen/javascript.ts is where the generated JavaScript output is assembled. If you ever want to see why a specific selector got picked over another, the comments in selectorGenerator.ts describe the cost function it uses (lower cost wins, with role-and-name treated as cheap and long CSS chains treated as expensive).

How do I tell Playwright codegen to prefer my own test-id attribute name?

Pass --test-id-attribute on the codegen CLI: npx playwright codegen --test-id-attribute=qa-id https://your-app.com. The recorder will then prefer getByTestId on any element that has a qa-id attribute, over every other signal. The same setting in playwright.config.ts is use.testIdAttribute. Without this flag the default is data-testid, which means your data-cy or qa-id attributes are invisible to the recorder and the CSS fallback wins.

Why do CSS modules and styled-components break recorder output worst of all?

Because the hashed class names (.css-1abc23, .Button__styled-xyz) look stable to the recorder at record time, so it sometimes commits to one as the only unique signal. The next build regenerates the hash and the locator breaks. The recorder has no way to know the class is build-generated; from its point of view it is just a string that uniquely identifies the element. The fix is to either add a stable attribute (data-testid, aria-label, or a role with an accessible name) to the component, or to rewrite the generated locator by hand. The recorder will not infer stability from class-name shape.

Why does the same recorder produce different selectors for the same element on two different runs?

Two main reasons. First, the recorder's uniqueness check runs against the live DOM at record time; if the page has dynamic content (a list with changing order, a modal that mounts on a different parent), the candidate that was unique last time may now collide with a sibling, and the picker falls back to a longer CSS path. Second, hashed class names from CSS-in-JS rebuild between sessions, so a selector that hashed to .css-1abc23 yesterday is .css-9zfx12 today. Both failures look like 'flaky recorder output' but they are actually deterministic given the DOM at record time.

Does the Chrome extension version of Playwright Test Recorder behave the same as the CLI codegen?

Same selectorGenerator.ts code path, yes. The Playwright Test for VSCode extension and the standalone Chrome devtools panel both call into the injected script. The difference is surface: VSCode writes into a .spec.ts file, the Chrome devtools recorder shows a side panel you can copy from. The selector quality is identical because the underlying picker is identical. If a selector is brittle in one, it is brittle in the other.

How does Assrt avoid the same brittleness when it generates Playwright tests?

Assrt is open source and generates real Playwright code, so it inherits the same locator surface (page.getByRole, page.getByTestId, page.locator). The difference is what feeds the picker: the Assrt agent crawls the app, builds a scenario from semantic intent ('click the primary CTA in the pricing card'), and emits a locator chosen against the semantic role of the element rather than its position in the DOM tree. When the DOM lacks the semantic anchors a role-based locator needs, the agent will say so in the scenario notes instead of silently committing to a brittle CSS chain. The generated file is plain Playwright; you can read it, edit it, and run it in any CI pipeline.

Are there cases where the recorder is right to pick a CSS chain?

Yes. SVG icons without role or aria-label, third-party widgets you cannot annotate, and dense visual-only UI like a chart canvas. For those, a CSS chain or a getByText against a nearby label is genuinely the best signal available. The mistake is treating every CSS-chain output as a bug in the recorder. Sometimes the recorder is correctly telling you that the only stable thing about this element is its DOM position, and you need to either accept the locator and pin the structure with a wrapper test-id, or use a visual assertion instead.

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.