A tag carries a colour, and every place a tag appears shows it — a product card, the filter drawer's control, the admin taxonomy screen — except the removable chips beside the Filters button, which rendered every filter as a default grey. Picking `vintage` from a control that showed it in red produced a grey chip of the same name right next to it.
Only tags get a colour, because only tags have one. Category, price, favorites and status keep the default, and that asymmetry is the point: in a row mixing four kinds of filter, colour now means "this is a tag". Nothing depends on it — every chip still carries its label — so this reads the same to anyone who cannot distinguish the colours.
The close control inherits the tag's text colour, so a coloured chip gets a matching cross rather than a grey one on a coloured ground. A tag not yet in the loaded options has no colour to use and keeps the default, which is the same window the existing `Tag {id}` label fallback covers.
The test asserts the chip's colour equals the same tag's colour on a product card, rather than asserting it is red. The colour is derived from the tag's name and free to change; what must hold is that a tag looks like itself wherever it appears, and comparing the two places says that directly. Confirmed to fail without the change — the old grey chip sets no colour class at all.
Verified visually as well as by assertion: three tags selected together render in the drawer, in the chip row and on the card in the same colours.
Closes #185
169 lines
6.3 KiB
TypeScript
169 lines
6.3 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;
|
|
|
|
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" });
|
|
}
|
|
|
|
async goto(): Promise<void> {
|
|
await this.page.goto('/');
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
}
|