diff --git a/frontend/tests/e2e/account-details.spec.ts b/frontend/tests/e2e/account-details.spec.ts index a30384d..2cd8abf 100644 --- a/frontend/tests/e2e/account-details.spec.ts +++ b/frontend/tests/e2e/account-details.spec.ts @@ -1,77 +1,40 @@ -import { test, expect, Page } from './fixtures'; +import { test, expect, uniqueEmail } from './fixtures'; -const PASSWORD = 'supersecret123'; - -const uniqueEmail = () => `details-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`; - -// Same generous wait as the other account specs: registration is a bcrypt -// round-trip, not a render, and runs past Playwright's 5s default when the -// suite's workers all register at once. -async function registerCustomer(page: Page): Promise { - const email = uniqueEmail(); - 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(); - await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); - return email; -} - -const accountModal = (page: Page) => page.getByRole('dialog', { name: 'My Account' }); - -async function signIn(page: Page, email: string, password: string) { - await page.goto('/login'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - await page.getByLabel('Password').fill(password); - await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click(); -} +const NEW_PASSWORD = 'a-brand-new-password'; test.describe('Managing your own details', () => { - test('saves a new name, and it survives a reload', async ({ page }) => { - await registerCustomer(page); - await page.goto('/account'); - - const modal = accountModal(page); - await modal.getByLabel('First name', { exact: true }).fill('Ada'); - await modal.getByLabel('Last name', { exact: true }).fill('Lovelace'); - await modal.getByRole('button', { name: 'Save name' }).click(); + test('saves a new name, and it survives a reload', async ({ page, customer, accountModal }) => { + await accountModal.open(); + await accountModal.saveName('Ada', 'Lovelace'); await expect(page.getByText('Name updated')).toBeVisible(); // Reloaded rather than re-read from component state, which would pass even // if nothing had been persisted. await page.reload(); - await expect(accountModal(page).getByLabel('First name', { exact: true })).toHaveValue('Ada'); - await expect(accountModal(page).getByLabel('Last name', { exact: true })).toHaveValue('Lovelace'); + await expect(accountModal.firstName).toHaveValue('Ada'); + await expect(accountModal.lastName).toHaveValue('Lovelace'); }); - test('refuses to save a blank name', async ({ page }) => { - await registerCustomer(page); - await page.goto('/account'); + test('refuses to save a blank name', async ({ customer, accountModal }) => { + await accountModal.open(); + await accountModal.saveName(''); - const modal = accountModal(page); - await modal.getByLabel('First name', { exact: true }).fill(''); - await modal.getByRole('button', { name: 'Save name' }).click(); - - await expect(modal.getByText('First name is required')).toBeVisible(); + await expect(accountModal.dialog.getByText('First name is required')).toBeVisible(); }); // The assertion that matters for a password change: not that the form said // something reassuring, but that the old credential has actually stopped // opening the account. - test('changes the password, leaving the old one dead and the new one working', async ({ page }) => { - const email = await registerCustomer(page); - const newPassword = 'a-brand-new-password'; - await page.goto('/account'); - - const modal = accountModal(page); - await modal.getByRole('button', { name: 'Change your password' }).click(); - await modal.getByLabel('Current password', { exact: true }).fill(PASSWORD); - await modal.getByLabel('New password', { exact: true }).fill(newPassword); - await modal.getByLabel('Confirm new password', { exact: true }).fill(newPassword); - await modal.getByRole('button', { name: 'Change password' }).click(); + test('changes the password, leaving the old one dead and the new one working', async ({ + page, + customer, + accountModal, + authModal, + header + }) => { + await accountModal.open(); + await accountModal.changePassword(customer.password, NEW_PASSWORD); await expect(page.getByText('Password changed. Other devices have been signed out.')).toBeVisible(); @@ -80,63 +43,53 @@ test.describe('Managing your own details', () => { // same modal is otherwise also a match. await expect(page.getByRole('button', { name: 'My Account', exact: true })).toBeVisible(); - // Logging out from inside the open modal, which is where the control lives. - await page.getByRole('button', { name: 'Log out' }).click(); + await accountModal.logOut(); await expect(page).toHaveURL(/\/$/, { timeout: 20000 }); - await signIn(page, email, PASSWORD); + await authModal.gotoLogIn(); + await authModal.logIn(customer.email, customer.password); await expect(page.getByText('invalid email or password')).toBeVisible(); - await signIn(page, email, newPassword); - await expect(page.getByRole('button', { name: 'My Account', exact: true })) - .toBeVisible({ timeout: 20000 }); + await authModal.logIn(customer.email, NEW_PASSWORD); + await header.waitForSignedIn(); }); - test('refuses a password change when the current password is wrong', async ({ page }) => { - await registerCustomer(page); - await page.goto('/account'); + test('refuses a password change when the current password is wrong', async ({ + customer, + accountModal + }) => { + await accountModal.open(); + await accountModal.changePassword('not-the-password', 'another-password'); - const modal = accountModal(page); - await modal.getByRole('button', { name: 'Change your password' }).click(); - await modal.getByLabel('Current password', { exact: true }).fill('not-the-password'); - await modal.getByLabel('New password', { exact: true }).fill('another-password'); - await modal.getByLabel('Confirm new password', { exact: true }).fill('another-password'); - await modal.getByRole('button', { name: 'Change password' }).click(); - - await expect(modal.getByText('current password is incorrect')).toBeVisible(); + await expect(accountModal.dialog.getByText('current password is incorrect')).toBeVisible(); }); - test('changes the email address and marks it unverified again', async ({ page }) => { - await registerCustomer(page); + test('changes the email address and marks it unverified again', async ({ + page, + customer, + accountModal + }) => { const nextEmail = uniqueEmail(); - await page.goto('/account'); - - const modal = accountModal(page); - await modal.getByRole('button', { name: 'Change your email address' }).click(); - await modal.getByLabel('New email address', { exact: true }).fill(nextEmail); - await modal.getByLabel('Your password', { exact: true }).fill(PASSWORD); - await modal.getByRole('button', { name: 'Change email' }).click(); + await accountModal.open(); + await accountModal.changeEmail(nextEmail, customer.password); await expect(page.getByText('Check the new address for a verification link.')).toBeVisible(); - await expect(modal).toContainText(nextEmail); + await expect(accountModal.dialog).toContainText(nextEmail); // A changed address is unverified by definition, and the account view has // to say so or the customer has no way to know a link is waiting. - await expect(modal.getByText('Email not verified')).toBeVisible(); + await expect(accountModal.notVerifiedNotice).toBeVisible(); }); // A live session is not enough to move the address a password reset goes to, // which is the whole reason the field is there. - test('refuses an email change when the password is wrong, and keeps the old address', async ({ page }) => { - const email = await registerCustomer(page); - await page.goto('/account'); + test('refuses an email change when the password is wrong, and keeps the old address', async ({ + customer, + accountModal + }) => { + await accountModal.open(); + await accountModal.changeEmail(uniqueEmail(), 'not-the-password'); - const modal = accountModal(page); - await modal.getByRole('button', { name: 'Change your email address' }).click(); - await modal.getByLabel('New email address', { exact: true }).fill(uniqueEmail()); - await modal.getByLabel('Your password', { exact: true }).fill('not-the-password'); - await modal.getByRole('button', { name: 'Change email' }).click(); - - await expect(modal.getByText('current password is incorrect')).toBeVisible(); - await expect(modal).toContainText(email); + await expect(accountModal.dialog.getByText('current password is incorrect')).toBeVisible(); + await expect(accountModal.dialog).toContainText(customer.email); }); }); diff --git a/frontend/tests/e2e/account-modal.spec.ts b/frontend/tests/e2e/account-modal.spec.ts index 0b277cb..34327ba 100644 --- a/frontend/tests/e2e/account-modal.spec.ts +++ b/frontend/tests/e2e/account-modal.spec.ts @@ -1,136 +1,116 @@ -import { test, expect, Page } from './fixtures'; - -const PASSWORD = 'supersecret123'; - -const uniqueEmail = () => `account-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`; - -// Registering closes the auth modal and returns to the storefront, signed in. -// Returns the address so a test can assert the right account is shown. -// -// The wait is generous because registration is a bcrypt round-trip rather than -// a render: about half a second unloaded, and past Playwright's 5s default when -// the suite's workers all register at once. -async function registerCustomer(page: Page): Promise { - const email = uniqueEmail(); - 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(); - await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); - return email; -} - -const accountModal = (page: Page) => page.getByRole('dialog', { name: 'My Account' }); - -async function closeAccount(page: Page) { - await accountModal(page).getByRole('button', { name: 'Close' }).click(); - await expect(accountModal(page)).toBeHidden(); -} +import { test, expect } from './fixtures'; test.describe('My Account opens as a modal', () => { - test('opens over the storefront and closes back to it, filters and all', async ({ page }) => { - await registerCustomer(page); - + test('opens over the storefront and closes back to it, filters and all', async ({ + page, + customer, + accountModal, + header + }) => { // A filtered view, to prove closing restores where the customer actually // was rather than a bare storefront. await page.goto('/?max_price=50000'); - await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible(); + await header.waitForSignedIn(); - await page.getByRole('button', { name: 'My Account' }).click(); - await expect(accountModal(page)).toBeVisible(); + await accountModal.openFromHeader(); // Still a real, linkable URL rather than hidden view state. await expect(page).toHaveURL(/\/account/); - await closeAccount(page); + await accountModal.close(); await expect(page).toHaveURL(/max_price=50000/); }); - test('the browser back button closes it, the same as the close control', async ({ page }) => { - await registerCustomer(page); - + test('the browser back button closes it, the same as the close control', async ({ + page, + customer, + accountModal, + header + }) => { await page.goto('/?max_price=50000'); - await page.getByRole('button', { name: 'My Account' }).click(); - await expect(accountModal(page)).toBeVisible(); + await header.waitForSignedIn(); + await accountModal.openFromHeader(); await page.goBack(); - await expect(accountModal(page)).toBeHidden(); + await expect(accountModal.dialog).toBeHidden(); await expect(page).toHaveURL(/max_price=50000/); }); - test('a direct visit renders the storefront behind it, so closing lands somewhere real', async ({ page }) => { - await registerCustomer(page); - + test('a direct visit renders the storefront behind it, so closing lands somewhere real', async ({ + page, + customer, + accountModal, + header + }) => { // A bookmark, or the link in a verification email. There is no page behind // in this case, which is what used to make /account a dead end. - await page.goto('/account'); + await accountModal.open(); - await expect(accountModal(page)).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible(); + await expect(header.siteTitle).toBeVisible(); - await closeAccount(page); + await accountModal.close(); await expect(page).toHaveURL(/\/$/); - await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible(); + await expect(header.myAccountButton).toBeVisible(); }); - test('survives a reload, since it is a route rather than view state', async ({ page }) => { - const email = await registerCustomer(page); - + test('survives a reload, since it is a route rather than view state', async ({ + page, + customer, + accountModal, + header + }) => { await page.goto('/?max_price=50000'); - await page.getByRole('button', { name: 'My Account' }).click(); - await expect(accountModal(page)).toBeVisible(); + await header.waitForSignedIn(); + await accountModal.openFromHeader(); await page.reload(); - await expect(accountModal(page)).toBeVisible(); - await expect(accountModal(page)).toContainText(email); + await expect(accountModal.dialog).toBeVisible(); + await expect(accountModal.dialog).toContainText(customer.email); }); - test('shows the signed-in account and its settings', async ({ page }) => { - const email = await registerCustomer(page); + test('shows the signed-in account and its settings', async ({ customer, accountModal }) => { + await accountModal.open(); - await page.goto('/account'); - - const modal = accountModal(page); - await expect(modal).toContainText(email); + await expect(accountModal.dialog).toContainText(customer.email); // The orders table lives at /orders now. What the account view still owes // the customer is a way to reach it. - await expect(modal.getByRole('button', { name: 'View order history' })).toBeVisible(); + await expect(accountModal.orderHistoryButton).toBeVisible(); // Scoped to the modal: the storefront behind it has a theme switch of its // own, so an unscoped switch locator would be ambiguous. - await expect(modal.getByRole('switch')).toHaveCount(2); + await expect(accountModal.themeSwitches).toHaveCount(2); }); - test('deleting the account does not leave the page behind it looking signed in', async ({ page }) => { - await registerCustomer(page); - await page.goto('/account'); - - await accountModal(page).getByRole('button', { name: 'Delete my account' }).click(); - await page.getByRole('dialog', { name: 'Delete your account?' }) - .getByRole('button', { name: 'Delete my account' }).click(); + test('deleting the account does not leave the page behind it looking signed in', async ({ + page, + customer, + accountModal, + header + }) => { + await accountModal.open(); + await accountModal.deleteAccount(); // The storefront is rendered behind the modal, so a session left in place // would visibly go on offering My Account for an account that is gone. - await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden(); + await expect(header.signUpButton).toBeVisible(); + await expect(header.myAccountButton).toBeHidden(); await expect(page).toHaveURL(/\/$/); }); - test('stays usable on a phone, with the close control in reach', async ({ page }) => { - await registerCustomer(page); + test('stays usable on a phone, with the close control in reach', async ({ + page, + customer, + accountModal + }) => { await page.setViewportSize({ width: 390, height: 664 }); - await page.goto('/account'); + await accountModal.open(); - const modal = accountModal(page); - await expect(modal).toBeVisible(); // The view is taller than the viewport, so the body scrolls rather than // pushing the title and close control off-screen. - await expect(modal.getByRole('button', { name: 'Close' })).toBeInViewport(); - await expect(modal.getByRole('button', { name: 'View order history' })).toBeVisible(); + await expect(accountModal.closeButton).toBeInViewport(); + await expect(accountModal.orderHistoryButton).toBeVisible(); - await closeAccount(page); + await accountModal.close(); await expect(page).toHaveURL(/\/$/); }); }); diff --git a/frontend/tests/e2e/error-boundary.spec.ts b/frontend/tests/e2e/error-boundary.spec.ts index a0f4a8f..ef540df 100644 --- a/frontend/tests/e2e/error-boundary.spec.ts +++ b/frontend/tests/e2e/error-boundary.spec.ts @@ -12,28 +12,35 @@ test.describe('Error boundaries', () => { await expect(page.getByRole('button', { name: 'Back to the shop' })).toBeVisible(); }); - test('a throw in the item grid leaves the header and theme switch usable', async ({ page }) => { + test('a throw in the item grid leaves the header and theme switch usable', async ({ + page, + storefront, + header + }) => { await page.goto('/?boom=catalogue'); - await expect(page.getByRole('heading', { name: "The item list didn't load" })).toBeVisible(); + await expect(storefront.catalogueBoundaryHeading).toBeVisible(); // The claim this boundary exists to make: a bad item no longer takes // navigation down with it. - await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible(); - await expect(page.getByRole('switch')).toBeVisible(); + await expect(header.siteTitle).toBeVisible(); + await expect(header.themeToggle).toBeVisible(); // And the root boundary did not also fire — only the nearest one should. await expect(page.getByRole('heading', { name: 'Something went wrong' })).toHaveCount(0); }); - test('a throw in the modal block leaves the storefront behind it intact', async ({ page }) => { + test('a throw in the modal block leaves the storefront behind it intact', async ({ + page, + header + }) => { await page.goto('/?boom=modal'); await expect(page.getByRole('heading', { name: "Couldn't open that" })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible(); + await expect(header.siteTitle).toBeVisible(); }); - test('a caught error is reported to the server', async ({ page }) => { + test('a caught error is reported to the server', async ({ page, storefront }) => { const reports: string[] = []; page.on('request', (request) => { if (request.url().includes('/api/client-errors')) { @@ -42,7 +49,7 @@ test.describe('Error boundaries', () => { }); await page.goto('/?boom=catalogue'); - await expect(page.getByRole('heading', { name: "The item list didn't load" })).toBeVisible(); + await expect(storefront.catalogueBoundaryHeading).toBeVisible(); // Observed on the wire rather than trusting that the reporter was called. // diff --git a/frontend/tests/e2e/filters.spec.ts b/frontend/tests/e2e/filters.spec.ts index f497ce1..c9f1a4d 100644 --- a/frontend/tests/e2e/filters.spec.ts +++ b/frontend/tests/e2e/filters.spec.ts @@ -1,4 +1,12 @@ -import { test, expect, APIRequestContext } from './fixtures'; +import { + test, + expect, + createAdminContext, + createCategory, + createTag, + createItem, + uniqueSuffix +} from './fixtures'; // The storefront shows every item ever seeded, and the e2e database is not // reset between runs. Every fixture below is therefore suffixed with a unique @@ -6,7 +14,7 @@ import { test, expect, APIRequestContext } from './fixtures'; // Playwright runs beforeAll once per worker, so the suffix mixes a timestamp // with randomness — two workers starting in the same millisecond would // otherwise seed colliding category names and 409 against each other. -const RUN = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; +const RUN = `f${uniqueSuffix()}`; const NAMES = { furniture: `Furniture ${RUN}`, @@ -20,41 +28,8 @@ const NAMES = { dearItem: `Dear cabinet ${RUN}` }; -async function createCategory(api: APIRequestContext, name: string, parentId: number | null) { - const res = await api.post('/api/admin/categories', { data: { name, parent_id: parentId } }); - expect(res.status()).toBe(201); - return (await res.json()).id as number; -} - -async function createTag(api: APIRequestContext, name: string) { - const res = await api.post('/api/admin/tags', { data: { name } }); - expect(res.status()).toBe(201); - return (await res.json()).id as number; -} - -async function createItem( - api: APIRequestContext, - name: string, - price: string, - categoryId: number | null, - tags: string[] -) { - const res = await api.post('/api/admin/items', { - multipart: { - name, - description: '', - price, - category_id: categoryId === null ? '' : String(categoryId), - tags: JSON.stringify(tags) - } - }); - expect(res.ok()).toBeTruthy(); - // New items are pending; the storefront only lists published ones. - expect((await api.post(`/api/admin/items/${(await res.json()).id}/mark-available`)).ok()).toBeTruthy(); -} - test.beforeAll(async ({ playwright }) => { - const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' }); + const api = await createAdminContext(playwright); // A worker can be handed tests from this file in more than one batch, which // re-runs beforeAll against the module-cached suffix. Seeding twice would @@ -67,130 +42,124 @@ test.beforeAll(async ({ playwright }) => { return; } - const furniture = await createCategory(api, NAMES.furniture, null); + const furniture = await createCategory(api, NAMES.furniture); const tables = await createCategory(api, NAMES.tables, furniture); - const decor = await createCategory(api, NAMES.decor, null); + const decor = await createCategory(api, NAMES.decor); await createTag(api, NAMES.vintage); await createTag(api, NAMES.oak); // Filed one level below the category the tests select, to prove descendant // matching rather than an exact-node match. - await createItem(api, NAMES.deepItem, '340', tables, [NAMES.vintage, NAMES.oak]); - await createItem(api, NAMES.midItem, '120', furniture, [NAMES.vintage]); - await createItem(api, NAMES.otherItem, '90', decor, [NAMES.vintage, NAMES.oak]); - await createItem(api, NAMES.dearItem, '5000', tables, [NAMES.vintage, NAMES.oak]); + await createItem(api, { name: NAMES.deepItem, price: '340', categoryId: tables, tags: [NAMES.vintage, NAMES.oak] }); + await createItem(api, { name: NAMES.midItem, price: '120', categoryId: furniture, tags: [NAMES.vintage] }); + await createItem(api, { name: NAMES.otherItem, price: '90', categoryId: decor, tags: [NAMES.vintage, NAMES.oak] }); + await createItem(api, { name: NAMES.dearItem, price: '5000', categoryId: tables, tags: [NAMES.vintage, NAMES.oak] }); await api.dispose(); }); -function card(page: import('@playwright/test').Page, name: string) { - return page.getByRole('heading', { name }); -} - test.describe('Storefront filters', () => { - test('filters by category, including everything filed beneath it', async ({ page }) => { - await page.goto('/'); - await expect(card(page, NAMES.otherItem)).toBeVisible(); + test('filters by category, including everything filed beneath it', async ({ + storefront, + filterDrawer + }) => { + await storefront.goto(); + await expect(storefront.card(NAMES.otherItem)).toBeVisible(); - await page.getByRole('button', { name: /Filters/ }).click(); - await page.getByRole('treeitem', { name: NAMES.furniture }).click(); + await storefront.openFilters(); + await filterDrawer.chooseCategory(NAMES.furniture); // Both the item filed directly in Furniture and the one nested under // Furniture > Tables must survive. - await expect(card(page, NAMES.midItem)).toBeVisible(); - await expect(card(page, NAMES.deepItem)).toBeVisible(); - await expect(card(page, NAMES.otherItem)).toBeHidden(); + await expect(storefront.card(NAMES.midItem)).toBeVisible(); + await expect(storefront.card(NAMES.deepItem)).toBeVisible(); + await expect(storefront.card(NAMES.otherItem)).toBeHidden(); }); - test('a nested category is reachable in the drawer', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: /Filters/ }).click(); + test('a nested category is reachable in the drawer', async ({ storefront, filterDrawer }) => { + await storefront.goto(); + await storefront.openFilters(); // The tree loads after the drawer mounts, so anything below the roots is // only reachable if expansion tracks the loaded data rather than the state // at mount time. - await page.getByRole('treeitem', { name: NAMES.tables }).click(); + await filterDrawer.chooseCategory(NAMES.tables); - await expect(card(page, NAMES.deepItem)).toBeVisible(); - await expect(card(page, NAMES.midItem)).toBeHidden(); + await expect(storefront.card(NAMES.deepItem)).toBeVisible(); + await expect(storefront.card(NAMES.midItem)).toBeHidden(); }); - test('requires every selected tag rather than any of them', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: /Filters/ }).click(); + test('requires every selected tag rather than any of them', async ({ storefront, filterDrawer }) => { + await storefront.goto(); + await storefront.openFilters(); - await page.getByRole('button', { name: NAMES.vintage }).click(); - await expect(card(page, NAMES.midItem)).toBeVisible(); + await filterDrawer.toggleTag(NAMES.vintage); + await expect(storefront.card(NAMES.midItem)).toBeVisible(); - await page.getByRole('button', { name: NAMES.oak }).click(); + await filterDrawer.toggleTag(NAMES.oak); // midItem carries only `vintage`, so adding `oak` must drop it. - await expect(card(page, NAMES.midItem)).toBeHidden(); - await expect(card(page, NAMES.deepItem)).toBeVisible(); + await expect(storefront.card(NAMES.midItem)).toBeHidden(); + await expect(storefront.card(NAMES.deepItem)).toBeVisible(); }); - test('filters by price range', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: /Filters/ }).click(); + test('filters by price range', async ({ storefront, filterDrawer }) => { + await storefront.goto(); + await storefront.openFilters(); - await page.getByLabel('Minimum price').fill('200'); - await page.getByLabel('Maximum price').fill('1000'); + await filterDrawer.setPriceRange('200', '1000'); - await expect(card(page, NAMES.deepItem)).toBeVisible(); - await expect(card(page, NAMES.midItem)).toBeHidden(); - await expect(card(page, NAMES.dearItem)).toBeHidden(); + await expect(storefront.card(NAMES.deepItem)).toBeVisible(); + await expect(storefront.card(NAMES.midItem)).toBeHidden(); + await expect(storefront.card(NAMES.dearItem)).toBeHidden(); }); - test('removing a chip widens the results again', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: /Filters/ }).click(); - await page.getByRole('treeitem', { name: NAMES.decor }).click(); - await page.getByRole('button', { name: 'Close' }).click(); + test('removing a chip widens the results again', async ({ storefront, filterDrawer }) => { + await storefront.goto(); + await storefront.openFilters(); + await filterDrawer.chooseCategory(NAMES.decor); + await filterDrawer.close(); - await expect(card(page, NAMES.deepItem)).toBeHidden(); + await expect(storefront.card(NAMES.deepItem)).toBeHidden(); - await page.getByRole('button', { name: `Remove filter ${NAMES.decor}` }).click(); - await expect(card(page, NAMES.deepItem)).toBeVisible(); + await storefront.removeFilterChip(NAMES.decor).click(); + await expect(storefront.card(NAMES.deepItem)).toBeVisible(); }); - test('clear all removes every active filter', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: /Filters/ }).click(); - await page.getByRole('treeitem', { name: NAMES.decor }).click(); - await page.getByRole('button', { name: NAMES.vintage }).click(); - await page.getByRole('button', { name: 'Close' }).click(); + test('clear all removes every active filter', async ({ page, storefront, filterDrawer }) => { + await storefront.goto(); + await storefront.openFilters(); + await filterDrawer.chooseCategory(NAMES.decor); + await filterDrawer.toggleTag(NAMES.vintage); + await filterDrawer.close(); - // Scoped to the chip row: the drawer carries a "Clear all" of its own. - await page - .getByRole('group', { name: 'Active filters' }) - .getByRole('button', { name: 'Clear all' }) - .click(); + await storefront.clearAllFilters(); - await expect(card(page, NAMES.deepItem)).toBeVisible(); - await expect(card(page, NAMES.otherItem)).toBeVisible(); + await expect(storefront.card(NAMES.deepItem)).toBeVisible(); + await expect(storefront.card(NAMES.otherItem)).toBeVisible(); await expect(page).toHaveURL(/\/$/); }); - test('a filtered view survives a reload', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: /Filters/ }).click(); - await page.getByRole('treeitem', { name: NAMES.furniture }).click(); - await page.getByRole('button', { name: 'Close' }).click(); + test('a filtered view survives a reload', async ({ page, storefront, filterDrawer }) => { + await storefront.goto(); + await storefront.openFilters(); + await filterDrawer.chooseCategory(NAMES.furniture); + await filterDrawer.close(); await expect(page).toHaveURL(/category=\d+/); await page.reload(); - await expect(card(page, NAMES.deepItem)).toBeVisible(); - await expect(card(page, NAMES.otherItem)).toBeHidden(); - await expect(page.getByRole('button', { name: `Remove filter ${NAMES.furniture}` })).toBeVisible(); + await expect(storefront.card(NAMES.deepItem)).toBeVisible(); + await expect(storefront.card(NAMES.otherItem)).toBeHidden(); + await expect(storefront.removeFilterChip(NAMES.furniture)).toBeVisible(); }); - test('shows an item\'s tags on its card', async ({ page }) => { - await page.goto('/'); - await page.getByRole('button', { name: /Filters/ }).click(); - await page.getByRole('treeitem', { name: NAMES.decor }).click(); - await page.getByRole('button', { name: 'Close' }).click(); + test("shows an item's tags on its card", async ({ storefront, filterDrawer }) => { + await storefront.goto(); + await storefront.openFilters(); + await filterDrawer.chooseCategory(NAMES.decor); + await filterDrawer.close(); - const wallArt = page.locator('.item-card').filter({ hasText: NAMES.otherItem }); + const wallArt = storefront.card(NAMES.otherItem); await expect(wallArt.getByText(NAMES.vintage)).toBeVisible(); await expect(wallArt.getByText(NAMES.oak)).toBeVisible(); }); diff --git a/frontend/tests/e2e/fixtures.ts b/frontend/tests/e2e/fixtures.ts index 653ebed..f1233e1 100644 --- a/frontend/tests/e2e/fixtures.ts +++ b/frontend/tests/e2e/fixtures.ts @@ -8,6 +8,7 @@ import { AccountModal } from './pages/AccountModal'; import { StorefrontPage } from './pages/StorefrontPage'; import { AdminPage } from './pages/AdminPage'; import { PasswordResetPages } from './pages/PasswordResetPages'; +import { FilterDrawer } from './pages/FilterDrawer'; import { uniqueEmail } from './support/api'; // Re-exported so specs can import everything from here — expect, Page, @@ -40,6 +41,7 @@ interface Pages { storefront: StorefrontPage; admin: AdminPage; passwordReset: PasswordResetPages; + filterDrawer: FilterDrawer; } interface Data { @@ -93,6 +95,9 @@ export const test = base.extend({ passwordReset: async ({ page }, use) => { await use(new PasswordResetPages(page)); }, + filterDrawer: async ({ page }, use) => { + await use(new FilterDrawer(page)); + }, adminApi: async ({ playwright, baseURL }, use) => { const context = await playwright.request.newContext({ baseURL }); diff --git a/frontend/tests/e2e/pages/AccountModal.ts b/frontend/tests/e2e/pages/AccountModal.ts index 7a6b1e0..63c8002 100644 --- a/frontend/tests/e2e/pages/AccountModal.ts +++ b/frontend/tests/e2e/pages/AccountModal.ts @@ -4,9 +4,11 @@ import { Locator, Page, expect } from '@playwright/test'; * The customer's own account view, which is a modal over the storefront rather * than a page of its own — /account renders the storefront with this on top. * - * Fields are scoped to the dialog. Several of them ("Password", "First name") - * share a label with the auth modal, and the two can both be in the DOM while - * one is closing. + * Everything is scoped to the dialog. Several controls have a twin on the page + * behind it: the storefront has its own theme switch, and "Password" and "First + * name" share a label with the auth modal, which can be in the DOM while one of + * the two is closing. An unscoped locator matches both and fails on strict mode, + * which is a confusing way to learn that a modal is a modal. */ export class AccountModal { readonly dialog: Locator; @@ -14,17 +16,26 @@ export class AccountModal { readonly orderHistoryButton: Locator; readonly closeButton: Locator; readonly resendVerificationButton: Locator; + readonly themeSwitches: Locator; + readonly notVerifiedNotice: Locator; readonly firstName: Locator; readonly lastName: Locator; readonly saveNameButton: Locator; + readonly changePasswordDisclosure: Locator; readonly currentPassword: Locator; readonly newPassword: Locator; readonly confirmNewPassword: Locator; + readonly submitPasswordChangeButton: Locator; + readonly changeEmailDisclosure: Locator; readonly newEmail: Locator; readonly passwordForEmailChange: Locator; + readonly submitEmailChangeButton: Locator; + + readonly deleteAccountButton: Locator; + readonly confirmDeleteDialog: Locator; constructor(private readonly page: Page) { this.dialog = page.getByRole('dialog', { name: 'My Account' }); @@ -32,17 +43,26 @@ export class AccountModal { this.orderHistoryButton = this.dialog.getByRole('button', { name: 'View order history' }); this.closeButton = this.dialog.getByRole('button', { name: 'Close' }); this.resendVerificationButton = this.dialog.getByRole('button', { name: 'Send it again' }); + this.themeSwitches = this.dialog.getByRole('switch'); + this.notVerifiedNotice = this.dialog.getByText('Email not verified'); this.firstName = this.dialog.getByLabel('First name', { exact: true }); this.lastName = this.dialog.getByLabel('Last name', { exact: true }); this.saveNameButton = this.dialog.getByRole('button', { name: 'Save name' }); + this.changePasswordDisclosure = this.dialog.getByRole('button', { name: 'Change your password' }); this.currentPassword = this.dialog.getByLabel('Current password', { exact: true }); this.newPassword = this.dialog.getByLabel('New password', { exact: true }); this.confirmNewPassword = this.dialog.getByLabel('Confirm new password', { exact: true }); + this.submitPasswordChangeButton = this.dialog.getByRole('button', { name: 'Change password' }); + this.changeEmailDisclosure = this.dialog.getByRole('button', { name: 'Change your email address' }); this.newEmail = this.dialog.getByLabel('New email address', { exact: true }); this.passwordForEmailChange = this.dialog.getByLabel('Your password', { exact: true }); + this.submitEmailChangeButton = this.dialog.getByRole('button', { name: 'Change email' }); + + this.deleteAccountButton = this.dialog.getByRole('button', { name: 'Delete my account' }); + this.confirmDeleteDialog = page.getByRole('dialog', { name: 'Delete your account?' }); } /** @@ -51,10 +71,26 @@ export class AccountModal { * The wait is the action's contract rather than an assertion: everything a * caller does next is scoped to this dialog, and a locator resolved before it * exists finds nothing. + * + * The timeout is generous for the same reason the header's is. Arriving here + * means booting the app and resolving the session against the server, and the + * 5s default is comfortably beaten on an idle machine and missed on a loaded + * one — the recipe for a test that fails only when the suite is busy. */ async open(): Promise { await this.page.goto('/account'); - await expect(this.dialog).toBeVisible(); + await expect(this.dialog).toBeVisible({ timeout: 20000 }); + } + + /** Opens it from the header, from wherever the customer was browsing. */ + async openFromHeader(): Promise { + await this.page.getByRole('button', { name: 'My Account' }).click(); + await expect(this.dialog).toBeVisible({ timeout: 20000 }); + } + + async close(): Promise { + await this.closeButton.click(); + await expect(this.dialog).toBeHidden(); } async logOut(): Promise { @@ -75,6 +111,33 @@ export class AccountModal { await this.logOut(); } + async saveName(firstName: string, lastName?: string): Promise { + await this.firstName.fill(firstName); + if (lastName !== undefined) await this.lastName.fill(lastName); + await this.saveNameButton.click(); + } + + /** Both password fields sit behind a disclosure, so it has to be opened first. */ + async changePassword(current: string, next: string): Promise { + await this.changePasswordDisclosure.click(); + await this.currentPassword.fill(current); + await this.newPassword.fill(next); + await this.confirmNewPassword.fill(next); + await this.submitPasswordChangeButton.click(); + } + + async changeEmail(newEmail: string, password: string): Promise { + await this.changeEmailDisclosure.click(); + await this.newEmail.fill(newEmail); + await this.passwordForEmailChange.fill(password); + await this.submitEmailChangeButton.click(); + } + + async deleteAccount(): Promise { + await this.deleteAccountButton.click(); + await this.confirmDeleteDialog.getByRole('button', { name: 'Delete my account' }).click(); + } + /** The address the account view shows, which is how a test knows whose it is. */ emailText(email: string): Locator { return this.dialog.getByText(email); diff --git a/frontend/tests/e2e/pages/FilterDrawer.ts b/frontend/tests/e2e/pages/FilterDrawer.ts new file mode 100644 index 0000000..193c250 --- /dev/null +++ b/frontend/tests/e2e/pages/FilterDrawer.ts @@ -0,0 +1,51 @@ +import { Locator, Page } from '@playwright/test'; + +/** + * The storefront's filter drawer. + * + * Categories are a tree rather than a list, because a category filter matches + * the node and everything filed beneath it — so the locator is `treeitem`, and + * a nested category is only reachable once the tree has loaded its data. + * + * Tags are buttons that toggle. The rule they follow is AND, not OR: selecting + * two tags means "must have both", which is deliberately different from how + * categories combine and is the thing several tests exist to pin down. + */ +export class FilterDrawer { + readonly minimumPrice: Locator; + readonly maximumPrice: Locator; + readonly closeButton: Locator; + readonly clearAllButton: Locator; + + constructor(private readonly page: Page) { + this.minimumPrice = page.getByLabel('Minimum price'); + this.maximumPrice = page.getByLabel('Maximum price'); + this.closeButton = page.getByRole('button', { name: 'Close' }); + this.clearAllButton = page.getByRole('button', { name: 'Clear all' }); + } + + category(name: string): Locator { + return this.page.getByRole('treeitem', { name }); + } + + tag(name: string): Locator { + return this.page.getByRole('button', { name }); + } + + async chooseCategory(name: string): Promise { + await this.category(name).click(); + } + + async toggleTag(name: string): Promise { + await this.tag(name).click(); + } + + async setPriceRange(minimum?: string, maximum?: string): Promise { + if (minimum !== undefined) await this.minimumPrice.fill(minimum); + if (maximum !== undefined) await this.maximumPrice.fill(maximum); + } + + async close(): Promise { + await this.closeButton.click(); + } +} diff --git a/frontend/tests/e2e/pages/Header.ts b/frontend/tests/e2e/pages/Header.ts index e9c2315..7ed4bf5 100644 --- a/frontend/tests/e2e/pages/Header.ts +++ b/frontend/tests/e2e/pages/Header.ts @@ -15,6 +15,12 @@ export class Header { readonly signUpButton: Locator; readonly cartButton: Locator; readonly themeToggle: Locator; + /** + * Where the theme actually lands. The switch is in the header, and the + * attribute it writes is on — so the control and its observable + * effect are named together rather than a spec knowing about `body`. + */ + readonly themedBody: Locator; constructor(page: Page) { this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' }); @@ -23,6 +29,7 @@ export class Header { this.signUpButton = page.getByRole('button', { name: 'Sign up' }); this.cartButton = page.getByRole('button', { name: /Cart/ }); this.themeToggle = page.getByRole('switch'); + this.themedBody = page.locator('body'); } /** @@ -40,4 +47,9 @@ export class Header { async waitForSignedOut(): Promise { await expect(this.myAccountButton).toHaveCount(0); } + + /** The theme currently applied, as the attribute a stylesheet reads. */ + async currentTheme(): Promise { + return this.themedBody.getAttribute('data-theme'); + } } diff --git a/frontend/tests/e2e/pages/StorefrontPage.ts b/frontend/tests/e2e/pages/StorefrontPage.ts index 01b3fc1..8e5c2f7 100644 --- a/frontend/tests/e2e/pages/StorefrontPage.ts +++ b/frontend/tests/e2e/pages/StorefrontPage.ts @@ -2,7 +2,7 @@ import { Locator, Page, expect } from '@playwright/test'; import { Header } from './Header'; /** - * The public catalogue. + * The public catalogue, and the states it shows instead of one. * * The item card locator is the one place in the suite that knows the storefront * renders items into `.item-card` — three specs reached for that class directly, @@ -13,14 +13,39 @@ import { Header } from './Header'; export class StorefrontPage { readonly header: Header; readonly filtersButton: Locator; + readonly privacyPolicyLink: Locator; + /** + * The chip row summarising what is filtered, which lives on the storefront + * rather than in the drawer. Scoping matters: the drawer carries a "Clear + * all" of its own, so an unscoped one matches both. + */ + readonly activeFilters: Locator; + + /** + * The three things the catalogue can say instead of listing items. Named + * together because the distinction between them is the point: telling a + * customer "No items yet" while the server is broken reads as an empty shop + * and hides the outage, so several tests assert one is showing and another + * is not. + */ + readonly emptyNotice: Locator; + readonly loadFailureNotice: Locator; readonly retryButton: Locator; - readonly loadFailureHeading: Locator; + + /** What the nearest error boundary renders when the grid itself throws. */ + readonly catalogueBoundaryHeading: Locator; constructor(private readonly page: Page) { this.header = new Header(page); this.filtersButton = page.getByRole('button', { name: /Filters/ }); + this.privacyPolicyLink = page.getByRole('link', { name: 'Privacy Policy' }); + this.activeFilters = page.getByRole('group', { name: 'Active filters' }); + + this.emptyNotice = page.getByText('No items yet'); + this.loadFailureNotice = page.getByText("Couldn't load items"); this.retryButton = page.getByRole('button', { name: 'Retry' }); - this.loadFailureHeading = page.getByRole('heading', { name: "The item list didn't load" }); + + this.catalogueBoundaryHeading = page.getByRole('heading', { name: "The item list didn't load" }); } async goto(): Promise { @@ -59,6 +84,14 @@ export class StorefrontPage { await this.filtersButton.click(); } + removeFilterChip(name: string): Locator { + return this.page.getByRole('button', { name: `Remove filter ${name}` }); + } + + async clearAllFilters(): Promise { + await this.activeFilters.getByRole('button', { name: 'Clear all' }).click(); + } + /** * Waits until the catalogue has rendered something. * diff --git a/frontend/tests/e2e/storefront-errors.spec.ts b/frontend/tests/e2e/storefront-errors.spec.ts index 5a6539c..74d69cc 100644 --- a/frontend/tests/e2e/storefront-errors.spec.ts +++ b/frontend/tests/e2e/storefront-errors.spec.ts @@ -1,21 +1,24 @@ import { test, expect } from './fixtures'; test.describe('Storefront failure states', () => { - test('reports a server failure instead of claiming the store is empty', async ({ page }) => { + test('reports a server failure instead of claiming the store is empty', async ({ + page, + storefront + }) => { await page.route('**/api/items*', (route) => route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' }) ); - await page.goto('/'); + await storefront.goto(); // Telling a customer "no items yet" when the server is broken is worse than // saying nothing — it reads as an empty catalogue and hides the outage. - await expect(page.getByText('No items yet')).toBeHidden(); - await expect(page.getByText("Couldn't load items")).toBeVisible(); - await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible(); + await expect(storefront.emptyNotice).toBeHidden(); + await expect(storefront.loadFailureNotice).toBeVisible(); + await expect(storefront.retryButton).toBeVisible(); }); - test('recovers when the server comes back', async ({ page }) => { + test('recovers when the server comes back', async ({ page, storefront }) => { let failing = true; await page.route('**/api/items*', (route) => { if (failing) { @@ -24,22 +27,25 @@ test.describe('Storefront failure states', () => { return route.continue(); }); - await page.goto('/'); - await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible(); + await storefront.goto(); + await expect(storefront.retryButton).toBeVisible(); failing = false; - await page.getByRole('button', { name: 'Retry' }).click(); + await storefront.retryButton.click(); - await expect(page.getByText("Couldn't load items")).toBeHidden(); + await expect(storefront.loadFailureNotice).toBeHidden(); }); - test('a request that never resolves does not render as an empty catalogue', async ({ page }) => { + test('a request that never resolves does not render as an empty catalogue', async ({ + page, + storefront + }) => { // Mirrors the real incident: an un-migrated database left every item query // hanging with no response at all. await page.route('**/api/items*', () => { /* never fulfilled */ }); - await page.goto('/'); + await storefront.goto(); - await expect(page.getByText('No items yet')).toBeHidden(); + await expect(storefront.emptyNotice).toBeHidden(); }); }); diff --git a/frontend/tests/e2e/storefront.spec.ts b/frontend/tests/e2e/storefront.spec.ts index 6f5d4b2..93e0751 100755 --- a/frontend/tests/e2e/storefront.spec.ts +++ b/frontend/tests/e2e/storefront.spec.ts @@ -1,21 +1,21 @@ import { test, expect } from './fixtures'; test.describe('Storefront', () => { - test('loads and shows the site title', async ({ page }) => { - await page.goto('/'); - await expect(page.getByText('Redefined Designs')).toBeVisible(); + test('loads and shows the site title', async ({ storefront }) => { + await storefront.goto(); + await expect(storefront.header.siteTitle).toBeVisible(); }); - test('links to the privacy policy from the footer', async ({ page }) => { - await page.goto('/'); - await page.getByRole('link', { name: 'Privacy Policy' }).click(); + test('links to the privacy policy from the footer', async ({ page, storefront }) => { + await storefront.goto(); + await storefront.privacyPolicyLink.click(); await expect(page).toHaveURL(/\/privacy/); await expect(page.getByText('What we collect')).toBeVisible(); }); - test('offers login and sign up when logged out', async ({ page }) => { - await page.goto('/'); - await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible(); + test('offers login and sign up when logged out', async ({ storefront }) => { + await storefront.goto(); + await expect(storefront.header.logInButton).toBeVisible(); + await expect(storefront.header.signUpButton).toBeVisible(); }); }); diff --git a/frontend/tests/e2e/support/api.ts b/frontend/tests/e2e/support/api.ts index f51174c..282ca15 100644 --- a/frontend/tests/e2e/support/api.ts +++ b/frontend/tests/e2e/support/api.ts @@ -1,5 +1,16 @@ import { APIRequestContext, expect } from '@playwright/test'; +/** + * Where the app under test is served. + * + * Matches playwright.config.ts's baseURL. It exists as a constant because + * `beforeAll` runs with worker-scoped fixtures only and cannot read the + * test-scoped `baseURL` option, so a seeding hook has to name the host itself. + * One spec used to write it inline, which meant changing the port in the config + * moved every test except that one. + */ +export const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173'; + /** * Seeding through the admin API. * @@ -13,6 +24,13 @@ import { APIRequestContext, expect } from '@playwright/test'; * working, so a broken form fails a hundred tests that are not about it. */ +/** An admin API context for `beforeAll`, where the `adminApi` fixture is out of reach. */ +export async function createAdminContext( + playwright: { request: { newContext: (o: { baseURL: string }) => Promise } } +): Promise { + return playwright.request.newContext({ baseURL: BASE_URL }); +} + export interface SeededItem { id: number; name: string; diff --git a/frontend/tests/e2e/theme.spec.ts b/frontend/tests/e2e/theme.spec.ts index 6c43a9c..2b0bd16 100755 --- a/frontend/tests/e2e/theme.spec.ts +++ b/frontend/tests/e2e/theme.spec.ts @@ -1,25 +1,23 @@ import { test, expect } from './fixtures'; test.describe('Theme switching', () => { - test('toggling the switch changes the body theme attribute', async ({ page }) => { - await page.goto('/'); - const body = page.locator('body'); - const initial = await body.getAttribute('data-theme'); + test('toggling the switch changes the body theme attribute', async ({ storefront, header }) => { + await storefront.goto(); + const initial = await header.currentTheme(); - await page.getByRole('switch').click(); + await header.themeToggle.click(); await expect(async () => { - const updated = await body.getAttribute('data-theme'); - expect(updated).not.toBe(initial); + expect(await header.currentTheme()).not.toBe(initial); }).toPass(); }); - test('theme preference persists across a reload', async ({ page }) => { - await page.goto('/'); - await page.getByRole('switch').click(); - const chosen = await page.locator('body').getAttribute('data-theme'); + test('theme preference persists across a reload', async ({ page, storefront, header }) => { + await storefront.goto(); + await header.themeToggle.click(); + const chosen = await header.currentTheme(); await page.reload(); - await expect(page.locator('body')).toHaveAttribute('data-theme', chosen || ''); + await expect(header.themedBody).toHaveAttribute('data-theme', chosen || ''); }); });