test(e2e): convert the remaining admin specs, completing the POM refactor (#137)
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:
@@ -1,65 +1,40 @@
|
||||
import { test, expect, Page } 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;
|
||||
}
|
||||
import { test, expect, createItem, uniqueSuffix } from './fixtures';
|
||||
|
||||
test.describe('Disabling a customer account', () => {
|
||||
test('an admin can disable an account and the customer is told at sign-in', async ({ page }) => {
|
||||
const email = uniqueEmail();
|
||||
await register(page, email);
|
||||
test('an admin can disable an account and the customer is told at sign-in', async ({
|
||||
page,
|
||||
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 expect(row.getByText('ACTIVE')).toBeVisible();
|
||||
|
||||
await row.getByRole('button', { name: 'Disable' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Disable' }).click();
|
||||
await adminCustomers.disable(customer.email);
|
||||
await expect(page.getByText('Account disabled')).toBeVisible();
|
||||
|
||||
const updated = page.getByRole('row').filter({ hasText: email });
|
||||
await expect(updated.getByText('DISABLED')).toBeVisible();
|
||||
await expect(adminCustomers.status(customer.email, 'DISABLED')).toBeVisible();
|
||||
|
||||
// A generic credential error would send a real customer round the
|
||||
// password-reset loop forever.
|
||||
await page.goto('/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||
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 authModal.gotoLogIn();
|
||||
await authModal.logIn(customer.email, customer.password);
|
||||
await expect(page.getByText(/disabled/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('an existing session stops working immediately', async ({ page, request }) => {
|
||||
const email = uniqueEmail();
|
||||
await register(page, email);
|
||||
|
||||
test('an existing session stops working immediately', async ({
|
||||
page,
|
||||
request,
|
||||
customer,
|
||||
accountModal
|
||||
}) => {
|
||||
// Still signed in from registration, in this same browser context.
|
||||
await page.goto('/account');
|
||||
await expect(page.getByText(email)).toBeVisible();
|
||||
await accountModal.open();
|
||||
await expect(accountModal.emailText(customer.email)).toBeVisible();
|
||||
|
||||
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`);
|
||||
|
||||
// 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/);
|
||||
});
|
||||
|
||||
test('re-enabling restores sign-in', async ({ page, request }) => {
|
||||
const email = uniqueEmail();
|
||||
await register(page, email);
|
||||
|
||||
test('re-enabling restores sign-in', async ({
|
||||
page,
|
||||
request,
|
||||
customer,
|
||||
admin,
|
||||
adminCustomers,
|
||||
authModal,
|
||||
header
|
||||
}) => {
|
||||
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`);
|
||||
|
||||
const row = await customerRow(page, email);
|
||||
await row.getByRole('button', { name: 'Re-enable' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Re-enable' }).click();
|
||||
await admin.open('Customers');
|
||||
await expect(adminCustomers.row(customer.email)).toBeVisible();
|
||||
await adminCustomers.reEnable(customer.email);
|
||||
await expect(page.getByText('Account re-enabled')).toBeVisible();
|
||||
|
||||
await page.goto('/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||
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.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
||||
await authModal.gotoLogIn();
|
||||
await authModal.logIn(customer.email, customer.password);
|
||||
await header.waitForSignedIn();
|
||||
});
|
||||
|
||||
test('the confirmation warns that held items will be released', async ({ page, request }) => {
|
||||
const email = uniqueEmail();
|
||||
const itemName = `Held ${Date.now().toString(36)}`;
|
||||
test('the confirmation warns that held items will be released', async ({
|
||||
page,
|
||||
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', {
|
||||
multipart: { name: itemName, description: '', price: '40', category_id: '', tags: '[]' }
|
||||
});
|
||||
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();
|
||||
await admin.open('Customers');
|
||||
await expect(adminCustomers.row(customer.email)).toBeVisible();
|
||||
await adminCustomers.disableButton(customer.email).click();
|
||||
|
||||
// The consequence has to be visible at the moment of the decision.
|
||||
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();
|
||||
|
||||
const item = await (await request.get(`/api/items/${itemId}`)).json();
|
||||
expect(item.status).toBe('available');
|
||||
const released = await (await request.get(`/api/items/${item.id}`)).json();
|
||||
expect(released.status).toBe('available');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,33 +12,27 @@ const DEFAULTS = {
|
||||
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.afterEach(async ({ page }) => {
|
||||
test.afterEach(async ({ page, adminSettings }) => {
|
||||
await page.request.put('/api/admin/settings', { data: DEFAULTS });
|
||||
});
|
||||
|
||||
test('offers the link lifetimes and the greeting alongside cart expiry', async ({ page }) => {
|
||||
await openSettings(page);
|
||||
test('offers the link lifetimes and the greeting alongside cart expiry', async ({ page, adminSettings }) => {
|
||||
await adminSettings.open();
|
||||
|
||||
// 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(page.getByLabel('Email verification link (hours)')).toHaveValue('24.0');
|
||||
await expect(page.getByLabel('Password reset link (hours)')).toHaveValue('1.0');
|
||||
await expect(page.getByLabel('Greeting format')).toHaveValue('Hi {{firstName}},');
|
||||
await expect(page.getByLabel('Fallback, when there is no first name')).toHaveValue('Hi,');
|
||||
await expect(adminSettings.cartExpiryHours).toHaveValue('24.0');
|
||||
await expect(adminSettings.verifyTokenHours).toHaveValue('24.0');
|
||||
await expect(adminSettings.passwordResetHours).toHaveValue('1.0');
|
||||
await expect(adminSettings.greetingFormat).toHaveValue('Hi {{firstName}},');
|
||||
await expect(adminSettings.greetingFallback).toHaveValue('Hi,');
|
||||
});
|
||||
|
||||
test('saves a new lifetime, and the server keeps it', async ({ page }) => {
|
||||
await openSettings(page);
|
||||
test('saves a new lifetime, and the server keeps it', async ({ page, adminSettings }) => {
|
||||
await adminSettings.open();
|
||||
|
||||
await page.getByLabel('Password reset link (hours)').fill('3');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await adminSettings.passwordResetHours.fill('3');
|
||||
await adminSettings.saveButton.click();
|
||||
await expect(page.getByText('Settings saved')).toBeVisible();
|
||||
|
||||
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
|
||||
// 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.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Emails' }).click();
|
||||
await page.getByRole('tab', { name: /Password reset/ }).click();
|
||||
await admin.open('Emails');
|
||||
await adminEmails.openTemplate('Password reset');
|
||||
|
||||
const preview = page.frameLocator('iframe[title="Password reset preview"]');
|
||||
await expect(preview.getByText('2 hours')).toBeVisible();
|
||||
await expect(adminEmails.preview('Password reset').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.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Emails' }).click();
|
||||
await page.getByRole('tab', { name: /Email verification/ }).click();
|
||||
await admin.open('Emails');
|
||||
await adminEmails.openTemplate('Email verification');
|
||||
|
||||
const preview = page.frameLocator('iframe[title="Email verification preview"]');
|
||||
await expect(preview.getByText('Salutations Ada!')).toBeVisible();
|
||||
await expect(adminEmails.preview('Email verification').getByText('Salutations Ada!')).toBeVisible();
|
||||
});
|
||||
|
||||
test('refuses a lifetime of zero rather than reporting a save', async ({ page }) => {
|
||||
await openSettings(page);
|
||||
test('refuses a lifetime of zero rather than reporting a save', async ({ page, adminSettings }) => {
|
||||
await adminSettings.open();
|
||||
|
||||
await page.getByLabel('Password reset link (hours)').fill('0');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await adminSettings.passwordResetHours.fill('0');
|
||||
await adminSettings.saveButton.click();
|
||||
|
||||
await expect(page.getByText('Settings saved')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -1,48 +1,34 @@
|
||||
import { test, expect } 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;
|
||||
}
|
||||
import { test, expect, uniqueSuffix, createCategory } from './fixtures';
|
||||
|
||||
test.describe('Inline category creation from the item form', () => {
|
||||
test('creates a category without leaving the item form and assigns it', async ({ page }) => {
|
||||
const RUN = suffix();
|
||||
test('creates a category without leaving the item form and assigns it', async ({
|
||||
page,
|
||||
admin,
|
||||
adminInventory
|
||||
}) => {
|
||||
const RUN = `i${uniqueSuffix()}`;
|
||||
const categoryName = `Inline ${RUN}`;
|
||||
const itemName = `Item ${RUN}`;
|
||||
|
||||
await page.goto('/admin');
|
||||
await openItemForm(page);
|
||||
await page.getByLabel('Name').fill(itemName);
|
||||
await page.getByLabel('Price (USD)').fill('99');
|
||||
await admin.goto();
|
||||
await adminInventory.openItemFormLoaded();
|
||||
await adminInventory.name.fill(itemName);
|
||||
await adminInventory.price.fill('99');
|
||||
|
||||
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);
|
||||
await adminInventory.categoryField.click();
|
||||
await expect(adminInventory.newCategoryName).toBeVisible();
|
||||
await expect(adminInventory.createCategoryButton).toBeVisible();
|
||||
await adminInventory.newCategoryName.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 adminInventory.newCategoryName.press('Enter');
|
||||
|
||||
// The new category should be selected straight away — having to hunt for it
|
||||
// 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();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('reports a duplicate category name instead of silently doing nothing', async ({ page }) => {
|
||||
const RUN = suffix();
|
||||
const categoryName = `Dupe ${RUN}`;
|
||||
test('reports a duplicate category name instead of silently doing nothing', async ({
|
||||
page,
|
||||
admin,
|
||||
adminInventory
|
||||
}) => {
|
||||
const categoryName = `Dupe i${uniqueSuffix()}`;
|
||||
await createCategory(page.request, categoryName);
|
||||
|
||||
const created = await page.request.post('/api/admin/categories', {
|
||||
data: { name: categoryName, parent_id: null }
|
||||
});
|
||||
expect(created.status()).toBe(201);
|
||||
|
||||
await page.goto('/admin');
|
||||
await openItemForm(page);
|
||||
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 admin.goto();
|
||||
await adminInventory.openItemFormLoaded();
|
||||
await adminInventory.categoryField.click();
|
||||
await expect(adminInventory.newCategoryName).toBeVisible();
|
||||
await expect(adminInventory.createCategoryButton).toBeVisible();
|
||||
await adminInventory.newCategoryName.fill(categoryName);
|
||||
await adminInventory.newCategoryName.press('Enter');
|
||||
|
||||
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 = {
|
||||
category: `Filterable ${RUN}`,
|
||||
@@ -12,166 +12,115 @@ const NAMES = {
|
||||
};
|
||||
|
||||
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', {
|
||||
data: { name: NAMES.category, parent_id: null }
|
||||
})).json()).id;
|
||||
await api.post('/api/admin/tags', { data: { name: NAMES.tag } });
|
||||
const categoryId = await createCategory(api, NAMES.category);
|
||||
await createTag(api, NAMES.tag);
|
||||
|
||||
// Published after creation: new items are pending, and these fixtures stand
|
||||
// in for ordinary stock rather than staged drafts.
|
||||
const item = async (name: string, price: string, inCategory: boolean, publish = true) => {
|
||||
const res = await api.post('/api/admin/items', {
|
||||
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);
|
||||
const inCategory = { categoryId, tags: [NAMES.tag] };
|
||||
await createItem(api, { name: NAMES.cheap, price: '50', ...inCategory });
|
||||
await createItem(api, { name: NAMES.mid, price: '150', ...inCategory });
|
||||
await createItem(api, { name: NAMES.dear, price: '900', ...inCategory });
|
||||
// Left pending on purpose: the Unpublished filter needs something to find,
|
||||
// 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();
|
||||
});
|
||||
|
||||
// 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('filters by category', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await filterToOwnCategory(page);
|
||||
test('filters by category', async ({ admin, adminInventory }) => {
|
||||
await admin.goto();
|
||||
await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
|
||||
|
||||
// All three fixtures share the category, and nothing else does.
|
||||
await expect(row(page, NAMES.cheap)).toBeVisible();
|
||||
await expect(row(page, NAMES.mid)).toBeVisible();
|
||||
await expect(row(page, NAMES.dear)).toBeVisible();
|
||||
await expect(adminInventory.row(NAMES.cheap)).toBeVisible();
|
||||
await expect(adminInventory.row(NAMES.mid)).toBeVisible();
|
||||
await expect(adminInventory.row(NAMES.dear)).toBeVisible();
|
||||
});
|
||||
|
||||
test('filters by price range', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await filterToOwnCategory(page);
|
||||
test('filters by price range', async ({ admin, adminInventory }) => {
|
||||
await admin.goto();
|
||||
await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
|
||||
|
||||
await page.getByLabel('Minimum price').fill('100');
|
||||
await page.getByLabel('Maximum price').fill('500');
|
||||
await adminInventory.minimumPrice.fill('100');
|
||||
await adminInventory.maximumPrice.fill('500');
|
||||
|
||||
await expect(row(page, NAMES.mid)).toBeVisible();
|
||||
await expect(row(page, NAMES.cheap)).toHaveCount(0);
|
||||
await expect(row(page, NAMES.dear)).toHaveCount(0);
|
||||
await expect(adminInventory.row(NAMES.mid)).toBeVisible();
|
||||
await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0);
|
||||
await expect(adminInventory.row(NAMES.dear)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('filters by a single status', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await filterToOwnCategory(page);
|
||||
test('filters by a single status', async ({ admin, adminInventory }) => {
|
||||
await admin.goto();
|
||||
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.
|
||||
await expect(row(page, NAMES.cheap)).toHaveCount(0);
|
||||
await expect(row(page, NAMES.mid)).toHaveCount(0);
|
||||
await expect(row(page, NAMES.dear)).toHaveCount(0);
|
||||
await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0);
|
||||
await expect(adminInventory.row(NAMES.mid)).toHaveCount(0);
|
||||
await expect(adminInventory.row(NAMES.dear)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// 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
|
||||
// replaced could not ask it.
|
||||
test('finds unpublished items, and only those', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await filterToOwnCategory(page);
|
||||
test('finds unpublished items, and only those', async ({ admin, adminInventory }) => {
|
||||
await admin.goto();
|
||||
await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
|
||||
|
||||
await chooseStatus(page, 'Pending');
|
||||
await adminInventory.toggleStatus('Pending');
|
||||
|
||||
await expect(row(page, NAMES.staged)).toBeVisible();
|
||||
await expect(row(page, NAMES.cheap)).toHaveCount(0);
|
||||
await expect(row(page, NAMES.mid)).toHaveCount(0);
|
||||
await expect(row(page, NAMES.dear)).toHaveCount(0);
|
||||
await expect(adminInventory.row(NAMES.staged)).toBeVisible();
|
||||
await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0);
|
||||
await expect(adminInventory.row(NAMES.mid)).toHaveCount(0);
|
||||
await expect(adminInventory.row(NAMES.dear)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// 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".
|
||||
test('finds published items by selecting several statuses at once', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await filterToOwnCategory(page);
|
||||
test('finds published items by selecting several statuses at once', async ({ admin, adminInventory }) => {
|
||||
await admin.goto();
|
||||
await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
|
||||
|
||||
await chooseStatus(page, 'Available');
|
||||
await chooseStatus(page, 'Reserved');
|
||||
await chooseStatus(page, 'Sold');
|
||||
await adminInventory.toggleStatus('Available');
|
||||
await adminInventory.toggleStatus('Reserved');
|
||||
await adminInventory.toggleStatus('Sold');
|
||||
|
||||
await expect(row(page, NAMES.cheap)).toBeVisible();
|
||||
await expect(row(page, NAMES.mid)).toBeVisible();
|
||||
await expect(row(page, NAMES.dear)).toBeVisible();
|
||||
await expect(row(page, NAMES.staged)).toHaveCount(0);
|
||||
await expect(adminInventory.row(NAMES.cheap)).toBeVisible();
|
||||
await expect(adminInventory.row(NAMES.mid)).toBeVisible();
|
||||
await expect(adminInventory.row(NAMES.dear)).toBeVisible();
|
||||
await expect(adminInventory.row(NAMES.staged)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// Cleared back to nothing must mean "no filter" rather than "no statuses",
|
||||
// or emptying the box would empty the table.
|
||||
test('clearing the status shows everything again', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await filterToOwnCategory(page);
|
||||
await chooseStatus(page, 'Pending');
|
||||
await expect(row(page, NAMES.cheap)).toHaveCount(0);
|
||||
test('clearing the status shows everything again', async ({ admin, adminInventory }) => {
|
||||
await admin.goto();
|
||||
await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
|
||||
await adminInventory.toggleStatus('Pending');
|
||||
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(row(page, NAMES.staged)).toBeVisible();
|
||||
await expect(adminInventory.row(NAMES.cheap)).toBeVisible();
|
||||
await expect(adminInventory.row(NAMES.staged)).toBeVisible();
|
||||
});
|
||||
|
||||
test('combines filters, and clearing restores them', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await filterToOwnCategory(page);
|
||||
await page.getByLabel('Minimum price').fill('800');
|
||||
await expect(row(page, NAMES.cheap)).toHaveCount(0);
|
||||
await expect(row(page, NAMES.dear)).toBeVisible();
|
||||
test('combines filters, and clearing restores them', async ({ admin, adminInventory }) => {
|
||||
await admin.goto();
|
||||
await adminInventory.filterByCategory(NAMES.category, NAMES.cheap);
|
||||
await adminInventory.minimumPrice.fill('800');
|
||||
await expect(adminInventory.row(NAMES.cheap)).toHaveCount(0);
|
||||
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
|
||||
// the table is the whole paginated catalogue again, so a given fixture is
|
||||
// not reliably on the first page.
|
||||
await expect(page.getByRole('button', { name: 'Clear filters' })).toHaveCount(0);
|
||||
await expect(page.getByLabel('Minimum price')).toHaveValue('');
|
||||
await expect(adminInventory.clearFiltersButton).toHaveCount(0);
|
||||
await expect(adminInventory.minimumPrice).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
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;
|
||||
}
|
||||
const DESCRIPTION = 'A preview subject';
|
||||
|
||||
test.describe('Admin item preview', () => {
|
||||
test('opens the storefront card from the item name', async ({ page }) => {
|
||||
const name = `Preview ${suffix()}`;
|
||||
await createItem(page, name, '42');
|
||||
test('opens the storefront card from the item name', async ({ page, admin, adminInventory }) => {
|
||||
const name = `Preview p${uniqueSuffix()}`;
|
||||
await createItem(page.request, { name, price: '42', description: DESCRIPTION });
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('button', { name }).click();
|
||||
await admin.goto();
|
||||
await adminInventory.openPreview(name);
|
||||
|
||||
const drawer = page.getByRole('dialog', { name: `Preview: ${name}` });
|
||||
const drawer = adminInventory.previewDrawer(name);
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
// 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
|
||||
// buttons must keep their normal appearance rather than being disabled,
|
||||
// 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 }) => {
|
||||
const name = `Preview ${suffix()}`;
|
||||
await createItem(page, name, '15');
|
||||
test('shows the Add to Cart button in its normal enabled state', async ({
|
||||
page,
|
||||
admin,
|
||||
adminInventory
|
||||
}) => {
|
||||
const name = `Preview p${uniqueSuffix()}`;
|
||||
await createItem(page.request, { name, price: '15', description: DESCRIPTION });
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('button', { name }).click();
|
||||
await admin.goto();
|
||||
await adminInventory.openPreview(name);
|
||||
|
||||
const drawer = page.getByRole('dialog', { name: `Preview: ${name}` });
|
||||
const addToCart = drawer.getByRole('button', { name: 'Add to Cart' });
|
||||
const addToCart = adminInventory.previewDrawer(name).getByRole('button', { name: 'Add to Cart' });
|
||||
|
||||
await expect(addToCart).toBeVisible();
|
||||
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
|
||||
// before it can add anything — so if that modal never appears, the handler
|
||||
// short-circuited before reaching any of its real work.
|
||||
test('does not act when the preview card is clicked', async ({ page }) => {
|
||||
const name = `Preview ${suffix()}`;
|
||||
await createItem(page, name, '99');
|
||||
test('does not act when the preview card is clicked', async ({ page, admin, adminInventory }) => {
|
||||
const name = `Preview p${uniqueSuffix()}`;
|
||||
await createItem(page.request, { name, price: '99', description: DESCRIPTION });
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('button', { name }).click();
|
||||
await admin.goto();
|
||||
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();
|
||||
|
||||
// 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
|
||||
// change would have quietly broken buying things.
|
||||
test('leaves the real storefront card working', async ({ page }) => {
|
||||
const name = `Live ${suffix()}`;
|
||||
await createItem(page, name, '20');
|
||||
test('leaves the real storefront card working', async ({ page, storefront }) => {
|
||||
const name = `Live p${uniqueSuffix()}`;
|
||||
await createItem(page.request, { name, price: '20', description: DESCRIPTION });
|
||||
|
||||
await page.goto('/');
|
||||
const card = page.locator('.ant-card').filter({ hasText: name });
|
||||
await expect(card).toBeVisible();
|
||||
await storefront.goto();
|
||||
await expect(storefront.card(name)).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.
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
@@ -1,61 +1,52 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect, createItem, uniqueSuffix } from './fixtures';
|
||||
|
||||
const suffix = () => `r${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
// Reserves an item by registering a customer and adding it to their cart, which
|
||||
// is the only way an item legitimately reaches 'reserved'.
|
||||
async function reserveItem(page: import('@playwright/test').Page, itemName: string) {
|
||||
const email = `reserve-${suffix()}@example.com`;
|
||||
|
||||
const created = await page.request.post('/api/admin/items', {
|
||||
multipart: { name: itemName, description: '', price: '75', category_id: '', tags: '[]' }
|
||||
});
|
||||
expect(created.ok()).toBeTruthy();
|
||||
const itemId = (await created.json()).id as number;
|
||||
// New items are pending, and a pending item cannot be reserved.
|
||||
expect((await page.request.post(`/api/admin/items/${itemId}/mark-available`)).ok()).toBeTruthy();
|
||||
|
||||
await page.goto('/register');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
||||
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
||||
await page.getByLabel('Password').fill('supersecret123');
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
const added = await page.request.post(`/api/cart/items/${itemId}`);
|
||||
expect(added.status()).toBe(201);
|
||||
|
||||
return { email, itemId };
|
||||
/**
|
||||
* Puts an item in the signed-in customer's cart, which is the only way an item
|
||||
* legitimately reaches 'reserved'.
|
||||
*
|
||||
* The `customer` fixture supplies the session, so this only has to seed the
|
||||
* item and add it — a pending item cannot be reserved, hence publishing first,
|
||||
* which `createItem` does by default.
|
||||
*/
|
||||
async function reserve(
|
||||
request: import('@playwright/test').APIRequestContext,
|
||||
itemName: string
|
||||
): Promise<number> {
|
||||
const item = await createItem(request, { name: itemName, price: '75' });
|
||||
expect((await request.post(`/api/cart/items/${item.id}`)).status()).toBe(201);
|
||||
return item.id;
|
||||
}
|
||||
|
||||
test.describe('Admin reserved items', () => {
|
||||
test('shows a reserved count that opens the held items', async ({ page }) => {
|
||||
const itemName = `Held ${suffix()}`;
|
||||
const { email } = await reserveItem(page, itemName);
|
||||
test('shows a reserved count that opens the held items', async ({
|
||||
page,
|
||||
customer,
|
||||
admin,
|
||||
adminCustomers
|
||||
}) => {
|
||||
const itemName = `Held r${uniqueSuffix()}`;
|
||||
await reserve(page.request, itemName);
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||
await admin.open('Customers');
|
||||
await expect(adminCustomers.row(customer.email)).toBeVisible();
|
||||
await adminCustomers.reservedCountButton(customer.email).click();
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: email });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole('button', { name: /item/ }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
|
||||
await expect(dialog.getByText(itemName)).toBeVisible();
|
||||
await expect(adminCustomers.reservedItemsDialog.getByText(itemName)).toBeVisible();
|
||||
});
|
||||
|
||||
test('releasing an item returns it to the storefront as available', async ({ page }) => {
|
||||
const itemName = `Freed ${suffix()}`;
|
||||
const { email, itemId } = await reserveItem(page, itemName);
|
||||
test('releasing an item returns it to the storefront as available', async ({
|
||||
page,
|
||||
customer,
|
||||
admin,
|
||||
adminCustomers
|
||||
}) => {
|
||||
const itemName = `Freed r${uniqueSuffix()}`;
|
||||
const itemId = await reserve(page.request, itemName);
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||
const row = page.getByRole('row').filter({ hasText: email });
|
||||
await row.getByRole('button', { name: /item/ }).click();
|
||||
await admin.open('Customers');
|
||||
await adminCustomers.reservedCountButton(customer.email).click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
|
||||
await dialog.getByRole('button', { name: 'Release' }).click();
|
||||
await adminCustomers.reservedItemsDialog.getByRole('button', { name: 'Release' }).click();
|
||||
await expect(page.getByText(`Released "${itemName}"`)).toBeVisible();
|
||||
|
||||
// The point of releasing is that the item becomes purchasable again.
|
||||
@@ -63,38 +54,34 @@ test.describe('Admin reserved items', () => {
|
||||
expect(item.status).toBe('available');
|
||||
});
|
||||
|
||||
test('the count drops once the item is released', async ({ page }) => {
|
||||
const itemName = `Recount ${suffix()}`;
|
||||
const { email } = await reserveItem(page, itemName);
|
||||
test('the count drops once the item is released', async ({
|
||||
page,
|
||||
customer,
|
||||
admin,
|
||||
adminCustomers
|
||||
}) => {
|
||||
await reserve(page.request, `Recount r${uniqueSuffix()}`);
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||
const row = page.getByRole('row').filter({ hasText: email });
|
||||
await row.getByRole('button', { name: /item/ }).click();
|
||||
await admin.open('Customers');
|
||||
await adminCustomers.reservedCountButton(customer.email).click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
|
||||
const dialog = adminCustomers.reservedItemsDialog;
|
||||
await dialog.getByRole('button', { name: 'Release' }).click();
|
||||
await expect(dialog.getByText("This customer isn't holding any items")).toBeVisible();
|
||||
|
||||
// The row behind the dialog must agree with the dialog it opened.
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(row.getByRole('button', { name: /item/ })).toHaveCount(0);
|
||||
await expect(adminCustomers.reservedCountButton(customer.email)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a customer holding nothing shows no link to click', async ({ page }) => {
|
||||
const email = `idle-${suffix()}@example.com`;
|
||||
await page.goto('/register');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
||||
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
||||
await page.getByLabel('Password').fill('supersecret123');
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
||||
test('a customer holding nothing shows no link to click', async ({
|
||||
customer,
|
||||
admin,
|
||||
adminCustomers
|
||||
}) => {
|
||||
await admin.open('Customers');
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||
const row = page.getByRole('row').filter({ hasText: email });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.getByRole('button', { name: /item/ })).toHaveCount(0);
|
||||
await expect(adminCustomers.row(customer.email)).toBeVisible();
|
||||
await expect(adminCustomers.reservedCountButton(customer.email)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
const suffix = () => `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
import { test, expect, uniqueSuffix } from './fixtures';
|
||||
|
||||
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) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
return route.fulfill({
|
||||
@@ -15,24 +17,25 @@ test.describe('Admin save failures', () => {
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('button', { name: 'Add Item' }).click();
|
||||
await page.getByLabel('Name').fill(`Broken ${suffix()}`);
|
||||
await page.getByLabel('Price (USD)').fill('12');
|
||||
await page.getByRole('button', { name: 'OK' }).click();
|
||||
await admin.goto();
|
||||
await adminInventory.addItem(`Broken ${uniqueSuffix()}`, '12');
|
||||
|
||||
// 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.
|
||||
await expect(page.getByText('Item added')).toBeHidden();
|
||||
await expect(page.getByText("Couldn't save item")).toBeVisible();
|
||||
// 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
|
||||
// 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', {
|
||||
multipart: { name, description: '', price: '10', category_id: '', tags: '[]' }
|
||||
});
|
||||
@@ -45,26 +48,26 @@ test.describe('Admin save failures', () => {
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/admin');
|
||||
await admin.goto();
|
||||
// Items list newest-first, so the seeded row is on the first page.
|
||||
const row = page.getByRole('row').filter({ hasText: name });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(adminInventory.row(name)).toBeVisible();
|
||||
await adminInventory.deleteButton(name).click();
|
||||
|
||||
await expect(page.getByText('Item deleted')).toBeHidden();
|
||||
await expect(page.getByText("Couldn't delete item")).toBeVisible();
|
||||
// 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 }) => {
|
||||
const name = `Good ${suffix()}`;
|
||||
test('saves an item successfully when the server accepts it', async ({
|
||||
page,
|
||||
admin,
|
||||
adminInventory
|
||||
}) => {
|
||||
const name = `Good ${uniqueSuffix()}`;
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('button', { name: 'Add Item' }).click();
|
||||
await page.getByLabel('Name').fill(name);
|
||||
await page.getByLabel('Price (USD)').fill('34');
|
||||
await page.getByRole('button', { name: 'OK' }).click();
|
||||
await admin.goto();
|
||||
await adminInventory.addItem(name, '34');
|
||||
|
||||
await expect(page.getByText('Item added')).toBeVisible();
|
||||
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
// 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('creates a category and a nested child that stays visible', async ({ page }) => {
|
||||
const RUN = suffix();
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Categories' }).click();
|
||||
test('creates a category and a nested child that stays visible', async ({
|
||||
page,
|
||||
admin,
|
||||
adminTaxonomy
|
||||
}) => {
|
||||
const RUN = `a${uniqueSuffix()}`;
|
||||
await admin.open('Categories');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Category' }).click();
|
||||
await page.getByLabel('Name').fill(`Furniture ${RUN}`);
|
||||
await page.getByRole('button', { name: 'OK' }).click();
|
||||
await adminTaxonomy.addCategory(`Furniture ${RUN}`);
|
||||
await expect(page.getByText(`Furniture ${RUN}`)).toBeVisible();
|
||||
|
||||
await page
|
||||
.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();
|
||||
await adminTaxonomy.addChildCategory(`Furniture ${RUN}`, `Tables ${RUN}`);
|
||||
|
||||
// The tree is mounted before this branch exists, so the child is only
|
||||
// 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();
|
||||
});
|
||||
|
||||
test('creates a tag with an automatically assigned colour', async ({ page }) => {
|
||||
const RUN = suffix();
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Tags' }).click();
|
||||
test('creates a tag with an automatically assigned colour', async ({
|
||||
page,
|
||||
admin,
|
||||
adminTaxonomy
|
||||
}) => {
|
||||
const RUN = `a${uniqueSuffix()}`;
|
||||
await admin.open('Tags');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Tag' }).click();
|
||||
await page.getByLabel('Name').fill(`vintage-${RUN}`);
|
||||
await page.getByRole('button', { name: 'OK' }).click();
|
||||
await adminTaxonomy.addTag(`vintage-${RUN}`);
|
||||
// Clicking OK only dispatches the request; wait for the confirmation so the
|
||||
// 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
|
||||
// row is confirmed through the API rather than hunted for across pages.
|
||||
@@ -51,12 +46,11 @@ test.describe('Admin taxonomy', () => {
|
||||
expect(created.color).toBeTruthy();
|
||||
});
|
||||
|
||||
test('offers category and tag fields on the item form', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('button', { name: 'Add Item' }).click();
|
||||
test('offers category and tag fields on the item form', async ({ admin, adminInventory }) => {
|
||||
await admin.goto();
|
||||
await adminInventory.openItemForm();
|
||||
|
||||
const modal = page.getByRole('dialog');
|
||||
await expect(modal.getByText('Category', { exact: true })).toBeVisible();
|
||||
await expect(modal.getByText('Pick existing tags')).toBeVisible();
|
||||
await expect(adminInventory.formDialog.getByText('Category', { exact: true })).toBeVisible();
|
||||
await expect(adminInventory.formDialog.getByText('Pick existing tags')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// 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);
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
// there.
|
||||
async function seed(page: Page) {
|
||||
const run = suffix();
|
||||
await page.request.post('/api/admin/categories', { data: { name: `Theme ${run}`, parent_id: null } });
|
||||
await page.request.post('/api/admin/tags', { data: { name: `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');
|
||||
// suite (#116). Seed what each test needs rather than depending on what happens
|
||||
// to be there.
|
||||
async function seed(request: import('@playwright/test').APIRequestContext) {
|
||||
const run = uniqueSuffix();
|
||||
await createCategory(request, `Theme ${run}`);
|
||||
await createTag(request, `theme-${run}`);
|
||||
}
|
||||
|
||||
test.describe('Admin dark mode', () => {
|
||||
test('the active tab label is readable against the dark background', async ({ page }) => {
|
||||
await switchToDark(page);
|
||||
|
||||
const label = page.locator('.ant-tabs-tab-active .ant-tabs-tab-btn').first();
|
||||
test('the active tab label is readable against the dark background', async ({ admin }) => {
|
||||
await admin.switchToDark();
|
||||
|
||||
// The bug: colorPrimary stayed #1a1a1a in dark mode, so the active tab was
|
||||
// near-black text on a near-black background.
|
||||
await expect
|
||||
.poll(async () => luminance(await label.evaluate((el) => getComputedStyle(el).color)))
|
||||
.poll(async () => luminance(await AdminPage.colorOf(admin.activeTabLabel)))
|
||||
.toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
test('the Categories tab follows the dark theme', async ({ page }) => {
|
||||
await seed(page);
|
||||
await switchToDark(page);
|
||||
await page.getByRole('tab', { name: 'Categories' }).click();
|
||||
test('the Categories tab follows the dark theme', async ({ page, admin }) => {
|
||||
await seed(page.request);
|
||||
await admin.switchToDark();
|
||||
await admin.openTab('Categories');
|
||||
|
||||
const tree = page.locator('.ant-tabs-tabpane-active .ant-tree').first();
|
||||
await expect(tree).toBeVisible();
|
||||
const bg = await tree.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
await expect(admin.activeTree).toBeVisible();
|
||||
|
||||
// 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.
|
||||
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 }) => {
|
||||
await seed(page);
|
||||
await switchToDark(page);
|
||||
await page.getByRole('tab', { name: 'Tags' }).click();
|
||||
test('the Tags tab follows the dark theme', async ({ page, admin }) => {
|
||||
await seed(page.request);
|
||||
await admin.switchToDark();
|
||||
await admin.openTab('Tags');
|
||||
|
||||
const table = page.locator('.ant-tabs-tabpane-active .ant-table').first();
|
||||
await expect(table).toBeVisible();
|
||||
const bg = await table.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(luminance(bg)).toBeLessThan(0.5);
|
||||
await expect(admin.activeTable).toBeVisible();
|
||||
expect(luminance(await AdminPage.backgroundOf(admin.activeTable))).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
test('the item form category selector follows the dark theme', async ({ page }) => {
|
||||
await switchToDark(page);
|
||||
await page.getByRole('button', { name: 'Add Item' }).click();
|
||||
await page.getByRole('dialog').getByLabel('Category', { exact: true }).click();
|
||||
test('the item form category selector follows the dark theme', async ({
|
||||
admin,
|
||||
adminInventory
|
||||
}) => {
|
||||
await admin.switchToDark();
|
||||
await adminInventory.openItemForm();
|
||||
await adminInventory.categoryField.click();
|
||||
|
||||
const popup = page.locator('.ant-select-dropdown').first();
|
||||
await expect(popup).toBeVisible();
|
||||
const bg = await popup.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(luminance(bg)).toBeLessThan(0.5);
|
||||
await expect(admin.selectDropdown).toBeVisible();
|
||||
expect(luminance(await AdminPage.backgroundOf(admin.selectDropdown))).toBeLessThan(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Admin copy', () => {
|
||||
test('uses American English spelling for color', async ({ page }) => {
|
||||
await seed(page);
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Tags' }).click();
|
||||
test('uses American English spelling for color', async ({ page, admin }) => {
|
||||
await seed(page.request);
|
||||
await admin.open('Tags');
|
||||
|
||||
await expect(page.getByRole('columnheader', { name: 'Color' })).toBeVisible();
|
||||
await expect(page.getByText('Colour')).toHaveCount(0);
|
||||
|
||||
@@ -1,44 +1,23 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
const suffix = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
import { test, expect, uniqueSuffix } from './fixtures';
|
||||
|
||||
// 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.
|
||||
async function restore(page: import('@playwright/test').Page, key: string) {
|
||||
await page.request.delete(`/api/admin/email-templates/${key}`);
|
||||
async function restore(request: import('@playwright/test').APIRequestContext, key: string) {
|
||||
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
|
||||
// parallel by default — so run concurrently they would race, one asserting a
|
||||
// template is unset while another has just saved it.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Editing the customer emails', () => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
await restore(page, 'passwordReset');
|
||||
test.afterEach(async ({ page, admin, adminEmails }) => {
|
||||
await restore(page.request, 'passwordReset');
|
||||
});
|
||||
|
||||
test('offers every template as a tab, marked default until it is edited', async ({ page }) => {
|
||||
await openEmails(page);
|
||||
test('offers every template as a tab, marked default until it is edited', async ({ page, admin, adminEmails }) => {
|
||||
await admin.open('Emails');
|
||||
|
||||
for (const label of [
|
||||
'Email verification',
|
||||
@@ -48,25 +27,25 @@ test.describe('Editing the customer emails', () => {
|
||||
'Cart reminder',
|
||||
'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
|
||||
// visible without opening each one. An untouched template carries nothing.
|
||||
await expect(page.getByRole('tab', { name: /Password reset/ })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: /Password reset.*Customised/ })).toHaveCount(0);
|
||||
await expect(adminEmails.railTab(/Password reset/)).toBeVisible();
|
||||
await expect(adminEmails.customisedTab('Password reset')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('saves a replacement subject and body', async ({ page }) => {
|
||||
const subject = `Reset ${suffix()}`;
|
||||
await openEmails(page);
|
||||
await openTemplate(page, 'Password reset');
|
||||
test('saves a replacement subject and body', async ({ page, admin, adminEmails }) => {
|
||||
const subject = `Reset ${uniqueSuffix()}`;
|
||||
await admin.open('Emails');
|
||||
await adminEmails.openTemplate('Password reset');
|
||||
|
||||
await page.getByLabel('Password reset subject').fill(subject);
|
||||
await adminEmails.subject('Password reset').fill(subject);
|
||||
await page
|
||||
.getByLabel('Password reset body')
|
||||
.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();
|
||||
|
||||
@@ -79,12 +58,12 @@ test.describe('Editing the customer emails', () => {
|
||||
// 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
|
||||
// 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 }) => {
|
||||
await openEmails(page);
|
||||
await openTemplate(page, 'Password reset');
|
||||
test('refuses a body that drops the required placeholder, and says which', async ({ page, admin, adminEmails }) => {
|
||||
await admin.open('Emails');
|
||||
await adminEmails.openTemplate('Password reset');
|
||||
|
||||
await page.getByLabel('Password reset body').fill('Just click the thing in your email.');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await adminEmails.body('Password reset').fill('Just click the thing in your email.');
|
||||
await adminEmails.saveButton.click();
|
||||
|
||||
await expect(page.getByText('the body must keep {{resetUrl}}')).toBeVisible();
|
||||
|
||||
@@ -94,17 +73,17 @@ test.describe('Editing the customer emails', () => {
|
||||
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', {
|
||||
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
|
||||
// dot — the stored template above was never touched through the UI.
|
||||
await expect(page.getByRole('tab', { name: /Password reset.*Customised/ })).toBeVisible();
|
||||
await openTemplate(page, 'Password reset');
|
||||
await page.getByRole('button', { name: 'Restore default' }).click();
|
||||
await expect(adminEmails.customisedTab('Password reset')).toBeVisible();
|
||||
await adminEmails.openTemplate('Password reset');
|
||||
await adminEmails.restoreDefaultButton.click();
|
||||
|
||||
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.afterEach(async ({ page }) => {
|
||||
await restore(page, 'passwordReset');
|
||||
test.afterEach(async ({ page, admin, adminEmails }) => {
|
||||
await restore(page.request, 'passwordReset');
|
||||
});
|
||||
|
||||
test('shows the draft being edited, not the stored copy', async ({ page }) => {
|
||||
const wording = `Wording ${suffix()}`;
|
||||
await openEmails(page);
|
||||
await openTemplate(page, 'Password reset');
|
||||
test('shows the draft being edited, not the stored copy', async ({ page, admin, adminEmails }) => {
|
||||
const wording = `Wording ${uniqueSuffix()}`;
|
||||
await admin.open('Emails');
|
||||
await adminEmails.openTemplate('Password reset');
|
||||
|
||||
await page
|
||||
.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
|
||||
// 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();
|
||||
expect(stored.find((t: { key: string }) => t.key === 'passwordReset').body).toBeNull();
|
||||
});
|
||||
|
||||
test('substitutes sample values rather than showing raw placeholders', async ({ page }) => {
|
||||
await openEmails(page);
|
||||
await openTemplate(page, 'Password reset');
|
||||
test('substitutes sample values rather than showing raw placeholders', async ({ page, admin, adminEmails }) => {
|
||||
await admin.open('Emails');
|
||||
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.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
|
||||
// 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.
|
||||
test('escapes raw HTML exactly as the mailer does', async ({ page }) => {
|
||||
await openEmails(page);
|
||||
await openTemplate(page, 'Password reset');
|
||||
test('escapes raw HTML exactly as the mailer does', async ({ page, admin, adminEmails }) => {
|
||||
await admin.open('Emails');
|
||||
await adminEmails.openTemplate('Password reset');
|
||||
|
||||
await page
|
||||
.getByLabel('Password reset body')
|
||||
.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
|
||||
// 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 }) => {
|
||||
await openEmails(page);
|
||||
test('includes the consent footer on a favorite template, and not on others', async ({ page, admin, adminEmails }) => {
|
||||
await admin.open('Emails');
|
||||
|
||||
await openTemplate(page, 'Favorited item sold');
|
||||
await expect(previewFrame(page, 'Favorited item sold').getByText(/account page/)).toBeVisible();
|
||||
await adminEmails.openTemplate('Favorited item sold');
|
||||
await expect(adminEmails.preview('Favorited item sold').getByText(/account page/)).toBeVisible();
|
||||
|
||||
await openTemplate(page, 'Password reset');
|
||||
await expect(previewFrame(page, 'Password reset').locator('body')).not.toContainText('account page');
|
||||
await adminEmails.openTemplate('Password reset');
|
||||
await expect(adminEmails.preview('Password reset').locator('body')).not.toContainText('account page');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,10 @@ import { FilterDrawer } from './pages/FilterDrawer';
|
||||
import { FavoritePrompt } from './pages/FavoritePrompt';
|
||||
import { OrdersPage } from './pages/OrdersPage';
|
||||
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';
|
||||
|
||||
// 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 { OrdersPage } from './pages/OrdersPage';
|
||||
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 collectingCoverage = process.env.COVERAGE === 'true';
|
||||
@@ -58,6 +66,10 @@ interface Pages {
|
||||
favoritePrompt: FavoritePrompt;
|
||||
orders: OrdersPage;
|
||||
adminInventory: AdminInventory;
|
||||
adminTaxonomy: AdminTaxonomy;
|
||||
adminCustomers: AdminCustomers;
|
||||
adminEmails: AdminEmails;
|
||||
adminSettings: AdminSettings;
|
||||
}
|
||||
|
||||
interface Data {
|
||||
@@ -123,6 +135,18 @@ export const test = base.extend<Pages & Data & { collectCoverage: void }>({
|
||||
adminInventory: async ({ page }, use) => {
|
||||
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) => {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -13,6 +13,13 @@ export class AdminInventory {
|
||||
readonly price: Locator;
|
||||
readonly category: Locator;
|
||||
readonly saveButton: Locator;
|
||||
/**
|
||||
* The item form is an antd Modal, whose footer button is "OK" rather than
|
||||
* anything named for what it does. Named here so a spec says what it means.
|
||||
*/
|
||||
readonly confirmButton: Locator;
|
||||
readonly formDialog: Locator;
|
||||
readonly tagPicker: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.addItemButton = page.getByRole('button', { name: 'Add Item' });
|
||||
@@ -20,6 +27,9 @@ export class AdminInventory {
|
||||
this.price = page.getByLabel('Price (USD)');
|
||||
this.category = page.getByLabel('Category', { 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 {
|
||||
@@ -53,7 +63,112 @@ export class AdminInventory {
|
||||
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> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,19 +24,38 @@ export class AdminPage {
|
||||
readonly tabList: Locator;
|
||||
readonly activePanel: 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) {
|
||||
this.tabList = page.getByRole('tablist');
|
||||
this.activePanel = page.locator('.ant-tabs-tabpane-active').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> {
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
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
|
||||
// caller's first locator resolves against the outgoing panel.
|
||||
await expect(this.activePanel).toBeVisible();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user