Problem
Locators can be narrowed by text, but not by another locator, and cannot be combined.
export interface FilterOptions {
hasText?: string | RegExp
hasNotText?: string | RegExp
}typeof page.locator('li').or // 'undefined'
typeof page.locator('li').and // 'undefined'
page.locator('tr').filter({ has: page.getByRole('button') }) // silently ignoredfilter() reads only the two text keys, so an unknown key is dropped without
complaint: filter({ has: ... }) returns a locator matching everything the
original did, which is worse than an error.
Why it matters
has is how you pick a row by what is inside it, which is the common shape for
any table or list:
// Playwright
const row = page.getByRole('row').filter({ has: page.getByText('Ridge Loop') })
await row.getByRole('button', { name: 'Delete' }).click()hasText is not a substitute. It matches the row's whole text content, so a row
containing "Ridge Loop" anywhere matches — including one whose neighbour column
happens to contain it — and it cannot express "the row containing a button with
this name", because hasText cannot ask about a role.
That a wrong key is silently ignored is the sharper half. filter({ has: ... })
looks like it worked, resolves, and matches every row; a subsequent .first()
then binds to row one and the test passes against the wrong record. Nothing
anywhere reports a problem.
or() is what handles a page with two possible states — a success banner or an
error one — without a race:
await expect(page.getByRole('alert').or(page.getByText('Saved'))).toBeVisible()and() is rarer and comes free once or() exists.
Proposed scope
FilterOptionsgainshas?: LocatorandhasNot?: Locator. A candidate passes when the inner locator resolves to at least one element inside that candidate — which means the inner locator has to be re-rooted at the candidate rather than at the page.Locatoralready holds a resolver closure, so this is a matter of giving it a scoped root, not a new query mechanism.- Reject unknown keys in
filter(). Silently ignoring one is how this became invisible; a thrown error naming the key is the whole difference between a five-second fix and an afternoon. or(other)— union, in document order, de-duplicated. Playwright'soris not strict on its own; strictness still applies when the result is used for an action, which falls out of_one()already.and(other)— intersection.locator.pagegetter, which Playwright has and whichor/andmake natural.
Worth checking that filter({ hasText }) and the new has compose, since
narrowing twice is the normal case.
Acceptance
filter({ has: ... })selects only candidates containing a match, and the reproduction above clicks the right row's button.- The inner locator is scoped to the candidate: a match elsewhere on the page does not qualify a row. This is the test that distinguishes a real implementation from one that just asks whether the inner locator matches anything.
filter({ hasNot: ... })is the complement, asserted against the same markup.filter({ hasTxt: 'typo' })throws, naming the key.or()returns both in document order and does not duplicate an element matched by both sides.and()returns only the overlap.- An action on an
or()resolving to two elements still raises the strictness error.
Follows #1591.