Files
redefined-designs/frontend/tests/e2e/pages/AdminInventory.ts
T
bermudalambandClaude Opus 5 94b2dfd186 fix(e2e): match the modal OK button exactly so a random suffix cannot collide (#253)
Playwright matches an accessible name case-insensitively and as a substring unless exact is passed, so `getByRole('button', { name: 'OK' })` matched any button whose name merely contained "ok".

Caught on a full serial run of main:

    strict mode violation: getByRole('button', { name: 'OK' }) resolved to 2 elements:
        1) aka getByRole('button', { name: 'Freed rmtiq9okg22k9e' })
        2) aka getByRole('button', { name: 'OK', exact: true })

A leftover "Freed …" toast was still on screen and the random base36 suffix happened to contain "ok". Roughly one suffix in a few hundred does, which is the profile of a test that fails occasionally and reproduces for nobody.

This is not the contention #241 addressed. It reproduced with workers: 1, serially, on a fresh database — it needs only a stale toast and an unlucky suffix. It is at least part of what #245 skipped a real test to work around.

Also makes the taxonomy modal's OK and the sold-filter 'All' radio exact, the only other targets short enough to appear inside a random suffix. The dozen or so remaining short names are left alone deliberately: they would need a collision in deliberately-chosen test data rather than a random one, and substring matching is load-bearing in some of them.

Verified with two consecutive full e2e passes, 155 of 155 both times.

Closes #253

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 09:42:37 -05:00

230 lines
8.3 KiB
TypeScript

import { Locator, Page, expect } from '@playwright/test';
/**
* The admin inventory tab: the item table and the form that adds to it.
*
* Rows are located by the item's name rather than by index. The table is shared
* with every other run's items and is paginated and sortable, so an index means
* a different row depending on what else exists.
*/
export class AdminInventory {
readonly addItemButton: Locator;
readonly name: Locator;
readonly price: Locator;
readonly category: Locator;
readonly saveButton: Locator;
/**
* The item form is an antd Modal, whose footer button is "OK" rather than
* anything named for what it does. Named here so a spec says what it means.
*/
readonly confirmButton: Locator;
readonly formDialog: Locator;
readonly tagPicker: Locator;
constructor(private readonly page: Page) {
this.addItemButton = page.getByRole('button', { name: 'Add Item' });
this.name = page.getByLabel('Name');
this.price = page.getByLabel('Price (USD)');
this.category = page.getByLabel('Category', { exact: true });
this.saveButton = page.getByRole('button', { name: 'Save', exact: true });
// exact, because Playwright matches an accessible name case-insensitively
// as a *substring* by default. Item names carry a random base36 suffix, and
// one containing "ok" — plus any leftover toast still on screen, such as
// "Freed rmtiq9okg22k9e" — makes this resolve to two buttons and fail as a
// strict mode violation. See #253.
this.confirmButton = page.getByRole('button', { name: 'OK', exact: true });
this.formDialog = page.getByRole('dialog');
this.tagPicker = page.getByText('Pick existing tags');
}
row(itemName: string): Locator {
return this.page.getByRole('row').filter({ hasText: itemName });
}
/** The status chip in an item's row — PENDING, AVAILABLE, RESERVED, SOLD. */
status(itemName: string, status: string): Locator {
return this.row(itemName).getByText(status);
}
publishButton(itemName: string): Locator {
return this.row(itemName).getByRole('button', { name: 'Publish' });
}
unpublishButton(itemName: string): Locator {
return this.row(itemName).getByRole('button', { name: 'Unpublish' });
}
/**
* The preview drawer, opened by clicking an item's name.
*
* It renders the item as the storefront card will, which is the question an
* admin is asking of a pending item — a customer never sees the pending state.
*/
previewDrawer(itemName: string): Locator {
return this.page.getByRole('dialog', { name: `Preview: ${itemName}` });
}
async openPreview(itemName: string): Promise<void> {
await this.page.getByRole('button', { name: itemName }).click();
}
deleteButton(itemName: string): Locator {
return this.row(itemName).getByRole('button', { name: 'Delete' });
}
/** The category field inside the form, and the inline-create controls in its popup. */
get categoryField(): Locator {
return this.formDialog.getByLabel('Category', { exact: true });
}
get newCategoryName(): Locator {
return this.page.getByPlaceholder('New category name');
}
get createCategoryButton(): Locator {
return this.page.getByRole('button', { name: 'Create category' });
}
// ---- The inventory filter bar ----
//
// The controls moved into the storefront's flyout (#169), so each method here
// opens the drawer, acts, and closes it again. Closing matters: the drawer
// overlays the table, and every assertion in these specs is about rows.
get filtersButton(): Locator {
return this.page.getByRole('button', { name: 'Filters' });
}
get filterDrawer(): Locator {
return this.page.getByRole('dialog', { name: 'Filters' });
}
/**
* The chip row's own "Clear all", scoped to the group so it stays distinct
* from the identically-labelled button in the drawer's footer.
*
* It exists only while something is filtered, which is what lets a spec assert
* that clearing worked by its absence.
*/
get clearFiltersButton(): Locator {
return this.page
.getByRole('group', { name: 'Active filters' })
.getByRole('button', { name: 'Clear all' });
}
get categoryFilter(): Locator {
return this.page.getByRole('combobox', { name: 'Filter by category' });
}
get statusFilter(): Locator {
return this.page.getByRole('combobox', { name: 'Filter by status' });
}
get minimumPrice(): Locator {
return this.page.getByLabel('Minimum price');
}
get maximumPrice(): Locator {
return this.page.getByLabel('Maximum price');
}
/** Opens the flyout, or leaves it open if it already is. */
async openFilters(): Promise<void> {
if (await this.filterDrawer.isVisible().catch(() => false)) return;
await this.filtersButton.click();
await expect(this.filterDrawer).toBeVisible();
}
/** Closes it through the footer button, which is what a person would click. */
async closeFilters(): Promise<void> {
await this.filterDrawer.getByRole('button', { name: /^Show / }).click();
await expect(this.filterDrawer).toBeHidden();
}
/**
* Toggles one status in the multi-select. Clicking a selected option removes
* it, which is what the clearing test relies on.
*
* The option is matched by class rather than by role because antd renders an
* invisible role="listbox" shim beside the real list for accessibility, so
* getByRole('option') finds something zero-sized that cannot be clicked; and
* once a status is selected it also renders as a tag carrying the same title,
* so an unscoped getByTitle becomes ambiguous.
*/
async toggleStatus(label: string): Promise<void> {
await this.openFilters();
const option = this.page.locator(`.ant-select-item-option[title="${label}"]`);
if (!(await option.isVisible().catch(() => false))) {
await this.statusFilter.click();
}
await option.click();
await this.closeFilters();
}
/**
* Narrows the table to one category.
*
* The table paginates and other specs create items concurrently, so an
* unfiltered page 1 is not a reliable place to look for a fixture. Waiting for
* a known row is the action's contract — the filter has been applied when the
* table has re-rendered under it.
*
* Typing the name before clicking it is not for realism: the tree is
* virtualized, so against a database holding hundreds of categories the wanted
* row is never rendered until a search narrows to it.
*/
async filterByCategory(categoryName: string, expectedRow: string): Promise<void> {
await this.openFilters();
await this.categoryFilter.click();
await this.categoryFilter.fill(categoryName);
await this.page.getByRole('treeitem', { name: categoryName }).click();
await this.closeFilters();
await expect(this.row(expectedRow)).toBeVisible();
}
/** Sets either end of the price range, leaving an omitted end untouched. */
async setPriceRange(minimum?: string, maximum?: string): Promise<void> {
await this.openFilters();
if (minimum !== undefined) await this.minimumPrice.fill(minimum);
if (maximum !== undefined) await this.maximumPrice.fill(maximum);
await this.closeFilters();
}
async clearFilters(): Promise<void> {
await this.clearFiltersButton.click();
}
async openItemForm(): Promise<void> {
await this.addItemButton.click();
}
/**
* Opens the form and waits for the data it fetches on open.
*
* Opening fetches both categories and tags, and each re-renders the modal as
* it lands. Waiting for only one still leaves the second to reflow the popup
* mid-interaction, so anything touching the category field has to wait for
* both rather than racing them.
*/
async openItemFormLoaded(): Promise<void> {
const loaded = Promise.all([
this.page.waitForResponse(
(res) => res.url().includes('/api/admin/categories') && res.request().method() === 'GET'
),
this.page.waitForResponse(
(res) => res.url().includes('/api/admin/tags') && res.request().method() === 'GET'
)
]);
await this.addItemButton.click();
await loaded;
}
/** Fills the item form and submits it. */
async addItem(name: string, price: string): Promise<void> {
await this.openItemForm();
await this.name.fill(name);
await this.price.fill(price);
await this.confirmButton.click();
}
}