test(e2e): convert the remaining admin specs, completing the POM refactor (#137)
Linting / lint (push) Successful in 1m53s
SonarQube Analysis / sonarqube (push) Successful in 17m12s

The last nine: admin-taxonomy, admin-save-failures, admin-theme, admin-inline-category, admin-item-preview, admin-disable-customer, admin-reserved-items, admin-inventory-filters, email-templates and admin-email-settings.

Every spec in tests/e2e now goes through page objects. Measured rather than asserted:

- raw CSS and antd-internal locators in spec files: 0 (was 13)
- local `register` helpers: 0 (was 9)
- hardcoded http://localhost:5173: 0 (was 3)

The antd knowledge that was spread across seven spec files is now in four page objects, each with the reason written next to it. Three of those are things no reader could have guessed from the locator:

Segmented hides its real radio behind a styled label, so the input is found by role and cannot be clicked — the title attribute is the handle.

The status multi-select renders an invisible role="listbox" shim beside the real list, so getByRole('option') finds something zero-sized; and a selected status renders again as a tag carrying the same title, so an unscoped getByTitle is ambiguous. Matching the visible option class avoids both. The dropdown is also opened only when closed, because antd keeps it open after a selection in multiple mode.

Select popups render into a portal at the end of <body>, outside the tab panel they belong to, so a dropdown cannot be found by scoping to the panel.

AdminPage.tab gained an `exact` flag for one specific collision: the Emails tab contains a rail that also renders tabs, and "Email verification" contains "Email". Without exact matching, opening the Emails tab is ambiguous with the template inside it.

Two helpers stayed local rather than moving into page objects, because they belong to their file's subject rather than to a surface: favorites-filter's `favorite()`, which settles the opt-in modal and waits for its fade before the wrapper stops intercepting pointer events, and admin-theme's `luminance()`, which is a WCAG calculation and not a locator. Both now take page objects as parameters instead of reaching for locators themselves.

Verified: 26/26 spec files converted, tsc clean over the whole tree, lint at the 30-warning src baseline with nothing added. Full suite 123 passed / 5 failed; all five pass in a 33/33 serial re-run, which is the load-related flakiness this suite has had throughout and not a change here — the backend hashes passwords with bcryptjs, a pure-JS implementation that blocks the event loop for every request while it runs.

Closes #137
This commit is contained in:
2026-08-23 19:54:57 -05:00
parent 549a08038e
commit 3b3bd06bbe
17 changed files with 790 additions and 573 deletions
+58 -71
View File
@@ -1,61 +1,52 @@
import { test, expect } from './fixtures';
import { test, expect, createItem, uniqueSuffix } from './fixtures';
const suffix = () => `r${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
// Reserves an item by registering a customer and adding it to their cart, which
// is the only way an item legitimately reaches 'reserved'.
async function reserveItem(page: import('@playwright/test').Page, itemName: string) {
const email = `reserve-${suffix()}@example.com`;
const created = await page.request.post('/api/admin/items', {
multipart: { name: itemName, description: '', price: '75', category_id: '', tags: '[]' }
});
expect(created.ok()).toBeTruthy();
const itemId = (await created.json()).id as number;
// New items are pending, and a pending item cannot be reserved.
expect((await page.request.post(`/api/admin/items/${itemId}/mark-available`)).ok()).toBeTruthy();
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
await page.getByLabel('Password').fill('supersecret123');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
const added = await page.request.post(`/api/cart/items/${itemId}`);
expect(added.status()).toBe(201);
return { email, itemId };
/**
* Puts an item in the signed-in customer's cart, which is the only way an item
* legitimately reaches 'reserved'.
*
* The `customer` fixture supplies the session, so this only has to seed the
* item and add it — a pending item cannot be reserved, hence publishing first,
* which `createItem` does by default.
*/
async function reserve(
request: import('@playwright/test').APIRequestContext,
itemName: string
): Promise<number> {
const item = await createItem(request, { name: itemName, price: '75' });
expect((await request.post(`/api/cart/items/${item.id}`)).status()).toBe(201);
return item.id;
}
test.describe('Admin reserved items', () => {
test('shows a reserved count that opens the held items', async ({ page }) => {
const itemName = `Held ${suffix()}`;
const { email } = await reserveItem(page, itemName);
test('shows a reserved count that opens the held items', async ({
page,
customer,
admin,
adminCustomers
}) => {
const itemName = `Held r${uniqueSuffix()}`;
await reserve(page.request, itemName);
await page.goto('/admin');
await page.getByRole('tab', { name: 'Customers' }).click();
await admin.open('Customers');
await expect(adminCustomers.row(customer.email)).toBeVisible();
await adminCustomers.reservedCountButton(customer.email).click();
const row = page.getByRole('row').filter({ hasText: email });
await expect(row).toBeVisible();
await row.getByRole('button', { name: /item/ }).click();
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
await expect(dialog.getByText(itemName)).toBeVisible();
await expect(adminCustomers.reservedItemsDialog.getByText(itemName)).toBeVisible();
});
test('releasing an item returns it to the storefront as available', async ({ page }) => {
const itemName = `Freed ${suffix()}`;
const { email, itemId } = await reserveItem(page, itemName);
test('releasing an item returns it to the storefront as available', async ({
page,
customer,
admin,
adminCustomers
}) => {
const itemName = `Freed r${uniqueSuffix()}`;
const itemId = await reserve(page.request, itemName);
await page.goto('/admin');
await page.getByRole('tab', { name: 'Customers' }).click();
const row = page.getByRole('row').filter({ hasText: email });
await row.getByRole('button', { name: /item/ }).click();
await admin.open('Customers');
await adminCustomers.reservedCountButton(customer.email).click();
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
await dialog.getByRole('button', { name: 'Release' }).click();
await adminCustomers.reservedItemsDialog.getByRole('button', { name: 'Release' }).click();
await expect(page.getByText(`Released "${itemName}"`)).toBeVisible();
// The point of releasing is that the item becomes purchasable again.
@@ -63,38 +54,34 @@ test.describe('Admin reserved items', () => {
expect(item.status).toBe('available');
});
test('the count drops once the item is released', async ({ page }) => {
const itemName = `Recount ${suffix()}`;
const { email } = await reserveItem(page, itemName);
test('the count drops once the item is released', async ({
page,
customer,
admin,
adminCustomers
}) => {
await reserve(page.request, `Recount r${uniqueSuffix()}`);
await page.goto('/admin');
await page.getByRole('tab', { name: 'Customers' }).click();
const row = page.getByRole('row').filter({ hasText: email });
await row.getByRole('button', { name: /item/ }).click();
await admin.open('Customers');
await adminCustomers.reservedCountButton(customer.email).click();
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
const dialog = adminCustomers.reservedItemsDialog;
await dialog.getByRole('button', { name: 'Release' }).click();
await expect(dialog.getByText("This customer isn't holding any items")).toBeVisible();
// The row behind the dialog must agree with the dialog it opened.
await dialog.getByRole('button', { name: 'Close' }).click();
await expect(row.getByRole('button', { name: /item/ })).toHaveCount(0);
await expect(adminCustomers.reservedCountButton(customer.email)).toHaveCount(0);
});
test('a customer holding nothing shows no link to click', async ({ page }) => {
const email = `idle-${suffix()}@example.com`;
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
await page.getByLabel('Password').fill('supersecret123');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
test('a customer holding nothing shows no link to click', async ({
customer,
admin,
adminCustomers
}) => {
await admin.open('Customers');
await page.goto('/admin');
await page.getByRole('tab', { name: 'Customers' }).click();
const row = page.getByRole('row').filter({ hasText: email });
await expect(row).toBeVisible();
await expect(row.getByRole('button', { name: /item/ })).toHaveCount(0);
await expect(adminCustomers.row(customer.email)).toBeVisible();
await expect(adminCustomers.reservedCountButton(customer.email)).toHaveCount(0);
});
});