The Testing Academy · Class Notes Saturday, 26 September (IST)
Live class · study guide

locator.filter(), the :has() pseudo-class, and searching a paginated web table

How to narrow a list of matches with locator.filter(), select a table row by its contents with the :has() pseudo-class, and find a record in a paginated web table with a while loop that stops when the next button is disabled, then the same search wrapped in a reusable function. Four specs, all pushed to the batch repo.

By Pramod Dutta, The Testing Academy. Study notes from the live Playwright 3x class, built from the session recording and the batch repository, which received the class's specs that afternoon (commit "feat: add locator filtering and table pagination"). Every spec on this page was run against the live practice pages, and the output shown is what they print. The Eraser deck was not reachable while this page was written.

01

What this class covered

  • Recap: three ways to select Rohan Mehta in the web table homework
  • locator.filter(): narrowing a list of matches with hasText, hasNotText, has and hasNot
  • Why page.locator comes before getByRole and the other built-in locators
  • CSS pseudo-classes, and selecting a table row with :has()
  • first() as a safeguard, and waitForTimeout in place of pause
  • The interview question: find a person across a paginated table
  • The while loop that stops when next is disabled, and the bug that made it fail first time
  • What to do when there is no next button
  • Wrapping the search in a reusable findRowByName function
  • Two interview-style exercises, and a test
02

Recap: three ways to select a row

The homework from the previous class was to select Rohan Mehta's row on the web table page. The class went through the working answers: walk the rows and columns with a dynamic XPath, as in the Helen Bennett example; or match the username cell and step sideways with an XPath axis. The second is one line:

TypeScript
await page.locator('//td[text()="Rohan.Mehta"]/preceding-sibling::td/input').click();

Both work. This class adds two more tools that make the same job shorter: filter() and the :has() pseudo-class.

03

Narrowing a list with filter()

When a locator matches several elements, filter() narrows it down. It takes options:

Option Keeps the elements that
hasText contain this text somewhere inside
hasNotText do not contain this text
has contain an element matching this inner locator
hasNot do not contain an element matching it

The first example redoes the Forgotten Password click from the previous class without reading all the labels first, then checks a footer link. The footer on that page has sixteen links; the filter picks out the one reading Privacy Policy.

TypeScript
import { test, expect, Locator } from '@playwright/test';

test('Verify the TestCase', async ({ page }) => {
   await page.goto("https://app.thetestingacademy.com/playwright/multiple_element_filter");

   const forgottenPasswordLink = page.locator('a.list-group-item')
      .filter({ hasText : 'Forgotten Password'});
   await forgottenPasswordLink.click();

   const privacyLink = page.locator('footer a')
   .filter(
      { hasText: 'Privacy Policy' }
   );

   await expect(privacyLink).toHaveAttribute('href', '#privacy-policy');

   await page.pause();
});
MANY MATCHES IN, ONE OUT footer a 16 matches .filter({ hasText: 'Privacy Policy' }) href = #privacy-policy 1 match, so the assertion is safe THE FOUR OPTIONS hasText / hasNotText test the text inside each match has / hasNot test for an element inside each match
filter() only makes sense on a locator that matches more than one element. It always returns a locator, never an array.

Why page.locator first. The built-in locators (getByRole, getByTestId, getByPlaceholder) come later in the course, deliberately. On real applications they do not reach every element, and the fallback is always page.locator with CSS or XPath. Once page.locator is second nature, the built-in ones are easy.

04

Selecting a row with :has()

CSS has pseudo-classes, the CSS counterpart of XPath's axes. The one that matters for tables is :has(): it matches an element that contains something matching the selector inside the brackets, and returns the outer element. Playwright adds its own text pseudo-classes on top of CSS, such as :text(), so you can match on a cell's text.

Put together: find the table row that has a cell reading Rohan.Mehta, then look inside that row for its checkbox.

TypeScript
import { test, expect, Locator } from '@playwright/test';

test('Verify the TestCase', async ({ page }) => {
   await page.goto("https://app.thetestingacademy.com/playwright/webtable");

   // await page.locator('//td[text()="Rohan.Mehta"]/preceding-sibling::td/input').click();


   await page.locator("tr:has(td:text('Rohan.Mehta'))")
   .locator('input')
   .first()
   .click();




   // await page.pause();
   await page.waitForTimeout(5000);
});
MATCH A CELL, GET ITS ROW, LOOK INSIDE THE ROW the tr that :has() returns [ ] input Rohan.Mehta Rohan Mehta SDET Lead td:text('Rohan.Mehta') 1. the inner selector matches this cell .locator('input').first() 3. search again, but only inside that row 2. tr:has(...) returns the whole row that contains the matching cell
:has() is how CSS walks upward. It replaces the XPath preceding-sibling step with "find the row, then look inside it".

Getting the quotes right took a few attempts in class. The selector is a string that itself contains a string, so the two must use different quote marks: double quotes outside, single quotes inside, as in the pushed code. The first version also located td inside the row instead of input, which clicked a cell rather than the checkbox. Running it confirms the version above checks the box, and that the :has() selector matches exactly one row.

first() is a safeguard. Locators are strict: an action on a locator that matches more than one element throws rather than guessing. Here the row contains one checkbox, so .first() changes nothing today. It stops the test breaking if the markup later gains a second input in that row.

waitForTimeout(5000) is five seconds, not five milliseconds. The argument is in milliseconds. The class used it instead of page.pause() to keep the browser open long enough to see the checkbox tick, without opening the Inspector. That is fine for watching a demo. Do not leave it in real tests: a fixed wait is either too long, which is slow, or too short, which is flaky.

05

The interview question: find a person across pages

The second half of the class was a real interview question. The table on the practice page is paginated: eight rows at a time, with a next button. Find Luca Greco, then print his email and country. Luca is not on page one; he is on page two.

You do not know in advance which page a name is on, or how many pages there are, so the loop runs until it either finds the row or runs out of pages:

KEEP TURNING PAGES UNTIL FOUND, OR UNTIL THERE ARE NONE LEFT rows.filter({ hasText: name }) await row.count() > 0 ? yes break, read email + country no await next.isDisabled() ? yes throw "Row not found!" no await next.click() Verified on the page Luca Greco: page 2 Mia Hoffman: page 3 Pramod: Row not found
The loop has exactly two exits: the row is found, or the next button is disabled on the last page.
TypeScript
import { test, expect, Locator } from '@playwright/test';

test('Verify the TestCase', async ({ page }) => {
   await page.goto("https://app.thetestingacademy.com/playwright/tables/webtable");

   let name: string = 'Luca Greco';
   let row;
   while (true) {
      row = page.locator('#employees-tbody tr').filter({ hasText: name });
      if (await row.count()) {
         break;
      }
      const next = page.getByTestId('next-page');
      if (await next.isDisabled()) {
         throw new Error("Row not found!");
      }
      await next.click();
   }

   const email = await row.locator('td[data-col="email"]').innerText();
   const country = await row.locator('td[data-col="country"]').innerText();


   console.log(email, country);


   await page.pause();
});
Text
luca@tta.dev Italy

On page one the filter finds nothing, count() is 0, and 0 is falsy, so the loop moves on to the next button. On page two the count is 1, the condition is true, and the loop breaks. The cells are read by their data-col attributes rather than by position, so the code does not care about column order.

The first run failed, and why. It threw "Row not found!" on page one, although Luca is on page two. The check had been written as next.isDisabled, without the brackets. That is a reference to the method itself, not a call to it, and a function is always truthy, so the "last page" branch fired immediately. isDisabled() is a method you call, and it returns a promise, hence await next.isDisabled().

Changing the name shows both exits working: Mia Hoffman is found on page three, and a name that is not in the table, such as Pramod, walks every page and then throws "Row not found!".

No next button? Some tables have numbered page buttons or an arrow instead. Count the page buttons and click through them, or find the arrow. Tables that load more rows as you scroll need a scrolling approach, which is covered in a later class.

06

Making the search reusable

The loop does not depend on Luca. Pull it into a function that takes the page and a name and returns the row, and any test can use it. It returns Promise<Locator> because filter() returns a locator and the function is async.

TypeScript
import { test, Page, expect, Locator } from '@playwright/test';

async function findRowByName(page: Page, name: string): Promise<Locator> {
   while (true) {
      const row = page.locator('#employees-tbody tr').filter({ hasText: name });
      if (await row.count()) {
         return row;
      }

      const next = page.getByTestId('next-page');
      if (await next.isDisabled()) throw new Error(`Row not found: ${name}`);
      await next.click();
   }
}


test('Verify the TestCase', async ({ page }) => {
   await page.goto("https://app.thetestingacademy.com/playwright/tables/webtable");

   let name: string = "Luca Greco";
   const row = await findRowByName(page, 'Luca Greco');
   const email = await row.locator('td[data-col="email"]').innerText();
   const country = await row.locator('td[data-col="country"]').innerText();
   console.log(email, country);

   await page.pause();
});

It prints the same luca line as before. Inside the function, return row replaces break, and row can be a const because a fresh locator is made on each pass. Functions like this are what the framework's utilities folder will hold later: anything the tests repeat becomes an async helper.

The while-true pattern is the default because the page count is usually unknown. If you do know it, you can loop a fixed number of times instead. The practice page lists both approaches, with solutions.

07

Tasks and announcements

  • A test opened the next morning at 9 AM, with twelve hours to complete it, covering everything so far.
  • Next classes: iframes, windows, keyboard and hover, SVG, JavaScript alerts, scrolling, file upload and download, data-driven tests and the page object model, then the framework.

Exercise 1, OrangeHRM. Log in to the OrangeHRM instance from the task post, add an employee with a name nobody else will use, then find that employee in the PIM list and delete them. A favourite interview question. The login details are in the task post.

Exercise 2, Flipkart. Search for "DSLR camera", then print the title and price of every result, clicking next page by page until there is no next page left.