From 4b0c01068fec7670c49d937728454dff12ba4667 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Sun, 23 Aug 2026 17:46:45 -0500 Subject: [PATCH 1/2] test(e2e): convert the account specs onto page objects (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit account-modal and account-details, taking two more copies of "register a customer" and two more of the `accountModal(page)` helper that every account-touching spec had rewritten. Both files had grown their own vocabulary for the same dialog. account-details reached for `modal.getByLabel('Current password', { exact: true })` and its five siblings inline in each test, so a change to the account form meant editing six places in one file and more in the next. Those are named locators now, and the three multi-step operations — saving a name, changing a password, changing an email — are actions, because each is a disclosure to open and three fields to fill before the button does anything. AccountModal.open() gains the 20s timeout the header already had, and for the same reason. Arriving at /account means booting the app and resolving the session against the server. The old specs never noticed because they registered through the form first, which loaded the app and confirmed the session before navigating; taking the API-registered `customer` fixture arrives cold, and the 5s default is comfortably beaten on an idle machine and missed on a loaded one. Verified: 18/18 across three serial repeats, and 12/13 in parallel. The one failure is `changes the email address and marks it unverified again`, which fails identically on the unconverted file and passes whenever the suite is not saturated — the same load-related flakiness as the rest of the family, not something this commit introduced. Refs #137 --- frontend/tests/e2e/account-details.spec.ts | 145 +++++++------------- frontend/tests/e2e/account-modal.spec.ts | 146 +++++++++------------ frontend/tests/e2e/pages/AccountModal.ts | 71 +++++++++- 3 files changed, 179 insertions(+), 183 deletions(-) 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/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); -- 2.54.0 From 9b3a03d1bf6bd82b285346235f336f3b3337bcf7 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Sun, 23 Aug 2026 17:50:59 -0500 Subject: [PATCH 2/2] test(e2e): convert the storefront and filter specs onto page objects (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit storefront, storefront-errors, theme, error-boundary and filters. filters.spec.ts carried the last hand-rolled copies of createCategory, createTag and createItem, and the hardcoded `http://localhost:5173` that meant changing the port in the config would have moved every test except this one. Seeding now goes through support/api, and the host it needs lives in one constant. It has to be a constant rather than the config's baseURL: beforeAll runs with worker-scoped fixtures only and cannot read a test-scoped option, which is why the URL was inlined there in the first place. New FilterDrawer object. The two rules it encodes are the ones the tests exist to pin down and neither is guessable from a locator: categories are a tree because the filter matches a node and everything filed beneath it, and tags combine with AND rather than OR, so selecting two means "must have both". The active-filter chips go on StorefrontPage rather than the drawer, because that is where they render — and the scoping matters, since the drawer carries a "Clear all" of its own that an unscoped locator also matches. StorefrontPage gains the three things the catalogue says instead of listing items. They are named together deliberately: the distinction between "No items yet" and "Couldn't load items" is the point, and several tests assert one is showing while the other is not, because telling a customer the shop is empty when the server is broken hides the outage. The theme switch and the attribute it writes are both on Header now. The switch is in the header and `data-theme` lands on , so a spec previously had to know about `body` to observe the control it had just clicked. Verified: 12/12 across the four small specs, 8/8 on filters, tsc clean, lint unchanged at the 30-warning src baseline. Refs #137 --- frontend/tests/e2e/error-boundary.spec.ts | 23 ++- frontend/tests/e2e/filters.spec.ts | 191 ++++++++----------- frontend/tests/e2e/fixtures.ts | 5 + frontend/tests/e2e/pages/FilterDrawer.ts | 51 +++++ frontend/tests/e2e/pages/Header.ts | 12 ++ frontend/tests/e2e/pages/StorefrontPage.ts | 39 +++- frontend/tests/e2e/storefront-errors.spec.ts | 32 ++-- frontend/tests/e2e/storefront.spec.ts | 20 +- frontend/tests/e2e/support/api.ts | 18 ++ frontend/tests/e2e/theme.spec.ts | 22 +-- 10 files changed, 256 insertions(+), 157 deletions(-) create mode 100644 frontend/tests/e2e/pages/FilterDrawer.ts 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/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 || ''); }); }); -- 2.54.0