test(e2e): convert the account specs onto page objects (#137)

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
This commit is contained in:
2026-08-23 17:46:45 -05:00
parent ed679986de
commit 4b0c01068f
3 changed files with 179 additions and 183 deletions
+63 -83
View File
@@ -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<string> {
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(/\/$/);
});
});