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>
230 lines
8.3 KiB
TypeScript
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();
|
|
}
|
|
}
|