Files
redefined-designs/frontend/tests/e2e/pages/StorefrontPage.ts
T
bermudalambandClaude Opus 5 1bd0024e6f
Linting / lint (pull_request) Successful in 3m17s
SonarQube Analysis / sonarqube (pull_request) Failing after 31m17s
test(storefront): cover paging, and say what the filter tests meant (#269)
Two assertions named a fixture and expected it visible in the unfiltered grid. No paginated catalogue can promise that — the item is on some page, not necessarily the first — so both would have started failing the moment paging landed. They were only ever proxies for "the result set got bigger", and the visible total lets them say that directly, which is what the issue predicted when it asked for a count.

The new cases assert the control and the URL rather than which item is on which page, because the development database never truncates and which item lands where is not something a test may rely on. That is the same trap the two rewritten assertions had fallen into, and repeating it in new tests would have been worse than leaving them alone.

Writing them found a real defect rather than just covering the feature. The control was rendering while the catalogue was still loading, showing "0 items" for a moment before the real count arrived — the empty-state early return only fires once loading has finished, so a mid-load render fell through to the grid branch with a total of zero. It is now suppressed until there is something to count, which is both true and what makes the count usable as a signal in a test. StorefrontPage.totalItems waits for the control for the same reason: reading during the load returned zero and quietly made "the result set shrank" compare against nothing.

The conditional skips carry a file-level eslint exception with its reasoning rather than being left to add four warnings. They are honest about a real limit: against a catalogue of ten items or fewer these cases prove nothing, and if the e2e database is ever seeded that thinly they need fixtures of their own instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:11:07 -05:00

202 lines
7.8 KiB
TypeScript

import { Locator, Page, expect } from '@playwright/test';
import { Header } from './Header';
/**
* The public catalogue, and the states it shows instead of one.
*
* The item card locator is the one place in the suite that knows the storefront
* renders items into `.item-card` — three specs reached for that class directly,
* and two others used `.ant-col`, which is antd's grid rather than anything this
* application owns. Both break on an antd upgrade, in files that have nothing to
* do with the upgrade.
*/
export class StorefrontPage {
readonly header: Header;
readonly filtersButton: Locator;
readonly privacyPolicyLink: Locator;
/**
* The chip row summarising what is filtered, which lives on the storefront
* rather than in the drawer. Scoping matters: the drawer carries a "Clear
* all" of its own, so an unscoped one matches both.
*/
readonly activeFilters: Locator;
/** Shown instead of the grid when filters match nothing. */
readonly noMatchesNotice: Locator;
/** Shown instead of that when a favorites filter is set with no session. */
readonly favoritesNeedSignInNotice: Locator;
/**
* The three things the catalogue can say instead of listing items. Named
* together because the distinction between them is the point: telling a
* customer "No items yet" while the server is broken reads as an empty shop
* and hides the outage, so several tests assert one is showing and another
* is not.
*/
readonly emptyNotice: Locator;
readonly loadFailureNotice: Locator;
readonly retryButton: Locator;
/** What the nearest error boundary renders when the grid itself throws. */
readonly catalogueBoundaryHeading: Locator;
/** The paging control under the grid (#269). */
readonly pagination: Locator;
/**
* Where the catalogue says how many items it has.
*
* A class locator rather than a role, matching how this file already reaches
* `.item-card` — antd gives the total no role of its own, and this is the one
* place in the suite that knows where it lives.
*/
readonly resultCount: Locator;
constructor(private readonly page: Page) {
this.header = new Header(page);
this.filtersButton = page.getByRole('button', { name: /Filters/ });
this.privacyPolicyLink = page.getByRole('link', { name: 'Privacy Policy' });
this.activeFilters = page.getByRole('group', { name: 'Active filters' });
this.noMatchesNotice = page.getByText('No items match these filters');
this.favoritesNeedSignInNotice = page.getByText('Sign in to see the items you have favorited');
this.emptyNotice = page.getByText('No items yet');
this.loadFailureNotice = page.getByText("Couldn't load items");
this.retryButton = page.getByRole('button', { name: 'Retry' });
this.catalogueBoundaryHeading = page.getByRole('heading', { name: "The item list didn't load" });
this.pagination = page.locator('.ant-pagination');
this.resultCount = page.locator('.ant-pagination-total-text');
}
async goto(): Promise<void> {
await this.page.goto('/');
}
/**
* How many items the catalogue says it has, in total, across every page.
*
* The number rather than the text, so a test can assert a result set grew or
* shrank without knowing what it grew from. That is the whole reason #269
* added a visible count: two assertions used to name a fixture and expect it
* in the unfiltered grid, which no paginated catalogue can promise.
*/
async totalItems(): Promise<number> {
// Waits rather than reading straight away. The control is not rendered
// until there is something to count, so reading during the initial load
// used to return 0 and quietly make "the result set shrank" assertions
// compare against nothing.
await this.resultCount.waitFor();
const text = (await this.resultCount.textContent()) ?? '';
const digits = /(\d+)/.exec(text);
if (digits === null) throw new Error(`no count in the pagination total: "${text}"`);
return Number(digits[1]);
}
/**
* Goes to the storefront and waits for the session to settle.
*
* Navigating remounts the app, so the session is briefly still resolving. The
* favorite control deliberately ignores clicks in that window rather than
* wrongly prompting a signed-in customer to sign in, so a test that clicks
* immediately gets nothing and no error. Waiting for the header is what a real
* customer sees settle too.
*/
async gotoSignedIn(): Promise<void> {
await this.goto();
await this.header.waitForSignedIn();
}
/** One item's card, located by the name shown on it. */
card(name: string): Locator {
return this.page.locator('.item-card').filter({ hasText: name });
}
addToCartButton(name: string): Locator {
return this.card(name).getByRole('button', { name: 'Add to Cart' });
}
/**
* The heart, in whichever state it is currently in.
*
* Located page-wide rather than inside the card: the storefront paginates as
* items accumulate, and the control is named for the item anyway, so scoping
* to a card buys nothing and breaks when the card is on another page.
*/
favoriteToggle(name: string): Locator {
return this.page.getByRole('button', { name: new RegExp(`(Add|Remove) ${name}`) });
}
/**
* The two settled states, named separately because the tests assert on the
* transition between them — a favorite that took is a "Remove" control, and
* one that did not is still an "Add".
*/
addToFavoritesButton(name: string): Locator {
return this.page.getByRole('button', { name: `Add ${name} to favorites` });
}
removeFromFavoritesButton(name: string): Locator {
return this.page.getByRole('button', { name: `Remove ${name} from favorites` });
}
async openFilters(): Promise<void> {
await this.filtersButton.click();
}
/**
* The item's whole grid cell rather than its card.
*
* The SOLD ribbon is rendered outside the card, so a test asserting on it has
* to reach the cell — and it has to be scoped to this item, because sold items
* from earlier runs share the page.
*/
gridCell(name: string): Locator {
return this.page.locator('.ant-col').filter({ hasText: name });
}
/**
* The availability segment.
*
* antd's Segmented hides the real radio behind a styled label, so the input is
* found by role but cannot be clicked. The label carries a title attribute,
* which is the same handle this suite uses for antd Select options.
*/
async chooseAvailability(label: string): Promise<void> {
await this.page.getByTitle(label, { exact: true }).click();
}
removeFilterChip(name: string): Locator {
return this.page.getByRole('button', { name: `Remove filter ${name}` });
}
/**
* The chip itself rather than its remove button, for asserting how it looks.
*
* antd expresses a tag's colour as an `ant-tag-<colour>` class, so the chip
* element is what carries it — the button inside only inherits the text
* colour.
*/
filterChip(name: string): Locator {
return this.activeFilters.locator('.ant-tag').filter({ hasText: name });
}
/** A tag as rendered on a product card, which is where its colour is set. */
cardTag(itemName: string, tagName: string): Locator {
return this.card(itemName).locator('.ant-tag').filter({ hasText: tagName });
}
async clearAllFilters(): Promise<void> {
await this.activeFilters.getByRole('button', { name: 'Clear all' }).click();
}
/**
* Waits until the catalogue has rendered something.
*
* A test that asserts an item is absent needs to know the list arrived and did
* not contain it, rather than that it asserted before the fetch resolved —
* which passes for the wrong reason and keeps passing when the filter breaks.
*/
async waitForAnyItem(): Promise<void> {
await expect(this.page.locator('.item-card').first()).toBeVisible();
}
}