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
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:
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.
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.
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();
});
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.
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.
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);
});
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.
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:
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();
});
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.
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.
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.
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.