Text is the best selector everywhere except inside a loop
You're building a scraper. You click the first product card on the page, and the tool writes down a selector for it. Something reasonable, maybe .product-title or li:nth-child(1) > h3. Then it waits for you to do something else.
But you didn't want that card. You wanted all twenty-four of them.
This is where recorded automation usually stops being useful. Every recorder is built on the same premise: watch what the user did, write it down, play it back. That premise handles click Sign In perfectly. It handles and now do that for every row in the table not at all — because you never did the thing for every row. You did it once and you meant it twenty-four times.
So something has to work out what you meant. What follows is how the selector engine in Orchestra does it, and the thing I want to argue is that the interesting part isn't the selector generation. It's that you're never asked a question.
The setup
Here's one of our test fixtures, a page reduced to its bones:
<h1 id="title">Catalog</h1>
<ul>
<li class="product"><h3>Alpha</h3><span class="price">10</span><a href="/items/alpha">view</a></li>
<li class="product"><h3>Beta</h3><span class="price">20</span><a href="/items/beta">view</a></li>
<li class="product"><h3>Gamma</h3><span class="price">30</span><a href="/items/gamma">view</a></li>
</ul>
You add an Each step to your flow, hit the crosshair next to its selector field, and click the title of the second product. The <h3>, not the row containing it.
What comes back is li.product, matching 3.
Not h3. Not li:nth-child(2). Not .product h3. And not li, which would also be correct here and catastrophically wrong on a page whose nav menu is built from <li> elements.
That's from the e2e suite, which drives a real headless Chromium:
const pt = await centerOf('.product h3', 1)
await page().mouse.click(pt.x, pt.y)
const res = await pick
expect(res?.selector).toBe('li.product')
expect(await page().locator(res!.selector).count()).toBe(3)
Now notice what you didn't do. Nothing asked whether you wanted a selector matching one element or many. You clicked a crosshair, then you clicked an element.
Three questions you never get asked
The engine needs three things before it can rank a single candidate: whether you want one element or many, whether to search the whole page or inside something, and which selector syntaxes are usable here. All three are already knowable from the flow you've built, so none of them is a setting.
One or many comes from the step you're filling in:
// Iterable instruments want a selector matching the whole group, not one element.
const picksList = !!instrument && (
(instrument.type === 'each' && String(instrument.params?._eachMode || '') === 'elements') ||
(instrument.type === 'extract' && String(instrument.params?._extractMode || '') === 'list')
)
An Each loop over elements iterates a group, so its selector field wants a group. An Extract in list mode wants a group. A Click wants exactly one thing. The instrument already declares which it is, so asking the user would be asking them to repeat themselves.
Where to search comes from position in the tree. scopeChainFor walks a step's ancestors and collects the selector of every enclosing Each:
for (const ancestor of path.slice(0, -1)) {
const p = ancestor.params || {}
if (ancestor.type === 'each' && String(p._eachMode || '') === 'elements' && p.selector) {
out.push({ selector: String(p.selector), selectorType: ..., index: ... })
}
}
Drop a Click inside your Each and the picker automatically dims everything outside the current row, refuses clicks that land outside it, and returns a selector relative to the row — span.price, not li.product:nth-child(2) span.price.
Which syntaxes are usable comes from what the step will do with the selector. Three strategies:
bestFor — click, fill, select, upload, extract, and the visibility checks behind If and While. Returns the top-scoring candidate of any kind, preferring the first that matches exactly one element. Free to return a Playwright locator type — getByTestId, getByLabel, getByPlaceholder, exact text — because those steps can consume one.
bestCss — hover, assert, wait-for. Identical ranking, filtered to CSS-engine candidates only. These three feed a raw CSS selector into places where a Playwright locator type isn't available, so a beautifully-scored getByLabel would be useless to them. Better to return the fourth-best candidate that actually works.
detectRow — Each, and only Each. The one that wants matches > 1, inverting everything the other two optimise for.
The dispatch is one line:
const c = cssOnly ? engine.bestCss(el, scope) : engine.bestFor(el, scope)
So the same click on the same element gives you [data-testid="add-cart"] when you're filling a Click step, a plain CSS selector when you're filling a Wait step, and a selector matching all twelve buttons when you're filling an Each. You never chose between them. You chose what you were building, which you had to do anyway.
How the group selector gets made
The algorithm itself is unglamorous. Start at the clicked element, walk up to seven ancestors. At each level, check whether this element even has siblings of the same tag. An only child isn't part of a repeating group, so skip it. Then propose from five families:
- Tag plus a shared stable class —
li.product. Best when it exists. - Tag plus an ARIA role —
li[role="listitem"],div[role="option"]. - Anchored to the nearest nameable ancestor —
#menu li,#menu > li. - Direct child of the parent's own best selector —
ul.results > li. - The bare tag —
li. Fine when it's the only group of its kind, terrible otherwise.
Each gets a base score decayed by how far up we walked (depth * 8), so a shape found immediately beats the same shape found four levels away.
Family 3 earns its keep on markup with no classes at all:
<nav><a href="/x">Home</a></nav>
<ul id="menu">
<li>One</li><li>Two</li><li>Three</li><li>Four</li>
</ul>
<div class="toolbar">
<button>Cut</button><button>Copy</button><button>Paste</button>
</div>
Click "Two" and there's nothing on the element to hang a selector on. But #menu is a stable id four levels up, so #menu li gets proposed, matches 4, and wins. Click "Cut" and you get the toolbar's three buttons. Both are tested.
The part that keeps it honest
Every one of those families can generate something plausible and wrong. li matches your products and the eight items in the site nav. a matches every link on the page. The generator will hand you these cheerfully, so the scoring has to take them away again.
Two guards do it. The first is trivial and load-bearing:
const matches = countCss(selector, within)
if (matches < 2) return
If a proposed group selector matches one element, it isn't a group selector. Don't offer it, don't rank it, drop it. A recorder that confidently offers a one-element "list" is worse than one that offers nothing.
The second guard uses the page's own structure as evidence:
const ratio = matches / Math.max(1, sameTagCount)
const overMatch = ratio > 1.6 ? Math.min(34, (ratio - 1) * 8) : 0
sameTagCount is the size of the local sibling group: how many <li> sit next to the one you clicked. That's the best evidence available for how big the answer should be. You clicked into a group of 3; a selector matching 40 things is not describing that group, whatever else it's describing.
The ratio has to clear 1.6 before anything happens, because real pages have footer links and pagination that widen a group a little. Past that the penalty ramps and caps at 34 — enough to sink a candidate, not enough to make it unrepresentable if nothing else exists. The comment in the source is blunter than the code: so we don't offer "every link on the page."
Inside the loop, the rules invert
Once the Each exists, you record inside it. Click the price in a row and you should get span.price, because that's what will work on row seven.
You'd think scoping just narrows the search. It doesn't. It changes which selectors are good.
Outside a loop, visible text is one of the strongest signals available. button:text-is("Submit") is readable, survives restyling, and matches what a human would point at. It deserves to rank near the top.
Inside a repeating row, text is the worst thing you can grab. It's the one attribute guaranteed to differ between rows. That's what makes them rows. A selector matching "Gamma" works on exactly one iteration and then silently returns nothing for the rest.
So the scores flip:
| Candidate | Unscoped | Inside a row |
|---|---|---|
[aria-label="…"] | 76 | 45 |
button:text-is("Save") | 72 | 30 |
| Playwright exact-text | 64 | 26 |
| Generated CSS path | 50 | 50 |
The generated CSS path doesn't move. Normally it's a mild code smell, the thing you write when you've run out of better ideas. But it describes the shape of a row rather than its contents, and the shape is the part that repeats. Inside a loop it wins by attrition.
I don't think I've seen this written down anywhere: text is a great selector everywhere except the one place people most want to loop. Once you see it the table is obvious. Before you see it, you ship a recorder that generates a beautiful getByText for every row and works precisely once.
Why the scoping is honest
The nice thing about all this is that the editor isn't faking it. Here's what an Each over elements actually generates:
for (const _el of await page.locator(".product").all()) { const _savedPage = page; page = _el
It rebinds page to the element. Child steps aren't scoped by some editor-only mechanism. They run against the element as if it were the page, so a page.locator("h3") inside the loop searches the card, because page is the card. The runtime does the same thing (activeFrame.set(el) around each iteration), and the parser reads that exact shape back out when you import a script.
So "relative to the row" means the same thing in the picker, in the generated Playwright, and at run time. The dimmed overlay you see while picking isn't an approximation of what will happen later. It's the same scoping, drawn.
What gets thrown away before scoring starts
None of it helps if you anchor to garbage. Two small functions decide whether an id or class is trustworthy, and they mostly encode a decade of framework output:
if (/^(ember|yui_|ext-gen|uid_|rc_|downshift-|headlessui-|mui-|mantine-|
react-select-|react-aria|radix-|cdk-|tippy-)/i.test(id)) return false
if (/\d{4,}/.test(id)) return false
if (/^[0-9a-f-]{16,}$/i.test(id)) return false
Prefix lists don't generalise to anything, but they catch the overwhelming majority of real cases. The rule I'm fondest of looks at the final hyphen- or underscore-separated segment:
const last = id.split(/[-_]/).pop() || ''
if (last.length >= 5 && /[a-z]/i.test(last) && /\d/.test(last)
&& !/^[a-z]+\d{1,2}$/i.test(last)) return false
That one rule rejects tooltip-9f8e7d6 and Button_root__x7Gh2 while keeping step2, col2, and tab3. Content hashes are long and mix letters with digits; human-written suffixes are short and mix letters with one or two digits. It's a heuristic, it's occasionally wrong, and it costs five lines.
What this doesn't do
No getByRole. The accessibility-first selector everyone recommends, including Playwright's own docs, and we don't emit it. The reason is a constraint from elsewhere: every selector has to survive a round trip through generated code and back into the visual editor, which means being expressible as a single string. getByRole('button', { name: 'Submit' }) has no single-string form. Roles are still used — as CSS, in li[role="listitem"] and [role="button"]:text-is("Save") — but the idiomatic call isn't available. A real cost of a design decision I'd still make again.
Uniqueness counting is expensive. Every candidate is counted against the live DOM across shadow roots, which is a BFS over every shadowRoot on the page. There's a 400ms cache and a 30,000-element bail-out, and on genuinely enormous pages the engine returns -1 for "couldn't count" rather than lying. An uncounted candidate ranks below a confirmed-unique one and above a confirmed-empty one.
The walk is bounded at seven ancestor levels and eight anchor candidates. Deeply nested groups inside deeply nested layouts can fall outside it. In practice this has been fine; in theory it's where the algorithm gives up.
It's still a guess. All of it is inference from one click. It shows you the selector it picked and the count it got, and every candidate it considered is one dropdown away. A recorder that can't be corrected is just a slower way to write selectors by hand.
The framing that took me longest to reach: a recorder's job isn't to record what you did. It's to work out what you meant. And almost everything it needs in order to do that is already sitting in the flow you're building, so asking would only be making you say it twice.
This is the selector engine in Orchestra, a desktop app for building Playwright automations visually, with an embedded real Chromium you watch them run in. The code and the test assertions in this post are copied out of the repo.