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 { 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 { // 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 { 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 { 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 { 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-` 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 { 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 { await expect(this.page.locator('.item-card').first()).toBeVisible(); } }