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
@@ -1,65 +1,40 @@
import { test, expect, Page } from './fixtures'; import { test, expect, createItem, uniqueSuffix } from './fixtures';
const PASSWORD = 'supersecret123';
const uniqueEmail = () => `disable-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
async function register(page: Page, email: string) {
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(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
// Registering now closes the auth modal and returns to the page behind it, so
// the header rather than the URL is what proves the session exists. The wait
// is generous because this is a bcrypt round-trip rather than a render.
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
}
async function customerRow(page: Page, email: string) {
await page.goto('/admin');
await page.getByRole('tab', { name: 'Customers' }).click();
const row = page.getByRole('row').filter({ hasText: email });
await expect(row).toBeVisible();
return row;
}
test.describe('Disabling a customer account', () => { test.describe('Disabling a customer account', () => {
test('an admin can disable an account and the customer is told at sign-in', async ({ page }) => { test('an admin can disable an account and the customer is told at sign-in', async ({
const email = uniqueEmail(); page,
await register(page, email); customer,
admin,
adminCustomers,
authModal
}) => {
await admin.open('Customers');
await expect(adminCustomers.row(customer.email)).toBeVisible();
await expect(adminCustomers.status(customer.email, 'ACTIVE')).toBeVisible();
const row = await customerRow(page, email); await adminCustomers.disable(customer.email);
await expect(row.getByText('ACTIVE')).toBeVisible();
await row.getByRole('button', { name: 'Disable' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Disable' }).click();
await expect(page.getByText('Account disabled')).toBeVisible(); await expect(page.getByText('Account disabled')).toBeVisible();
await expect(adminCustomers.status(customer.email, 'DISABLED')).toBeVisible();
const updated = page.getByRole('row').filter({ hasText: email });
await expect(updated.getByText('DISABLED')).toBeVisible();
// A generic credential error would send a real customer round the // A generic credential error would send a real customer round the
// password-reset loop forever. // password-reset loop forever.
await page.goto('/login'); await authModal.gotoLogIn();
await page.getByRole('textbox', { name: 'Email' }).fill(email); await authModal.logIn(customer.email, customer.password);
await page.getByLabel('Password').fill(PASSWORD);
// Scoped to the modal: the storefront rendered behind it has a "Log in"
// button of its own, which is what opened this one.
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
await expect(page.getByText(/disabled/i)).toBeVisible(); await expect(page.getByText(/disabled/i)).toBeVisible();
}); });
test('an existing session stops working immediately', async ({ page, request }) => { test('an existing session stops working immediately', async ({
const email = uniqueEmail(); page,
await register(page, email); request,
customer,
accountModal
}) => {
// Still signed in from registration, in this same browser context. // Still signed in from registration, in this same browser context.
await page.goto('/account'); await accountModal.open();
await expect(page.getByText(email)).toBeVisible(); await expect(accountModal.emailText(customer.email)).toBeVisible();
const customers = await (await request.get('/api/admin/customers')).json(); const customers = await (await request.get('/api/admin/customers')).json();
const id = customers.find((c: { email: string }) => c.email === email).id; const id = customers.find((c: { email: string }) => c.email === customer.email).id;
await request.post(`/api/admin/customers/${id}/disable`); await request.post(`/api/admin/customers/${id}/disable`);
// The cookie is unchanged, so this proves the server rejects it rather // The cookie is unchanged, so this proves the server rejects it rather
@@ -68,51 +43,49 @@ test.describe('Disabling a customer account', () => {
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/);
}); });
test('re-enabling restores sign-in', async ({ page, request }) => { test('re-enabling restores sign-in', async ({
const email = uniqueEmail(); page,
await register(page, email); request,
customer,
admin,
adminCustomers,
authModal,
header
}) => {
const customers = await (await request.get('/api/admin/customers')).json(); const customers = await (await request.get('/api/admin/customers')).json();
const id = customers.find((c: { email: string }) => c.email === email).id; const id = customers.find((c: { email: string }) => c.email === customer.email).id;
await request.post(`/api/admin/customers/${id}/disable`); await request.post(`/api/admin/customers/${id}/disable`);
const row = await customerRow(page, email); await admin.open('Customers');
await row.getByRole('button', { name: 'Re-enable' }).click(); await expect(adminCustomers.row(customer.email)).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Re-enable' }).click(); await adminCustomers.reEnable(customer.email);
await expect(page.getByText('Account re-enabled')).toBeVisible(); await expect(page.getByText('Account re-enabled')).toBeVisible();
await page.goto('/login'); await authModal.gotoLogIn();
await page.getByRole('textbox', { name: 'Email' }).fill(email); await authModal.logIn(customer.email, customer.password);
await page.getByLabel('Password').fill(PASSWORD); await header.waitForSignedIn();
// Scoped to the modal: the storefront rendered behind it has a "Log in"
// button of its own, which is what opened this one.
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
}); });
test('the confirmation warns that held items will be released', async ({ page, request }) => { test('the confirmation warns that held items will be released', async ({
const email = uniqueEmail(); page,
const itemName = `Held ${Date.now().toString(36)}`; request,
customer,
admin,
adminCustomers
}) => {
const item = await createItem(request, { name: `Held ${uniqueSuffix()}`, price: '40' });
expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201);
const created = await request.post('/api/admin/items', { await admin.open('Customers');
multipart: { name: itemName, description: '', price: '40', category_id: '', tags: '[]' } await expect(adminCustomers.row(customer.email)).toBeVisible();
}); await adminCustomers.disableButton(customer.email).click();
const itemId = (await created.json()).id as number;
// New items are pending, and a pending item cannot be added to a cart.
expect((await request.post(`/api/admin/items/${itemId}/mark-available`)).ok()).toBeTruthy();
await register(page, email);
expect((await page.request.post(`/api/cart/items/${itemId}`)).status()).toBe(201);
const row = await customerRow(page, email);
await row.getByRole('button', { name: 'Disable' }).click();
// The consequence has to be visible at the moment of the decision. // The consequence has to be visible at the moment of the decision.
await expect(page.getByText(/1 reserved item/)).toBeVisible(); await expect(page.getByText(/1 reserved item/)).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Disable' }).click(); await adminCustomers.confirmDialog.getByRole('button', { name: 'Disable' }).click();
await expect(page.getByText('Account disabled')).toBeVisible(); await expect(page.getByText('Account disabled')).toBeVisible();
const item = await (await request.get(`/api/items/${itemId}`)).json(); const released = await (await request.get(`/api/items/${item.id}`)).json();
expect(item.status).toBe('available'); expect(released.status).toBe('available');
}); });
}); });
+24 -34
View File
@@ -12,33 +12,27 @@ const DEFAULTS = {
greetingFallback: 'Hi,' greetingFallback: 'Hi,'
}; };
async function openSettings(page: import('@playwright/test').Page) {
await page.goto('/admin');
await page.getByRole('tab', { name: 'Settings' }).click();
await expect(page.getByRole('heading', { name: 'Link lifetimes' })).toBeVisible();
}
test.describe('The email settings', () => { test.describe('The email settings', () => {
test.afterEach(async ({ page }) => { test.afterEach(async ({ page, adminSettings }) => {
await page.request.put('/api/admin/settings', { data: DEFAULTS }); await page.request.put('/api/admin/settings', { data: DEFAULTS });
}); });
test('offers the link lifetimes and the greeting alongside cart expiry', async ({ page }) => { test('offers the link lifetimes and the greeting alongside cart expiry', async ({ page, adminSettings }) => {
await openSettings(page); await adminSettings.open();
// antd renders a stepped InputNumber to the step's precision, so "24.0". // antd renders a stepped InputNumber to the step's precision, so "24.0".
await expect(page.getByLabel('Cart expiry (hours)')).toHaveValue('24.0'); await expect(adminSettings.cartExpiryHours).toHaveValue('24.0');
await expect(page.getByLabel('Email verification link (hours)')).toHaveValue('24.0'); await expect(adminSettings.verifyTokenHours).toHaveValue('24.0');
await expect(page.getByLabel('Password reset link (hours)')).toHaveValue('1.0'); await expect(adminSettings.passwordResetHours).toHaveValue('1.0');
await expect(page.getByLabel('Greeting format')).toHaveValue('Hi {{firstName}},'); await expect(adminSettings.greetingFormat).toHaveValue('Hi {{firstName}},');
await expect(page.getByLabel('Fallback, when there is no first name')).toHaveValue('Hi,'); await expect(adminSettings.greetingFallback).toHaveValue('Hi,');
}); });
test('saves a new lifetime, and the server keeps it', async ({ page }) => { test('saves a new lifetime, and the server keeps it', async ({ page, adminSettings }) => {
await openSettings(page); await adminSettings.open();
await page.getByLabel('Password reset link (hours)').fill('3'); await adminSettings.passwordResetHours.fill('3');
await page.getByRole('button', { name: 'Save' }).click(); await adminSettings.saveButton.click();
await expect(page.getByText('Settings saved')).toBeVisible(); await expect(page.getByText('Settings saved')).toBeVisible();
const stored = await (await page.request.get('/api/admin/settings')).json(); const stored = await (await page.request.get('/api/admin/settings')).json();
@@ -47,33 +41,29 @@ test.describe('The email settings', () => {
// The whole reason the placeholder exists: the sentence in the email is // The whole reason the placeholder exists: the sentence in the email is
// rendered from the setting rather than written out beside it. // rendered from the setting rather than written out beside it.
test('the password reset preview states the configured lifetime', async ({ page }) => { test('the password reset preview states the configured lifetime', async ({ page, admin, adminEmails }) => {
await page.request.put('/api/admin/settings', { data: { passwordResetHours: 2 } }); await page.request.put('/api/admin/settings', { data: { passwordResetHours: 2 } });
await page.goto('/admin'); await admin.open('Emails');
await page.getByRole('tab', { name: 'Emails' }).click(); await adminEmails.openTemplate('Password reset');
await page.getByRole('tab', { name: /Password reset/ }).click();
const preview = page.frameLocator('iframe[title="Password reset preview"]'); await expect(adminEmails.preview('Password reset').getByText('2 hours')).toBeVisible();
await expect(preview.getByText('2 hours')).toBeVisible();
}); });
test('the preview greets through the configured format', async ({ page }) => { test('the preview greets through the configured format', async ({ page, admin, adminEmails }) => {
await page.request.put('/api/admin/settings', { data: { greetingFormat: 'Salutations {{firstName}}!' } }); await page.request.put('/api/admin/settings', { data: { greetingFormat: 'Salutations {{firstName}}!' } });
await page.goto('/admin'); await admin.open('Emails');
await page.getByRole('tab', { name: 'Emails' }).click(); await adminEmails.openTemplate('Email verification');
await page.getByRole('tab', { name: /Email verification/ }).click();
const preview = page.frameLocator('iframe[title="Email verification preview"]'); await expect(adminEmails.preview('Email verification').getByText('Salutations Ada!')).toBeVisible();
await expect(preview.getByText('Salutations Ada!')).toBeVisible();
}); });
test('refuses a lifetime of zero rather than reporting a save', async ({ page }) => { test('refuses a lifetime of zero rather than reporting a save', async ({ page, adminSettings }) => {
await openSettings(page); await adminSettings.open();
await page.getByLabel('Password reset link (hours)').fill('0'); await adminSettings.passwordResetHours.fill('0');
await page.getByRole('button', { name: 'Save' }).click(); await adminSettings.saveButton.click();
await expect(page.getByText('Settings saved')).toHaveCount(0); await expect(page.getByText('Settings saved')).toHaveCount(0);
}); });
@@ -1,48 +1,34 @@
import { test, expect } from './fixtures'; import { test, expect, uniqueSuffix, createCategory } from './fixtures';
const suffix = () => `i${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
// Opening the form kicks off a categories fetch. Interacting with the category
// field before it lands means the tree re-renders under the cursor, so wait for
// the data rather than racing it.
async function openItemForm(page: import('@playwright/test').Page) {
// Opening the form fetches both categories and tags, and each one re-renders
// the modal as it lands. Waiting for only one still leaves the second to
// reflow the popup mid-interaction.
const loaded = Promise.all([
page.waitForResponse((res) => res.url().includes('/api/admin/categories') && res.request().method() === 'GET'),
page.waitForResponse((res) => res.url().includes('/api/admin/tags') && res.request().method() === 'GET')
]);
await page.getByRole('button', { name: 'Add Item' }).click();
await loaded;
}
test.describe('Inline category creation from the item form', () => { test.describe('Inline category creation from the item form', () => {
test('creates a category without leaving the item form and assigns it', async ({ page }) => { test('creates a category without leaving the item form and assigns it', async ({
const RUN = suffix(); page,
admin,
adminInventory
}) => {
const RUN = `i${uniqueSuffix()}`;
const categoryName = `Inline ${RUN}`; const categoryName = `Inline ${RUN}`;
const itemName = `Item ${RUN}`; const itemName = `Item ${RUN}`;
await page.goto('/admin'); await admin.goto();
await openItemForm(page); await adminInventory.openItemFormLoaded();
await page.getByLabel('Name').fill(itemName); await adminInventory.name.fill(itemName);
await page.getByLabel('Price (USD)').fill('99'); await adminInventory.price.fill('99');
await page.getByRole('dialog').getByLabel('Category', { exact: true }).click(); await adminInventory.categoryField.click();
const nameInput = page.getByPlaceholder('New category name'); await expect(adminInventory.newCategoryName).toBeVisible();
await expect(nameInput).toBeVisible(); await expect(adminInventory.createCategoryButton).toBeVisible();
await expect(page.getByRole('button', { name: 'Create category' })).toBeVisible(); await adminInventory.newCategoryName.fill(categoryName);
await nameInput.fill(categoryName);
// Submitted with Enter rather than a click: the popup sits over a // Submitted with Enter rather than a click: the popup sits over a
// virtualized tree that keeps re-measuring, so a click target inside it is // virtualized tree that keeps re-measuring, so a click target inside it is
// never geometrically stable. Enter runs the same handler as the button. // never geometrically stable. Enter runs the same handler as the button.
await nameInput.press('Enter'); await adminInventory.newCategoryName.press('Enter');
// The new category should be selected straight away — having to hunt for it // The new category should be selected straight away — having to hunt for it
// in the tree afterwards defeats the point of creating it inline. // in the tree afterwards defeats the point of creating it inline.
await expect(page.getByRole('dialog').getByText(categoryName)).toBeVisible(); await expect(adminInventory.formDialog.getByText(categoryName)).toBeVisible();
await page.getByRole('button', { name: 'OK' }).click(); await adminInventory.confirmButton.click();
await expect(page.getByText('Item added')).toBeVisible(); await expect(page.getByText('Item added')).toBeVisible();
const items = await (await page.request.get('/api/admin/items')).json(); const items = await (await page.request.get('/api/admin/items')).json();
@@ -51,26 +37,21 @@ test.describe('Inline category creation from the item form', () => {
expect(saved.category_name).toBe(categoryName); expect(saved.category_name).toBe(categoryName);
}); });
test('reports a duplicate category name instead of silently doing nothing', async ({ page }) => { test('reports a duplicate category name instead of silently doing nothing', async ({
const RUN = suffix(); page,
const categoryName = `Dupe ${RUN}`; admin,
adminInventory
}) => {
const categoryName = `Dupe i${uniqueSuffix()}`;
await createCategory(page.request, categoryName);
const created = await page.request.post('/api/admin/categories', { await admin.goto();
data: { name: categoryName, parent_id: null } await adminInventory.openItemFormLoaded();
}); await adminInventory.categoryField.click();
expect(created.status()).toBe(201); await expect(adminInventory.newCategoryName).toBeVisible();
await expect(adminInventory.createCategoryButton).toBeVisible();
await page.goto('/admin'); await adminInventory.newCategoryName.fill(categoryName);
await openItemForm(page); await adminInventory.newCategoryName.press('Enter');
await page.getByRole('dialog').getByLabel('Category', { exact: true }).click();
const nameInput = page.getByPlaceholder('New category name');
await expect(nameInput).toBeVisible();
await expect(page.getByRole('button', { name: 'Create category' })).toBeVisible();
await nameInput.fill(categoryName);
// Submitted with Enter rather than a click: the popup sits over a
// virtualized tree that keeps re-measuring, so a click target inside it is
// never geometrically stable. Enter runs the same handler as the button.
await nameInput.press('Enter');
await expect(page.getByText(/already exists/i)).toBeVisible(); await expect(page.getByText(/already exists/i)).toBeVisible();
}); });
@@ -1,6 +1,6 @@
import { test, expect, Page } from './fixtures'; import { test, expect, createAdminContext, createCategory, createTag, createItem, uniqueSuffix } from './fixtures';
const RUN = `v${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; const RUN = `v${uniqueSuffix()}`;
const NAMES = { const NAMES = {
category: `Filterable ${RUN}`, category: `Filterable ${RUN}`,
@@ -12,166 +12,115 @@ const NAMES = {
}; };
test.beforeAll(async ({ playwright }) => { test.beforeAll(async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' }); const api = await createAdminContext(playwright);
const categoryId = (await (await api.post('/api/admin/categories', { const categoryId = await createCategory(api, NAMES.category);
data: { name: NAMES.category, parent_id: null } await createTag(api, NAMES.tag);
})).json()).id;
await api.post('/api/admin/tags', { data: { name: NAMES.tag } });
// Published after creation: new items are pending, and these fixtures stand const inCategory = { categoryId, tags: [NAMES.tag] };
// in for ordinary stock rather than staged drafts. await createItem(api, { name: NAMES.cheap, price: '50', ...inCategory });
const item = async (name: string, price: string, inCategory: boolean, publish = true) => { await createItem(api, { name: NAMES.mid, price: '150', ...inCategory });
const res = await api.post('/api/admin/items', { await createItem(api, { name: NAMES.dear, price: '900', ...inCategory });
multipart: {
name,
description: '',
price,
category_id: inCategory ? String(categoryId) : '',
tags: JSON.stringify(inCategory ? [NAMES.tag] : [])
}
});
if (publish) {
await api.post(`/api/admin/items/${(await res.json()).id}/mark-available`);
}
};
await item(NAMES.cheap, '50', true);
await item(NAMES.mid, '150', true);
await item(NAMES.dear, '900', true);
// Left pending on purpose: the Unpublished filter needs something to find, // Left pending on purpose: the Unpublished filter needs something to find,
// and every other fixture here is published. // and every other fixture here is published.
await item(NAMES.staged, '400', true, false); await createItem(api, { name: NAMES.staged, price: '400', ...inCategory, publish: false });
await api.dispose(); await api.dispose();
}); });
// Toggles one status in the multi-select. Clicking an option that is already
// selected removes it, which is what the clearing test relies on.
//
// Two antd details decide this locator. It 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 as the option, so an unscoped
// getByTitle becomes ambiguous. Matching the visible option class avoids both.
//
// The dropdown is opened only when it is not already open: antd keeps it open
// after a selection in multiple mode, so clicking the box again would close it.
async function chooseStatus(page: Page, label: string) {
const option = page.locator(`.ant-select-item-option[title="${label}"]`);
if (!(await option.isVisible().catch(() => false))) {
await page.getByRole('combobox', { name: 'Filter by status' }).click();
}
await option.click();
}
const row = (page: Page, name: string) => page.getByRole('row').filter({ hasText: name });
// The Inventory table paginates and other specs create items concurrently, so
// an unfiltered page 1 is not a reliable place to look for a fixture. Every
// assertion below therefore runs against a category filter that narrows the
// table to this spec's own items.
async function filterToOwnCategory(page: Page) {
const category = page.getByRole('combobox', { name: 'Filter by category' });
await category.click();
await category.fill(NAMES.category);
await page.getByTitle(NAMES.category, { exact: true }).click();
await expect(row(page, NAMES.cheap)).toBeVisible();
}
test.describe('Admin inventory filters', () => { test.describe('Admin inventory filters', () => {
test('filters by category', async ({ page }) => { test('filters by category', async ({ admin, adminInventory }) => {
await page.goto('/admin'); await admin.goto();
await filterToOwnCategory(page); await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
// All three fixtures share the category, and nothing else does. // All three fixtures share the category, and nothing else does.
await expect(row(page, NAMES.cheap)).toBeVisible(); await expect(adminInventory.row(NAMES.cheap)).toBeVisible();
await expect(row(page, NAMES.mid)).toBeVisible(); await expect(adminInventory.row(NAMES.mid)).toBeVisible();
await expect(row(page, NAMES.dear)).toBeVisible(); await expect(adminInventory.row(NAMES.dear)).toBeVisible();
}); });
test('filters by price range', async ({ page }) => { test('filters by price range', async ({ admin, adminInventory }) => {
await page.goto('/admin'); await admin.goto();
await filterToOwnCategory(page); await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
await page.getByLabel('Minimum price').fill('100'); await adminInventory.minimumPrice.fill('100');
await page.getByLabel('Maximum price').fill('500'); await adminInventory.maximumPrice.fill('500');
await expect(row(page, NAMES.mid)).toBeVisible(); await expect(adminInventory.row(NAMES.mid)).toBeVisible();
await expect(row(page, NAMES.cheap)).toHaveCount(0); await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0);
await expect(row(page, NAMES.dear)).toHaveCount(0); await expect(adminInventory.row(NAMES.dear)).toHaveCount(0);
}); });
test('filters by a single status', async ({ page }) => { test('filters by a single status', async ({ admin, adminInventory }) => {
await page.goto('/admin'); await admin.goto();
await filterToOwnCategory(page); await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
await chooseStatus(page, 'Sold'); await adminInventory.toggleStatus('Sold');
// Every fixture is published and unsold, so a Sold filter excludes them all. // Every fixture is published and unsold, so a Sold filter excludes them all.
await expect(row(page, NAMES.cheap)).toHaveCount(0); await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0);
await expect(row(page, NAMES.mid)).toHaveCount(0); await expect(adminInventory.row(NAMES.mid)).toHaveCount(0);
await expect(row(page, NAMES.dear)).toHaveCount(0); await expect(adminInventory.row(NAMES.dear)).toHaveCount(0);
}); });
// The question that prompted #132. Every item arrives pending since #90, so // The question that prompted #132. Every item arrives pending since #90, so
// "what is waiting for me to publish" is routine, and the preset this control // "what is waiting for me to publish" is routine, and the preset this control
// replaced could not ask it. // replaced could not ask it.
test('finds unpublished items, and only those', async ({ page }) => { test('finds unpublished items, and only those', async ({ admin, adminInventory }) => {
await page.goto('/admin'); await admin.goto();
await filterToOwnCategory(page); await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
await chooseStatus(page, 'Pending'); await adminInventory.toggleStatus('Pending');
await expect(row(page, NAMES.staged)).toBeVisible(); await expect(adminInventory.row(NAMES.staged)).toBeVisible();
await expect(row(page, NAMES.cheap)).toHaveCount(0); await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0);
await expect(row(page, NAMES.mid)).toHaveCount(0); await expect(adminInventory.row(NAMES.mid)).toHaveCount(0);
await expect(row(page, NAMES.dear)).toHaveCount(0); await expect(adminInventory.row(NAMES.dear)).toHaveCount(0);
}); });
// The complement, and the case a two-way preset could not express either: // The complement, and the case a two-way preset could not express either:
// published means three statuses at once, not one and not "everything else". // published means three statuses at once, not one and not "everything else".
test('finds published items by selecting several statuses at once', async ({ page }) => { test('finds published items by selecting several statuses at once', async ({ admin, adminInventory }) => {
await page.goto('/admin'); await admin.goto();
await filterToOwnCategory(page); await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
await chooseStatus(page, 'Available'); await adminInventory.toggleStatus('Available');
await chooseStatus(page, 'Reserved'); await adminInventory.toggleStatus('Reserved');
await chooseStatus(page, 'Sold'); await adminInventory.toggleStatus('Sold');
await expect(row(page, NAMES.cheap)).toBeVisible(); await expect(adminInventory.row(NAMES.cheap)).toBeVisible();
await expect(row(page, NAMES.mid)).toBeVisible(); await expect(adminInventory.row(NAMES.mid)).toBeVisible();
await expect(row(page, NAMES.dear)).toBeVisible(); await expect(adminInventory.row(NAMES.dear)).toBeVisible();
await expect(row(page, NAMES.staged)).toHaveCount(0); await expect(adminInventory.row(NAMES.staged)).toHaveCount(0);
}); });
// Cleared back to nothing must mean "no filter" rather than "no statuses", // Cleared back to nothing must mean "no filter" rather than "no statuses",
// or emptying the box would empty the table. // or emptying the box would empty the table.
test('clearing the status shows everything again', async ({ page }) => { test('clearing the status shows everything again', async ({ admin, adminInventory }) => {
await page.goto('/admin'); await admin.goto();
await filterToOwnCategory(page); await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
await chooseStatus(page, 'Pending'); await adminInventory.toggleStatus('Pending');
await expect(row(page, NAMES.cheap)).toHaveCount(0); await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0);
await chooseStatus(page, 'Pending'); await adminInventory.toggleStatus('Pending');
await expect(row(page, NAMES.cheap)).toBeVisible(); await expect(adminInventory.row(NAMES.cheap)).toBeVisible();
await expect(row(page, NAMES.staged)).toBeVisible(); await expect(adminInventory.row(NAMES.staged)).toBeVisible();
}); });
test('combines filters, and clearing restores them', async ({ page }) => { test('combines filters, and clearing restores them', async ({ admin, adminInventory }) => {
await page.goto('/admin'); await admin.goto();
await filterToOwnCategory(page); await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
await page.getByLabel('Minimum price').fill('800'); await adminInventory.minimumPrice.fill('800');
await expect(row(page, NAMES.cheap)).toHaveCount(0); await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0);
await expect(row(page, NAMES.dear)).toBeVisible(); await expect(adminInventory.row(NAMES.dear)).toBeVisible();
await page.getByRole('button', { name: 'Clear filters' }).click(); await adminInventory.clearFiltersButton.click();
// Asserting on the controls rather than on the rows: with the filters gone // Asserting on the controls rather than on the rows: with the filters gone
// the table is the whole paginated catalogue again, so a given fixture is // the table is the whole paginated catalogue again, so a given fixture is
// not reliably on the first page. // not reliably on the first page.
await expect(page.getByRole('button', { name: 'Clear filters' })).toHaveCount(0); await expect(adminInventory.clearFiltersButton).toHaveCount(0);
await expect(page.getByLabel('Minimum price')).toHaveValue(''); await expect(adminInventory.minimumPrice).toHaveValue('');
}); });
}); });
+30 -41
View File
@@ -1,29 +1,16 @@
import { test, expect } from './fixtures'; import { test, expect, createItem, uniqueSuffix } from './fixtures';
const suffix = () => `p${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; const DESCRIPTION = 'A preview subject';
async function createItem(page: import('@playwright/test').Page, name: string, price: string) {
const created = await page.request.post('/api/admin/items', {
multipart: { name, description: 'A preview subject', price, category_id: '', tags: '[]' }
});
expect(created.ok()).toBeTruthy();
const id = (await created.json()).id as number;
// New items are pending. Published here so the card renders the same states
// a customer would see; the pending case is covered in the pending spec.
const published = await page.request.post(`/api/admin/items/${id}/mark-available`);
expect(published.ok()).toBeTruthy();
return id;
}
test.describe('Admin item preview', () => { test.describe('Admin item preview', () => {
test('opens the storefront card from the item name', async ({ page }) => { test('opens the storefront card from the item name', async ({ page, admin, adminInventory }) => {
const name = `Preview ${suffix()}`; const name = `Preview p${uniqueSuffix()}`;
await createItem(page, name, '42'); await createItem(page.request, { name, price: '42', description: DESCRIPTION });
await page.goto('/admin'); await admin.goto();
await page.getByRole('button', { name }).click(); await adminInventory.openPreview(name);
const drawer = page.getByRole('dialog', { name: `Preview: ${name}` }); const drawer = adminInventory.previewDrawer(name);
await expect(drawer).toBeVisible(); await expect(drawer).toBeVisible();
// The card itself, not just an empty panel: the storefront renders the // The card itself, not just an empty panel: the storefront renders the
@@ -34,15 +21,18 @@ test.describe('Admin item preview', () => {
// The claim the preview has to make: it looks live, and it is not. The // The claim the preview has to make: it looks live, and it is not. The
// buttons must keep their normal appearance rather than being disabled, // buttons must keep their normal appearance rather than being disabled,
// because showing a customer's view is the entire purpose of the panel. // because showing a customer's view is the entire purpose of the panel.
test('shows the Add to Cart button in its normal enabled state', async ({ page }) => { test('shows the Add to Cart button in its normal enabled state', async ({
const name = `Preview ${suffix()}`; page,
await createItem(page, name, '15'); admin,
adminInventory
}) => {
const name = `Preview p${uniqueSuffix()}`;
await createItem(page.request, { name, price: '15', description: DESCRIPTION });
await page.goto('/admin'); await admin.goto();
await page.getByRole('button', { name }).click(); await adminInventory.openPreview(name);
const drawer = page.getByRole('dialog', { name: `Preview: ${name}` }); const addToCart = adminInventory.previewDrawer(name).getByRole('button', { name: 'Add to Cart' });
const addToCart = drawer.getByRole('button', { name: 'Add to Cart' });
await expect(addToCart).toBeVisible(); await expect(addToCart).toBeVisible();
await expect(addToCart).toBeEnabled(); await expect(addToCart).toBeEnabled();
@@ -52,14 +42,14 @@ test.describe('Admin item preview', () => {
// at the screen. Signed out, a real Add to Cart opens the sign-in prompt // at the screen. Signed out, a real Add to Cart opens the sign-in prompt
// before it can add anything — so if that modal never appears, the handler // before it can add anything — so if that modal never appears, the handler
// short-circuited before reaching any of its real work. // short-circuited before reaching any of its real work.
test('does not act when the preview card is clicked', async ({ page }) => { test('does not act when the preview card is clicked', async ({ page, admin, adminInventory }) => {
const name = `Preview ${suffix()}`; const name = `Preview p${uniqueSuffix()}`;
await createItem(page, name, '99'); await createItem(page.request, { name, price: '99', description: DESCRIPTION });
await page.goto('/admin'); await admin.goto();
await page.getByRole('button', { name }).click(); await adminInventory.openPreview(name);
const drawer = page.getByRole('dialog', { name: `Preview: ${name}` }); const drawer = adminInventory.previewDrawer(name);
await drawer.getByRole('button', { name: 'Add to Cart' }).click(); await drawer.getByRole('button', { name: 'Add to Cart' }).click();
// Nothing succeeded and nothing prompted. // Nothing succeeded and nothing prompted.
@@ -73,15 +63,14 @@ test.describe('Admin item preview', () => {
// The storefront card must stay live where it is actually used, or this // The storefront card must stay live where it is actually used, or this
// change would have quietly broken buying things. // change would have quietly broken buying things.
test('leaves the real storefront card working', async ({ page }) => { test('leaves the real storefront card working', async ({ page, storefront }) => {
const name = `Live ${suffix()}`; const name = `Live p${uniqueSuffix()}`;
await createItem(page, name, '20'); await createItem(page.request, { name, price: '20', description: DESCRIPTION });
await page.goto('/'); await storefront.goto();
const card = page.locator('.ant-card').filter({ hasText: name }); await expect(storefront.card(name)).toBeVisible();
await expect(card).toBeVisible();
await card.getByRole('button', { name: 'Add to Cart' }).click(); await storefront.addToCartButton(name).click();
// Signed out, the real card prompts for sign-in — proof the handler ran. // Signed out, the real card prompts for sign-in — proof the handler ran.
await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByRole('dialog')).toBeVisible();
+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)}`; /**
* Puts an item in the signed-in customer's cart, which is the only way an item
// Reserves an item by registering a customer and adding it to their cart, which * legitimately reaches 'reserved'.
// is the only way an item legitimately reaches 'reserved'. *
async function reserveItem(page: import('@playwright/test').Page, itemName: string) { * The `customer` fixture supplies the session, so this only has to seed the
const email = `reserve-${suffix()}@example.com`; * item and add it — a pending item cannot be reserved, hence publishing first,
* which `createItem` does by default.
const created = await page.request.post('/api/admin/items', { */
multipart: { name: itemName, description: '', price: '75', category_id: '', tags: '[]' } async function reserve(
}); request: import('@playwright/test').APIRequestContext,
expect(created.ok()).toBeTruthy(); itemName: string
const itemId = (await created.json()).id as number; ): Promise<number> {
// New items are pending, and a pending item cannot be reserved. const item = await createItem(request, { name: itemName, price: '75' });
expect((await page.request.post(`/api/admin/items/${itemId}/mark-available`)).ok()).toBeTruthy(); expect((await request.post(`/api/cart/items/${item.id}`)).status()).toBe(201);
return item.id;
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 };
} }
test.describe('Admin reserved items', () => { test.describe('Admin reserved items', () => {
test('shows a reserved count that opens the held items', async ({ page }) => { test('shows a reserved count that opens the held items', async ({
const itemName = `Held ${suffix()}`; page,
const { email } = await reserveItem(page, itemName); customer,
admin,
adminCustomers
}) => {
const itemName = `Held r${uniqueSuffix()}`;
await reserve(page.request, itemName);
await page.goto('/admin'); await admin.open('Customers');
await page.getByRole('tab', { name: 'Customers' }).click(); await expect(adminCustomers.row(customer.email)).toBeVisible();
await adminCustomers.reservedCountButton(customer.email).click();
const row = page.getByRole('row').filter({ hasText: email }); await expect(adminCustomers.reservedItemsDialog.getByText(itemName)).toBeVisible();
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();
}); });
test('releasing an item returns it to the storefront as available', async ({ page }) => { test('releasing an item returns it to the storefront as available', async ({
const itemName = `Freed ${suffix()}`; page,
const { email, itemId } = await reserveItem(page, itemName); customer,
admin,
adminCustomers
}) => {
const itemName = `Freed r${uniqueSuffix()}`;
const itemId = await reserve(page.request, itemName);
await page.goto('/admin'); await admin.open('Customers');
await page.getByRole('tab', { name: 'Customers' }).click(); await adminCustomers.reservedCountButton(customer.email).click();
const row = page.getByRole('row').filter({ hasText: email });
await row.getByRole('button', { name: /item/ }).click();
const dialog = page.getByRole('dialog', { name: /Items reserved by/ }); await adminCustomers.reservedItemsDialog.getByRole('button', { name: 'Release' }).click();
await dialog.getByRole('button', { name: 'Release' }).click();
await expect(page.getByText(`Released "${itemName}"`)).toBeVisible(); await expect(page.getByText(`Released "${itemName}"`)).toBeVisible();
// The point of releasing is that the item becomes purchasable again. // 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'); expect(item.status).toBe('available');
}); });
test('the count drops once the item is released', async ({ page }) => { test('the count drops once the item is released', async ({
const itemName = `Recount ${suffix()}`; page,
const { email } = await reserveItem(page, itemName); customer,
admin,
adminCustomers
}) => {
await reserve(page.request, `Recount r${uniqueSuffix()}`);
await page.goto('/admin'); await admin.open('Customers');
await page.getByRole('tab', { name: 'Customers' }).click(); await adminCustomers.reservedCountButton(customer.email).click();
const row = page.getByRole('row').filter({ hasText: email });
await row.getByRole('button', { name: /item/ }).click();
const dialog = page.getByRole('dialog', { name: /Items reserved by/ }); const dialog = adminCustomers.reservedItemsDialog;
await dialog.getByRole('button', { name: 'Release' }).click(); await dialog.getByRole('button', { name: 'Release' }).click();
await expect(dialog.getByText("This customer isn't holding any items")).toBeVisible(); await expect(dialog.getByText("This customer isn't holding any items")).toBeVisible();
// The row behind the dialog must agree with the dialog it opened. // The row behind the dialog must agree with the dialog it opened.
await dialog.getByRole('button', { name: 'Close' }).click(); 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 }) => { test('a customer holding nothing shows no link to click', async ({
const email = `idle-${suffix()}@example.com`; customer,
await page.goto('/register'); admin,
await page.getByRole('textbox', { name: 'Email' }).fill(email); adminCustomers
await page.getByRole('textbox', { name: 'First name' }).fill('Test'); }) => {
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer'); await admin.open('Customers');
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 });
await page.goto('/admin'); await expect(adminCustomers.row(customer.email)).toBeVisible();
await page.getByRole('tab', { name: 'Customers' }).click(); await expect(adminCustomers.reservedCountButton(customer.email)).toHaveCount(0);
const row = page.getByRole('row').filter({ hasText: email });
await expect(row).toBeVisible();
await expect(row.getByRole('button', { name: /item/ })).toHaveCount(0);
}); });
}); });
+27 -24
View File
@@ -1,9 +1,11 @@
import { test, expect } from './fixtures'; import { test, expect, uniqueSuffix } from './fixtures';
const suffix = () => `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
test.describe('Admin save failures', () => { test.describe('Admin save failures', () => {
test('does not claim an item was saved when the request failed', async ({ page }) => { test('does not claim an item was saved when the request failed', async ({
page,
admin,
adminInventory
}) => {
await page.route('**/api/admin/items', (route) => { await page.route('**/api/admin/items', (route) => {
if (route.request().method() === 'POST') { if (route.request().method() === 'POST') {
return route.fulfill({ return route.fulfill({
@@ -15,24 +17,25 @@ test.describe('Admin save failures', () => {
return route.continue(); return route.continue();
}); });
await page.goto('/admin'); await admin.goto();
await page.getByRole('button', { name: 'Add Item' }).click(); await adminInventory.addItem(`Broken ${uniqueSuffix()}`, '12');
await page.getByLabel('Name').fill(`Broken ${suffix()}`);
await page.getByLabel('Price (USD)').fill('12');
await page.getByRole('button', { name: 'OK' }).click();
// Reporting success for a failed save is worse than failing loudly: the // Reporting success for a failed save is worse than failing loudly: the
// item is silently absent and the user has no reason to look for it. // item is silently absent and the user has no reason to look for it.
await expect(page.getByText('Item added')).toBeHidden(); await expect(page.getByText('Item added')).toBeHidden();
await expect(page.getByText("Couldn't save item")).toBeVisible(); await expect(page.getByText("Couldn't save item")).toBeVisible();
// The form must stay open so the entered values aren't lost. // The form must stay open so the entered values aren't lost.
await expect(page.getByRole('dialog')).toBeVisible(); await expect(adminInventory.formDialog).toBeVisible();
}); });
test('reports a failed delete rather than claiming success', async ({ page }) => { test('reports a failed delete rather than claiming success', async ({
page,
admin,
adminInventory
}) => {
// Seeded through the API so the test owns a known row rather than clicking // Seeded through the API so the test owns a known row rather than clicking
// whichever Delete button happens to be first in a paginated table. // whichever Delete button happens to be first in a paginated table.
const name = `Doomed ${suffix()}`; const name = `Doomed ${uniqueSuffix()}`;
const created = await page.request.post('/api/admin/items', { const created = await page.request.post('/api/admin/items', {
multipart: { name, description: '', price: '10', category_id: '', tags: '[]' } multipart: { name, description: '', price: '10', category_id: '', tags: '[]' }
}); });
@@ -45,26 +48,26 @@ test.describe('Admin save failures', () => {
return route.continue(); return route.continue();
}); });
await page.goto('/admin'); await admin.goto();
// Items list newest-first, so the seeded row is on the first page. // Items list newest-first, so the seeded row is on the first page.
const row = page.getByRole('row').filter({ hasText: name }); await expect(adminInventory.row(name)).toBeVisible();
await expect(row).toBeVisible(); await adminInventory.deleteButton(name).click();
await row.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Item deleted')).toBeHidden(); await expect(page.getByText('Item deleted')).toBeHidden();
await expect(page.getByText("Couldn't delete item")).toBeVisible(); await expect(page.getByText("Couldn't delete item")).toBeVisible();
// The row must survive a failed delete. // The row must survive a failed delete.
await expect(row).toBeVisible(); await expect(adminInventory.row(name)).toBeVisible();
}); });
test('saves an item successfully when the server accepts it', async ({ page }) => { test('saves an item successfully when the server accepts it', async ({
const name = `Good ${suffix()}`; page,
admin,
adminInventory
}) => {
const name = `Good ${uniqueSuffix()}`;
await page.goto('/admin'); await admin.goto();
await page.getByRole('button', { name: 'Add Item' }).click(); await adminInventory.addItem(name, '34');
await page.getByLabel('Name').fill(name);
await page.getByLabel('Price (USD)').fill('34');
await page.getByRole('button', { name: 'OK' }).click();
await expect(page.getByText('Item added')).toBeVisible(); await expect(page.getByText('Item added')).toBeVisible();
+24 -30
View File
@@ -1,29 +1,23 @@
import { test, expect } from './fixtures'; import { test, expect, uniqueSuffix } from './fixtures';
// The e2e database is shared and never reset, so every fixture name carries a // The e2e database is shared and never reset, so every fixture name carries a
// unique suffix and assertions are scoped to the nodes this run created. The // unique suffix and assertions are scoped to the nodes this run created. The
// suffix is generated per test rather than per module: a worker can run this // suffix is generated per test rather than per module: a worker can run this
// file more than once, and a module-level constant would collide with itself. // file more than once, and a module-level constant would collide with itself.
const suffix = () => `a${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
test.describe('Admin taxonomy', () => { test.describe('Admin taxonomy', () => {
test('creates a category and a nested child that stays visible', async ({ page }) => { test('creates a category and a nested child that stays visible', async ({
const RUN = suffix(); page,
await page.goto('/admin'); admin,
await page.getByRole('tab', { name: 'Categories' }).click(); adminTaxonomy
}) => {
const RUN = `a${uniqueSuffix()}`;
await admin.open('Categories');
await page.getByRole('button', { name: 'Add Category' }).click(); await adminTaxonomy.addCategory(`Furniture ${RUN}`);
await page.getByLabel('Name').fill(`Furniture ${RUN}`);
await page.getByRole('button', { name: 'OK' }).click();
await expect(page.getByText(`Furniture ${RUN}`)).toBeVisible(); await expect(page.getByText(`Furniture ${RUN}`)).toBeVisible();
await page await adminTaxonomy.addChildCategory(`Furniture ${RUN}`, `Tables ${RUN}`);
.getByRole('treeitem')
.filter({ hasText: `Furniture ${RUN}` })
.getByRole('button', { name: 'Add child' })
.click();
await page.getByLabel('Name').fill(`Tables ${RUN}`);
await page.getByRole('button', { name: 'OK' }).click();
// The tree is mounted before this branch exists, so the child is only // The tree is mounted before this branch exists, so the child is only
// visible if expansion follows newly created nodes rather than the state // visible if expansion follows newly created nodes rather than the state
@@ -31,17 +25,18 @@ test.describe('Admin taxonomy', () => {
await expect(page.getByText(`Tables ${RUN}`)).toBeVisible(); await expect(page.getByText(`Tables ${RUN}`)).toBeVisible();
}); });
test('creates a tag with an automatically assigned colour', async ({ page }) => { test('creates a tag with an automatically assigned colour', async ({
const RUN = suffix(); page,
await page.goto('/admin'); admin,
await page.getByRole('tab', { name: 'Tags' }).click(); adminTaxonomy
}) => {
const RUN = `a${uniqueSuffix()}`;
await admin.open('Tags');
await page.getByRole('button', { name: 'Add Tag' }).click(); await adminTaxonomy.addTag(`vintage-${RUN}`);
await page.getByLabel('Name').fill(`vintage-${RUN}`);
await page.getByRole('button', { name: 'OK' }).click();
// Clicking OK only dispatches the request; wait for the confirmation so the // Clicking OK only dispatches the request; wait for the confirmation so the
// lookup below can't race the create. // lookup below can't race the create.
await expect(page.getByText('Tag added')).toBeVisible(); await expect(adminTaxonomy.tagAddedNotice).toBeVisible();
// The table paginates and the shared database holds many tags, so the new // The table paginates and the shared database holds many tags, so the new
// row is confirmed through the API rather than hunted for across pages. // row is confirmed through the API rather than hunted for across pages.
@@ -51,12 +46,11 @@ test.describe('Admin taxonomy', () => {
expect(created.color).toBeTruthy(); expect(created.color).toBeTruthy();
}); });
test('offers category and tag fields on the item form', async ({ page }) => { test('offers category and tag fields on the item form', async ({ admin, adminInventory }) => {
await page.goto('/admin'); await admin.goto();
await page.getByRole('button', { name: 'Add Item' }).click(); await adminInventory.openItemForm();
const modal = page.getByRole('dialog'); await expect(adminInventory.formDialog.getByText('Category', { exact: true })).toBeVisible();
await expect(modal.getByText('Category', { exact: true })).toBeVisible(); await expect(adminInventory.formDialog.getByText('Pick existing tags')).toBeVisible();
await expect(modal.getByText('Pick existing tags')).toBeVisible();
}); });
}); });
+34 -51
View File
@@ -1,4 +1,4 @@
import { test, expect, Page } from './fixtures'; import { test, expect, AdminPage, createCategory, createTag, uniqueSuffix } from './fixtures';
// Relative luminance per WCAG, used to tell "light" from "dark" without // Relative luminance per WCAG, used to tell "light" from "dark" without
// asserting exact hex values, which would break on any palette tweak. // asserting exact hex values, which would break on any palette tweak.
@@ -11,82 +11,65 @@ function luminance(rgb: string): number {
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
} }
const suffix = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
// The Categories and Tags tabs render an empty state when there is nothing to // The Categories and Tags tabs render an empty state when there is nothing to
// show, and the shared dev database gets truncated by the backend integration // show, and the shared dev database gets truncated by the backend integration
// suite. Seed what each test needs rather than depending on what happens to be // suite (#116). Seed what each test needs rather than depending on what happens
// there. // to be there.
async function seed(page: Page) { async function seed(request: import('@playwright/test').APIRequestContext) {
const run = suffix(); const run = uniqueSuffix();
await page.request.post('/api/admin/categories', { data: { name: `Theme ${run}`, parent_id: null } }); await createCategory(request, `Theme ${run}`);
await page.request.post('/api/admin/tags', { data: { name: `theme-${run}` } }); await createTag(request, `theme-${run}`);
}
async function switchToDark(page: Page) {
await page.goto('/admin');
const toggle = page.getByRole('switch');
if ((await toggle.getAttribute('aria-checked')) !== 'true') {
await toggle.click();
}
await expect(page.locator('body')).toHaveAttribute('data-theme', 'dark');
} }
test.describe('Admin dark mode', () => { test.describe('Admin dark mode', () => {
test('the active tab label is readable against the dark background', async ({ page }) => { test('the active tab label is readable against the dark background', async ({ admin }) => {
await switchToDark(page); await admin.switchToDark();
const label = page.locator('.ant-tabs-tab-active .ant-tabs-tab-btn').first();
// The bug: colorPrimary stayed #1a1a1a in dark mode, so the active tab was // The bug: colorPrimary stayed #1a1a1a in dark mode, so the active tab was
// near-black text on a near-black background. // near-black text on a near-black background.
await expect await expect
.poll(async () => luminance(await label.evaluate((el) => getComputedStyle(el).color))) .poll(async () => luminance(await AdminPage.colorOf(admin.activeTabLabel)))
.toBeGreaterThan(0.5); .toBeGreaterThan(0.5);
}); });
test('the Categories tab follows the dark theme', async ({ page }) => { test('the Categories tab follows the dark theme', async ({ page, admin }) => {
await seed(page); await seed(page.request);
await switchToDark(page); await admin.switchToDark();
await page.getByRole('tab', { name: 'Categories' }).click(); await admin.openTab('Categories');
const tree = page.locator('.ant-tabs-tabpane-active .ant-tree').first(); await expect(admin.activeTree).toBeVisible();
await expect(tree).toBeVisible();
const bg = await tree.evaluate((el) => getComputedStyle(el).backgroundColor);
// The bug: deep imports from antd/lib loaded a second copy of antd that // The bug: deep imports from antd/lib loaded a second copy of antd that
// never saw ConfigProvider, so this rendered pure white in dark mode. // never saw ConfigProvider, so this rendered pure white in dark mode.
expect(luminance(bg)).toBeLessThan(0.5); expect(luminance(await AdminPage.backgroundOf(admin.activeTree))).toBeLessThan(0.5);
}); });
test('the Tags tab follows the dark theme', async ({ page }) => { test('the Tags tab follows the dark theme', async ({ page, admin }) => {
await seed(page); await seed(page.request);
await switchToDark(page); await admin.switchToDark();
await page.getByRole('tab', { name: 'Tags' }).click(); await admin.openTab('Tags');
const table = page.locator('.ant-tabs-tabpane-active .ant-table').first(); await expect(admin.activeTable).toBeVisible();
await expect(table).toBeVisible(); expect(luminance(await AdminPage.backgroundOf(admin.activeTable))).toBeLessThan(0.5);
const bg = await table.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(luminance(bg)).toBeLessThan(0.5);
}); });
test('the item form category selector follows the dark theme', async ({ page }) => { test('the item form category selector follows the dark theme', async ({
await switchToDark(page); admin,
await page.getByRole('button', { name: 'Add Item' }).click(); adminInventory
await page.getByRole('dialog').getByLabel('Category', { exact: true }).click(); }) => {
await admin.switchToDark();
await adminInventory.openItemForm();
await adminInventory.categoryField.click();
const popup = page.locator('.ant-select-dropdown').first(); await expect(admin.selectDropdown).toBeVisible();
await expect(popup).toBeVisible(); expect(luminance(await AdminPage.backgroundOf(admin.selectDropdown))).toBeLessThan(0.5);
const bg = await popup.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(luminance(bg)).toBeLessThan(0.5);
}); });
}); });
test.describe('Admin copy', () => { test.describe('Admin copy', () => {
test('uses American English spelling for color', async ({ page }) => { test('uses American English spelling for color', async ({ page, admin }) => {
await seed(page); await seed(page.request);
await page.goto('/admin'); await admin.open('Tags');
await page.getByRole('tab', { name: 'Tags' }).click();
await expect(page.getByRole('columnheader', { name: 'Color' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Color' })).toBeVisible();
await expect(page.getByText('Colour')).toHaveCount(0); await expect(page.getByText('Colour')).toHaveCount(0);
+47 -68
View File
@@ -1,44 +1,23 @@
import { test, expect } from './fixtures'; import { test, expect, uniqueSuffix } from './fixtures';
const suffix = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
// Each test leaves the templates as it found them, because they are stored in // Each test leaves the templates as it found them, because they are stored in
// admin_settings and would otherwise change the copy a later test reads. // admin_settings and would otherwise change the copy a later test reads.
async function restore(page: import('@playwright/test').Page, key: string) { async function restore(request: import('@playwright/test').APIRequestContext, key: string) {
await page.request.delete(`/api/admin/email-templates/${key}`); await request.delete(`/api/admin/email-templates/${key}`);
} }
async function openEmails(page: import('@playwright/test').Page) {
await page.goto('/admin');
await page.getByRole('tab', { name: 'Emails' }).click();
// The rail's first entry, rather than a heading — the tab label is the
// heading now, so there is no second one inside the page to wait on.
await expect(page.getByRole('tab', { name: /Email verification/ })).toBeVisible();
}
// Opens one template's tab. Only the active tab's editor is mounted, which is
// what makes the labels below unambiguous — the previous stacked layout had
// every editor on screen at once and a locator for "Save" matched all six.
async function openTemplate(page: import('@playwright/test').Page, label: string) {
await page.getByRole('tab', { name: new RegExp(label) }).click();
await expect(page.getByLabel(`${label} subject`)).toBeVisible();
}
const previewFrame = (page: import('@playwright/test').Page, label: string) =>
page.frameLocator(`iframe[title="${label} preview"]`);
// Serial: these edit one shared stored template, and the suite runs fully // Serial: these edit one shared stored template, and the suite runs fully
// parallel by default — so run concurrently they would race, one asserting a // parallel by default — so run concurrently they would race, one asserting a
// template is unset while another has just saved it. // template is unset while another has just saved it.
test.describe.configure({ mode: 'serial' }); test.describe.configure({ mode: 'serial' });
test.describe('Editing the customer emails', () => { test.describe('Editing the customer emails', () => {
test.afterEach(async ({ page }) => { test.afterEach(async ({ page, admin, adminEmails }) => {
await restore(page, 'passwordReset'); await restore(page.request, 'passwordReset');
}); });
test('offers every template as a tab, marked default until it is edited', async ({ page }) => { test('offers every template as a tab, marked default until it is edited', async ({ page, admin, adminEmails }) => {
await openEmails(page); await admin.open('Emails');
for (const label of [ for (const label of [
'Email verification', 'Email verification',
@@ -48,25 +27,25 @@ test.describe('Editing the customer emails', () => {
'Cart reminder', 'Cart reminder',
'Email address changed' 'Email address changed'
]) { ]) {
await expect(page.getByRole('tab', { name: new RegExp(label) })).toBeVisible(); await expect(adminEmails.railTab(new RegExp(label))).toBeVisible();
} }
// Only a customised template is marked, so which ones have been changed is // Only a customised template is marked, so which ones have been changed is
// visible without opening each one. An untouched template carries nothing. // visible without opening each one. An untouched template carries nothing.
await expect(page.getByRole('tab', { name: /Password reset/ })).toBeVisible(); await expect(adminEmails.railTab(/Password reset/)).toBeVisible();
await expect(page.getByRole('tab', { name: /Password reset.*Customised/ })).toHaveCount(0); await expect(adminEmails.customisedTab('Password reset')).toHaveCount(0);
}); });
test('saves a replacement subject and body', async ({ page }) => { test('saves a replacement subject and body', async ({ page, admin, adminEmails }) => {
const subject = `Reset ${suffix()}`; const subject = `Reset ${uniqueSuffix()}`;
await openEmails(page); await admin.open('Emails');
await openTemplate(page, 'Password reset'); await adminEmails.openTemplate('Password reset');
await page.getByLabel('Password reset subject').fill(subject); await adminEmails.subject('Password reset').fill(subject);
await page await page
.getByLabel('Password reset body') .getByLabel('Password reset body')
.fill('Fresh wording. [Choose a new password]({{resetUrl}}).'); .fill('Fresh wording. [Choose a new password]({{resetUrl}}).');
await page.getByRole('button', { name: 'Save', exact: true }).click(); await adminEmails.saveButton.click();
await expect(page.getByText('Password reset saved')).toBeVisible(); await expect(page.getByText('Password reset saved')).toBeVisible();
@@ -79,12 +58,12 @@ test.describe('Editing the customer emails', () => {
// The assertion that matters. A body without its link still sends and still // The assertion that matters. A body without its link still sends and still
// looks fine in the log, so the save has to be refused rather than warned // looks fine in the log, so the save has to be refused rather than warned
// about — and the admin has to be told which placeholder is missing. // about — and the admin has to be told which placeholder is missing.
test('refuses a body that drops the required placeholder, and says which', async ({ page }) => { test('refuses a body that drops the required placeholder, and says which', async ({ page, admin, adminEmails }) => {
await openEmails(page); await admin.open('Emails');
await openTemplate(page, 'Password reset'); await adminEmails.openTemplate('Password reset');
await page.getByLabel('Password reset body').fill('Just click the thing in your email.'); await adminEmails.body('Password reset').fill('Just click the thing in your email.');
await page.getByRole('button', { name: 'Save', exact: true }).click(); await adminEmails.saveButton.click();
await expect(page.getByText('the body must keep {{resetUrl}}')).toBeVisible(); await expect(page.getByText('the body must keep {{resetUrl}}')).toBeVisible();
@@ -94,17 +73,17 @@ test.describe('Editing the customer emails', () => {
expect(reset.body).toBeNull(); expect(reset.body).toBeNull();
}); });
test('restores the built-in copy', async ({ page }) => { test('restores the built-in copy', async ({ page, admin, adminEmails }) => {
await page.request.put('/api/admin/email-templates/passwordReset', { await page.request.put('/api/admin/email-templates/passwordReset', {
data: { subject: 'Temporary', body: 'Temporary [link]({{resetUrl}}).' } data: { subject: 'Temporary', body: 'Temporary [link]({{resetUrl}}).' }
}); });
await openEmails(page); await admin.open('Emails');
// Marked in the rail before it is opened, which is the whole point of the // Marked in the rail before it is opened, which is the whole point of the
// dot — the stored template above was never touched through the UI. // dot — the stored template above was never touched through the UI.
await expect(page.getByRole('tab', { name: /Password reset.*Customised/ })).toBeVisible(); await expect(adminEmails.customisedTab('Password reset')).toBeVisible();
await openTemplate(page, 'Password reset'); await adminEmails.openTemplate('Password reset');
await page.getByRole('button', { name: 'Restore default' }).click(); await adminEmails.restoreDefaultButton.click();
await expect(page.getByText('Password reset restored to the default')).toBeVisible(); await expect(page.getByText('Password reset restored to the default')).toBeVisible();
@@ -116,14 +95,14 @@ test.describe('Editing the customer emails', () => {
}); });
test.describe('Previewing the customer emails', () => { test.describe('Previewing the customer emails', () => {
test.afterEach(async ({ page }) => { test.afterEach(async ({ page, admin, adminEmails }) => {
await restore(page, 'passwordReset'); await restore(page.request, 'passwordReset');
}); });
test('shows the draft being edited, not the stored copy', async ({ page }) => { test('shows the draft being edited, not the stored copy', async ({ page, admin, adminEmails }) => {
const wording = `Wording ${suffix()}`; const wording = `Wording ${uniqueSuffix()}`;
await openEmails(page); await admin.open('Emails');
await openTemplate(page, 'Password reset'); await adminEmails.openTemplate('Password reset');
await page await page
.getByLabel('Password reset body') .getByLabel('Password reset body')
@@ -131,17 +110,17 @@ test.describe('Previewing the customer emails', () => {
// Nothing has been saved. The preview still reflects it, which is the whole // Nothing has been saved. The preview still reflects it, which is the whole
// point: an admin sees the effect before committing to it. // point: an admin sees the effect before committing to it.
await expect(previewFrame(page, 'Password reset').getByText(wording)).toBeVisible(); await expect(adminEmails.preview('Password reset').getByText(wording)).toBeVisible();
const stored = await (await page.request.get('/api/admin/email-templates')).json(); const stored = await (await page.request.get('/api/admin/email-templates')).json();
expect(stored.find((t: { key: string }) => t.key === 'passwordReset').body).toBeNull(); expect(stored.find((t: { key: string }) => t.key === 'passwordReset').body).toBeNull();
}); });
test('substitutes sample values rather than showing raw placeholders', async ({ page }) => { test('substitutes sample values rather than showing raw placeholders', async ({ page, admin, adminEmails }) => {
await openEmails(page); await admin.open('Emails');
await openTemplate(page, 'Password reset'); await adminEmails.openTemplate('Password reset');
const frame = previewFrame(page, 'Password reset'); const frame = adminEmails.preview('Password reset');
await expect(frame.getByRole('link')).toHaveAttribute('href', /reset-password\?token=/); await expect(frame.getByRole('link')).toHaveAttribute('href', /reset-password\?token=/);
await expect(frame.locator('body')).not.toContainText('{{resetUrl}}'); await expect(frame.locator('body')).not.toContainText('{{resetUrl}}');
}); });
@@ -149,26 +128,26 @@ test.describe('Previewing the customer emails', () => {
// The control that stops an admin putting script into a customer's inbox is // The control that stops an admin putting script into a customer's inbox is
// markdown-it's html: false on the server. The preview has to show the same // markdown-it's html: false on the server. The preview has to show the same
// thing the mailer emits, or it would be reassuring about the wrong output. // thing the mailer emits, or it would be reassuring about the wrong output.
test('escapes raw HTML exactly as the mailer does', async ({ page }) => { test('escapes raw HTML exactly as the mailer does', async ({ page, admin, adminEmails }) => {
await openEmails(page); await admin.open('Emails');
await openTemplate(page, 'Password reset'); await adminEmails.openTemplate('Password reset');
await page await page
.getByLabel('Password reset body') .getByLabel('Password reset body')
.fill('<script>alert(1)</script> [link]({{resetUrl}})'); .fill('<script>alert(1)</script> [link]({{resetUrl}})');
await expect(previewFrame(page, 'Password reset').getByText('<script>alert(1)</script>')).toBeVisible(); await expect(adminEmails.preview('Password reset').getByText('<script>alert(1)</script>')).toBeVisible();
}); });
// Appended by the server and not editable, so it has to appear in the preview // Appended by the server and not editable, so it has to appear in the preview
// of the two templates it belongs to and nowhere else. // of the two templates it belongs to and nowhere else.
test('includes the consent footer on a favorite template, and not on others', async ({ page }) => { test('includes the consent footer on a favorite template, and not on others', async ({ page, admin, adminEmails }) => {
await openEmails(page); await admin.open('Emails');
await openTemplate(page, 'Favorited item sold'); await adminEmails.openTemplate('Favorited item sold');
await expect(previewFrame(page, 'Favorited item sold').getByText(/account page/)).toBeVisible(); await expect(adminEmails.preview('Favorited item sold').getByText(/account page/)).toBeVisible();
await openTemplate(page, 'Password reset'); await adminEmails.openTemplate('Password reset');
await expect(previewFrame(page, 'Password reset').locator('body')).not.toContainText('account page'); await expect(adminEmails.preview('Password reset').locator('body')).not.toContainText('account page');
}); });
}); });
+24
View File
@@ -12,6 +12,10 @@ import { FilterDrawer } from './pages/FilterDrawer';
import { FavoritePrompt } from './pages/FavoritePrompt'; import { FavoritePrompt } from './pages/FavoritePrompt';
import { OrdersPage } from './pages/OrdersPage'; import { OrdersPage } from './pages/OrdersPage';
import { AdminInventory } from './pages/AdminInventory'; import { AdminInventory } from './pages/AdminInventory';
import { AdminTaxonomy } from './pages/AdminTaxonomy';
import { AdminCustomers } from './pages/AdminCustomers';
import { AdminEmails } from './pages/AdminEmails';
import { AdminSettings } from './pages/AdminSettings';
import { uniqueEmail } from './support/api'; import { uniqueEmail } from './support/api';
// Re-exported so specs can import everything from here — expect, Page, // Re-exported so specs can import everything from here — expect, Page,
@@ -29,6 +33,10 @@ export { FilterDrawer } from './pages/FilterDrawer';
export { FavoritePrompt } from './pages/FavoritePrompt'; export { FavoritePrompt } from './pages/FavoritePrompt';
export { OrdersPage } from './pages/OrdersPage'; export { OrdersPage } from './pages/OrdersPage';
export { AdminInventory } from './pages/AdminInventory'; export { AdminInventory } from './pages/AdminInventory';
export { AdminTaxonomy } from './pages/AdminTaxonomy';
export { AdminCustomers } from './pages/AdminCustomers';
export { AdminEmails } from './pages/AdminEmails';
export { AdminSettings } from './pages/AdminSettings';
const NYC_OUTPUT = path.resolve(__dirname, '..', '..', '.nyc_output'); const NYC_OUTPUT = path.resolve(__dirname, '..', '..', '.nyc_output');
const collectingCoverage = process.env.COVERAGE === 'true'; const collectingCoverage = process.env.COVERAGE === 'true';
@@ -58,6 +66,10 @@ interface Pages {
favoritePrompt: FavoritePrompt; favoritePrompt: FavoritePrompt;
orders: OrdersPage; orders: OrdersPage;
adminInventory: AdminInventory; adminInventory: AdminInventory;
adminTaxonomy: AdminTaxonomy;
adminCustomers: AdminCustomers;
adminEmails: AdminEmails;
adminSettings: AdminSettings;
} }
interface Data { interface Data {
@@ -123,6 +135,18 @@ export const test = base.extend<Pages & Data & { collectCoverage: void }>({
adminInventory: async ({ page }, use) => { adminInventory: async ({ page }, use) => {
await use(new AdminInventory(page)); await use(new AdminInventory(page));
}, },
adminTaxonomy: async ({ page }, use) => {
await use(new AdminTaxonomy(page));
},
adminCustomers: async ({ page }, use) => {
await use(new AdminCustomers(page));
},
adminEmails: async ({ page }, use) => {
await use(new AdminEmails(page));
},
adminSettings: async ({ page }, use) => {
await use(new AdminSettings(page));
},
adminApi: async ({ playwright, baseURL }, use) => { adminApi: async ({ playwright, baseURL }, use) => {
const context = await playwright.request.newContext({ baseURL }); const context = await playwright.request.newContext({ baseURL });
@@ -0,0 +1,53 @@
import { Locator, Page } from '@playwright/test';
/**
* The Customers tab.
*
* Rows are found by email rather than position: the table is shared with every
* other run's accounts and is paginated, so an index means a different customer
* depending on what else exists.
*/
export class AdminCustomers {
readonly confirmDialog: Locator;
constructor(private readonly page: Page) {
this.confirmDialog = page.getByRole('dialog');
}
row(email: string): Locator {
return this.page.getByRole('row').filter({ hasText: email });
}
/** The status chip in a customer's row — ACTIVE or DISABLED. */
status(email: string, status: string): Locator {
return this.row(email).getByText(status);
}
disableButton(email: string): Locator {
return this.row(email).getByRole('button', { name: 'Disable' });
}
/** The count of items this customer is holding, which opens the list. */
reservedCountButton(email: string): Locator {
return this.row(email).getByRole('button', { name: /item/ });
}
get reservedItemsDialog(): Locator {
return this.page.getByRole('dialog', { name: /Items reserved by/ });
}
reEnableButton(email: string): Locator {
return this.row(email).getByRole('button', { name: 'Re-enable' });
}
/** Disabling asks for confirmation, so the button appears twice. */
async disable(email: string): Promise<void> {
await this.disableButton(email).click();
await this.confirmDialog.getByRole('button', { name: 'Disable' }).click();
}
async reEnable(email: string): Promise<void> {
await this.reEnableButton(email).click();
await this.confirmDialog.getByRole('button', { name: 'Re-enable' }).click();
}
}
+61
View File
@@ -0,0 +1,61 @@
import { FrameLocator, Locator, Page, expect } from '@playwright/test';
/**
* The Emails tab: a vertical rail of template types and one editor at a time.
*
* Only the active template's editor is mounted, which is what keeps the labels
* below unambiguous — the previous stacked layout had every editor on screen at
* once and a locator for "Save" matched all six (#135).
*
* A customised template is marked in the rail with a dot carrying an
* aria-label, so the state is in the tab's accessible name rather than colour
* alone — which is what lets a test assert it without opening the editor.
*/
export class AdminEmails {
readonly saveButton: Locator;
readonly restoreDefaultButton: Locator;
constructor(private readonly page: Page) {
this.saveButton = page.getByRole('button', { name: 'Save', exact: true });
this.restoreDefaultButton = page.getByRole('button', { name: 'Restore default' });
}
/** One template's entry in the rail. */
railTab(label: string | RegExp): Locator {
return this.page.getByRole('tab', { name: label });
}
customisedTab(label: string): Locator {
return this.page.getByRole('tab', { name: new RegExp(`${label}.*Customised`) });
}
subject(label: string): Locator {
return this.page.getByLabel(`${label} subject`);
}
body(label: string): Locator {
return this.page.getByLabel(`${label} body`);
}
/**
* The rendered preview, which is an iframe.
*
* It is the server's rendering of the actual email rather than the markdown
* editor's own, so it is the only thing that shows the consent footer and the
* substituted placeholders.
*/
preview(label: string): FrameLocator {
return this.page.frameLocator(`iframe[title="${label} preview"]`);
}
/**
* Opens one template and waits for its editor.
*
* The wait is the action's contract: the rail swaps the editor, and a locator
* resolved mid-swap finds the outgoing one.
*/
async openTemplate(label: string): Promise<void> {
await this.railTab(new RegExp(label)).click();
await expect(this.subject(label)).toBeVisible();
}
}
+116 -1
View File
@@ -1,4 +1,4 @@
import { Locator, Page } from '@playwright/test'; import { Locator, Page, expect } from '@playwright/test';
/** /**
* The admin inventory tab: the item table and the form that adds to it. * The admin inventory tab: the item table and the form that adds to it.
@@ -13,6 +13,13 @@ export class AdminInventory {
readonly price: Locator; readonly price: Locator;
readonly category: Locator; readonly category: Locator;
readonly saveButton: 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) { constructor(private readonly page: Page) {
this.addItemButton = page.getByRole('button', { name: 'Add Item' }); this.addItemButton = page.getByRole('button', { name: 'Add Item' });
@@ -20,6 +27,9 @@ export class AdminInventory {
this.price = page.getByLabel('Price (USD)'); this.price = page.getByLabel('Price (USD)');
this.category = page.getByLabel('Category', { exact: true }); this.category = page.getByLabel('Category', { exact: true });
this.saveButton = page.getByRole('button', { name: 'Save', exact: true }); this.saveButton = page.getByRole('button', { name: 'Save', exact: true });
this.confirmButton = page.getByRole('button', { name: 'OK' });
this.formDialog = page.getByRole('dialog');
this.tagPicker = page.getByText('Pick existing tags');
} }
row(itemName: string): Locator { row(itemName: string): Locator {
@@ -53,7 +63,112 @@ export class AdminInventory {
await this.page.getByRole('button', { name: itemName }).click(); 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 ----
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');
}
get clearFiltersButton(): Locator {
return this.page.getByRole('button', { name: 'Clear filters' });
}
/**
* Toggles one status in the multi-select. Clicking a selected option removes
* it, which is what the clearing test relies on.
*
* Two antd details decide this locator. It 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 as the option, so an
* unscoped getByTitle becomes ambiguous. Matching the visible option class
* avoids both.
*
* The dropdown is opened only when it is not already open: antd keeps it open
* after a selection in multiple mode, so clicking the box again would close it.
*/
async toggleStatus(label: string): Promise<void> {
const option = this.page.locator(`.ant-select-item-option[title="${label}"]`);
if (!(await option.isVisible().catch(() => false))) {
await this.statusFilter.click();
}
await option.click();
}
/**
* 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.
*/
async filterByCategory(categoryName: string, expectedRow: string): Promise<void> {
await this.categoryFilter.click();
await this.categoryFilter.fill(categoryName);
await this.page.getByTitle(categoryName, { exact: true }).click();
await expect(this.row(expectedRow)).toBeVisible();
}
async openItemForm(): Promise<void> { async openItemForm(): Promise<void> {
await this.addItemButton.click(); 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();
}
} }
+54 -3
View File
@@ -24,19 +24,38 @@ export class AdminPage {
readonly tabList: Locator; readonly tabList: Locator;
readonly activePanel: Locator; readonly activePanel: Locator;
readonly activeTabLabel: Locator; readonly activeTabLabel: Locator;
readonly themeToggle: Locator;
readonly themedBody: Locator;
/**
* antd renders Select popups into a portal at the end of <body>, outside the
* panel they belong to — so a dropdown cannot be found by scoping to the tab.
*/
readonly selectDropdown: Locator;
constructor(private readonly page: Page) { constructor(private readonly page: Page) {
this.tabList = page.getByRole('tablist'); this.tabList = page.getByRole('tablist');
this.activePanel = page.locator('.ant-tabs-tabpane-active').first(); this.activePanel = page.locator('.ant-tabs-tabpane-active').first();
this.activeTabLabel = page.locator('.ant-tabs-tab-active .ant-tabs-tab-btn').first(); this.activeTabLabel = page.locator('.ant-tabs-tab-active .ant-tabs-tab-btn').first();
this.themeToggle = page.getByRole('switch');
this.themedBody = page.locator('body');
this.selectDropdown = page.locator('.ant-select-dropdown').first();
} }
async goto(): Promise<void> { async goto(): Promise<void> {
await this.page.goto('/admin'); await this.page.goto('/admin');
} }
tab(name: AdminTab | RegExp): Locator { /**
return this.page.getByRole('tab', { name }); * A top-level admin tab.
*
* `exact` matters on the Emails tab: the template rail inside it also renders
* tabs, and "Email verification" contains "Email". Exact matching keeps the
* shell's tabs and the rail's apart.
*/
tab(name: AdminTab | RegExp, exact = false): Locator {
return typeof name === 'string'
? this.page.getByRole('tab', { name, exact })
: this.page.getByRole('tab', { name });
} }
/** /**
@@ -50,9 +69,41 @@ export class AdminPage {
await this.openTab(name); await this.openTab(name);
} }
/** The tree or table inside whichever panel is showing. */
get activeTree(): Locator {
return this.activePanel.locator('.ant-tree').first();
}
get activeTable(): Locator {
return this.activePanel.locator('.ant-table').first();
}
/**
* Puts the admin in dark mode, whatever it was in before.
*
* Idempotent rather than a toggle: the theme persists across reloads, so a
* blind click leaves the state depending on what the previous test chose.
*/
async switchToDark(): Promise<void> {
await this.goto();
if ((await this.themeToggle.getAttribute('aria-checked')) !== 'true') {
await this.themeToggle.click();
}
await expect(this.themedBody).toHaveAttribute('data-theme', 'dark');
}
/** The computed background of an element, for the contrast assertions. */
static async backgroundOf(locator: Locator): Promise<string> {
return locator.evaluate((el) => getComputedStyle(el).backgroundColor);
}
static async colorOf(locator: Locator): Promise<string> {
return locator.evaluate((el) => getComputedStyle(el).color);
}
/** Switches tabs without renavigating, for a test that visits two of them. */ /** Switches tabs without renavigating, for a test that visits two of them. */
async openTab(name: AdminTab): Promise<void> { async openTab(name: AdminTab): Promise<void> {
await this.tab(name).click(); await this.tab(name, true).click();
// The panel being mounted is the completion of the click. Without this a // The panel being mounted is the completion of the click. Without this a
// caller's first locator resolves against the outgoing panel. // caller's first locator resolves against the outgoing panel.
await expect(this.activePanel).toBeVisible(); await expect(this.activePanel).toBeVisible();
+36
View File
@@ -0,0 +1,36 @@
import { Locator, Page, expect } from '@playwright/test';
/**
* The Settings tab: cart expiry, the two link lifetimes, and the greeting.
*
* The lifetimes are not cosmetic — the verification and password reset emails
* state their own duration through a placeholder rendered from these values, so
* changing one here changes what a customer is told (#136). The greeting is a
* format plus a separate fallback, because a format with the name edited out
* would ship "Hi ," to everyone who registered while first names were optional.
*/
export class AdminSettings {
readonly heading: Locator;
readonly cartExpiryHours: Locator;
readonly verifyTokenHours: Locator;
readonly passwordResetHours: Locator;
readonly greetingFormat: Locator;
readonly greetingFallback: Locator;
readonly saveButton: Locator;
constructor(private readonly page: Page) {
this.heading = page.getByRole('heading', { name: 'Link lifetimes' });
this.cartExpiryHours = page.getByLabel('Cart expiry (hours)');
this.verifyTokenHours = page.getByLabel('Email verification link (hours)');
this.passwordResetHours = page.getByLabel('Password reset link (hours)');
this.greetingFormat = page.getByLabel('Greeting format');
this.greetingFallback = page.getByLabel('Fallback, when there is no first name');
this.saveButton = page.getByRole('button', { name: 'Save' });
}
async open(): Promise<void> {
await this.page.goto('/admin');
await this.page.getByRole('tab', { name: 'Settings', exact: true }).click();
await expect(this.heading).toBeVisible();
}
}
+49
View File
@@ -0,0 +1,49 @@
import { Locator, Page } from '@playwright/test';
/**
* The Categories and Tags tabs, which share a shape: a table or tree, an
* "Add X" button, and an antd Modal with a single Name field behind it.
*
* Categories are a tree because they nest arbitrarily deep; tags are flat. The
* tree is what makes "Add child" meaningful, and it is mounted before a new
* branch exists — so a child is only visible if expansion follows newly created
* nodes rather than the state captured at first render, which is the thing the
* spec here pins down.
*/
export class AdminTaxonomy {
readonly addCategoryButton: Locator;
readonly addTagButton: Locator;
readonly name: Locator;
readonly confirmButton: Locator;
readonly tagAddedNotice: Locator;
constructor(private readonly page: Page) {
this.addCategoryButton = page.getByRole('button', { name: 'Add Category' });
this.addTagButton = page.getByRole('button', { name: 'Add Tag' });
this.name = page.getByLabel('Name');
this.confirmButton = page.getByRole('button', { name: 'OK' });
this.tagAddedNotice = page.getByText('Tag added');
}
categoryNode(name: string): Locator {
return this.page.getByRole('treeitem').filter({ hasText: name });
}
async addCategory(name: string): Promise<void> {
await this.addCategoryButton.click();
await this.name.fill(name);
await this.confirmButton.click();
}
async addChildCategory(parentName: string, childName: string): Promise<void> {
await this.categoryNode(parentName).getByRole('button', { name: 'Add child' }).click();
await this.name.fill(childName);
await this.confirmButton.click();
}
async addTag(name: string): Promise<void> {
await this.addTagButton.click();
await this.name.fill(name);
await this.confirmButton.click();
}
}