ReviewOS

stacks/very-happy-dom

filter({has}) is silently ignored, and locators cannot be combined with or()/and()

#1619
Closed glennmichael123 opened this 8 days ago ·
8 days ago

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 ignored

filter() 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

  • FilterOptions gains has?: Locator and hasNot?: 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. Locator already 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's or is 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.page getter, which Playwright has and which or/and make 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.

Sign in to comment on this issue.