Feature/137 convert account specs #151
@@ -1,77 +1,40 @@
|
|||||||
import { test, expect, Page } from './fixtures';
|
import { test, expect, uniqueEmail } from './fixtures';
|
||||||
|
|
||||||
const PASSWORD = 'supersecret123';
|
const NEW_PASSWORD = 'a-brand-new-password';
|
||||||
|
|
||||||
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<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 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Managing your own details', () => {
|
test.describe('Managing your own details', () => {
|
||||||
test('saves a new name, and it survives a reload', async ({ page }) => {
|
test('saves a new name, and it survives a reload', async ({ page, customer, accountModal }) => {
|
||||||
await registerCustomer(page);
|
await accountModal.open();
|
||||||
await page.goto('/account');
|
await accountModal.saveName('Ada', 'Lovelace');
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
await expect(page.getByText('Name updated')).toBeVisible();
|
await expect(page.getByText('Name updated')).toBeVisible();
|
||||||
|
|
||||||
// Reloaded rather than re-read from component state, which would pass even
|
// Reloaded rather than re-read from component state, which would pass even
|
||||||
// if nothing had been persisted.
|
// if nothing had been persisted.
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(accountModal(page).getByLabel('First name', { exact: true })).toHaveValue('Ada');
|
await expect(accountModal.firstName).toHaveValue('Ada');
|
||||||
await expect(accountModal(page).getByLabel('Last name', { exact: true })).toHaveValue('Lovelace');
|
await expect(accountModal.lastName).toHaveValue('Lovelace');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('refuses to save a blank name', async ({ page }) => {
|
test('refuses to save a blank name', async ({ customer, accountModal }) => {
|
||||||
await registerCustomer(page);
|
await accountModal.open();
|
||||||
await page.goto('/account');
|
await accountModal.saveName('');
|
||||||
|
|
||||||
const modal = accountModal(page);
|
await expect(accountModal.dialog.getByText('First name is required')).toBeVisible();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The assertion that matters for a password change: not that the form said
|
// The assertion that matters for a password change: not that the form said
|
||||||
// something reassuring, but that the old credential has actually stopped
|
// something reassuring, but that the old credential has actually stopped
|
||||||
// opening the account.
|
// opening the account.
|
||||||
test('changes the password, leaving the old one dead and the new one working', async ({ page }) => {
|
test('changes the password, leaving the old one dead and the new one working', async ({
|
||||||
const email = await registerCustomer(page);
|
page,
|
||||||
const newPassword = 'a-brand-new-password';
|
customer,
|
||||||
await page.goto('/account');
|
accountModal,
|
||||||
|
authModal,
|
||||||
const modal = accountModal(page);
|
header
|
||||||
await modal.getByRole('button', { name: 'Change your password' }).click();
|
}) => {
|
||||||
await modal.getByLabel('Current password', { exact: true }).fill(PASSWORD);
|
await accountModal.open();
|
||||||
await modal.getByLabel('New password', { exact: true }).fill(newPassword);
|
await accountModal.changePassword(customer.password, NEW_PASSWORD);
|
||||||
await modal.getByLabel('Confirm new password', { exact: true }).fill(newPassword);
|
|
||||||
await modal.getByRole('button', { name: 'Change password' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText('Password changed. Other devices have been signed out.')).toBeVisible();
|
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.
|
// same modal is otherwise also a match.
|
||||||
await expect(page.getByRole('button', { name: 'My Account', exact: true })).toBeVisible();
|
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 accountModal.logOut();
|
||||||
await page.getByRole('button', { name: 'Log out' }).click();
|
|
||||||
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
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 expect(page.getByText('invalid email or password')).toBeVisible();
|
||||||
|
|
||||||
await signIn(page, email, newPassword);
|
await authModal.logIn(customer.email, NEW_PASSWORD);
|
||||||
await expect(page.getByRole('button', { name: 'My Account', exact: true }))
|
await header.waitForSignedIn();
|
||||||
.toBeVisible({ timeout: 20000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('refuses a password change when the current password is wrong', async ({ page }) => {
|
test('refuses a password change when the current password is wrong', async ({
|
||||||
await registerCustomer(page);
|
customer,
|
||||||
await page.goto('/account');
|
accountModal
|
||||||
|
}) => {
|
||||||
|
await accountModal.open();
|
||||||
|
await accountModal.changePassword('not-the-password', 'another-password');
|
||||||
|
|
||||||
const modal = accountModal(page);
|
await expect(accountModal.dialog.getByText('current password is incorrect')).toBeVisible();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('changes the email address and marks it unverified again', async ({ page }) => {
|
test('changes the email address and marks it unverified again', async ({
|
||||||
await registerCustomer(page);
|
page,
|
||||||
|
customer,
|
||||||
|
accountModal
|
||||||
|
}) => {
|
||||||
const nextEmail = uniqueEmail();
|
const nextEmail = uniqueEmail();
|
||||||
await page.goto('/account');
|
await accountModal.open();
|
||||||
|
await accountModal.changeEmail(nextEmail, customer.password);
|
||||||
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 expect(page.getByText('Check the new address for a verification link.')).toBeVisible();
|
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
|
// 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.
|
// 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,
|
// A live session is not enough to move the address a password reset goes to,
|
||||||
// which is the whole reason the field is there.
|
// 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 }) => {
|
test('refuses an email change when the password is wrong, and keeps the old address', async ({
|
||||||
const email = await registerCustomer(page);
|
customer,
|
||||||
await page.goto('/account');
|
accountModal
|
||||||
|
}) => {
|
||||||
|
await accountModal.open();
|
||||||
|
await accountModal.changeEmail(uniqueEmail(), 'not-the-password');
|
||||||
|
|
||||||
const modal = accountModal(page);
|
await expect(accountModal.dialog.getByText('current password is incorrect')).toBeVisible();
|
||||||
await modal.getByRole('button', { name: 'Change your email address' }).click();
|
await expect(accountModal.dialog).toContainText(customer.email);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,136 +1,116 @@
|
|||||||
import { test, expect, Page } from './fixtures';
|
import { test, expect } 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('My Account opens as a modal', () => {
|
test.describe('My Account opens as a modal', () => {
|
||||||
test('opens over the storefront and closes back to it, filters and all', async ({ page }) => {
|
test('opens over the storefront and closes back to it, filters and all', async ({
|
||||||
await registerCustomer(page);
|
page,
|
||||||
|
customer,
|
||||||
|
accountModal,
|
||||||
|
header
|
||||||
|
}) => {
|
||||||
// A filtered view, to prove closing restores where the customer actually
|
// A filtered view, to prove closing restores where the customer actually
|
||||||
// was rather than a bare storefront.
|
// was rather than a bare storefront.
|
||||||
await page.goto('/?max_price=50000');
|
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 accountModal.openFromHeader();
|
||||||
await expect(accountModal(page)).toBeVisible();
|
|
||||||
// Still a real, linkable URL rather than hidden view state.
|
// Still a real, linkable URL rather than hidden view state.
|
||||||
await expect(page).toHaveURL(/\/account/);
|
await expect(page).toHaveURL(/\/account/);
|
||||||
|
|
||||||
await closeAccount(page);
|
await accountModal.close();
|
||||||
await expect(page).toHaveURL(/max_price=50000/);
|
await expect(page).toHaveURL(/max_price=50000/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the browser back button closes it, the same as the close control', async ({ page }) => {
|
test('the browser back button closes it, the same as the close control', async ({
|
||||||
await registerCustomer(page);
|
page,
|
||||||
|
customer,
|
||||||
|
accountModal,
|
||||||
|
header
|
||||||
|
}) => {
|
||||||
await page.goto('/?max_price=50000');
|
await page.goto('/?max_price=50000');
|
||||||
await page.getByRole('button', { name: 'My Account' }).click();
|
await header.waitForSignedIn();
|
||||||
await expect(accountModal(page)).toBeVisible();
|
await accountModal.openFromHeader();
|
||||||
|
|
||||||
await page.goBack();
|
await page.goBack();
|
||||||
|
|
||||||
await expect(accountModal(page)).toBeHidden();
|
await expect(accountModal.dialog).toBeHidden();
|
||||||
await expect(page).toHaveURL(/max_price=50000/);
|
await expect(page).toHaveURL(/max_price=50000/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a direct visit renders the storefront behind it, so closing lands somewhere real', async ({ page }) => {
|
test('a direct visit renders the storefront behind it, so closing lands somewhere real', async ({
|
||||||
await registerCustomer(page);
|
page,
|
||||||
|
customer,
|
||||||
|
accountModal,
|
||||||
|
header
|
||||||
|
}) => {
|
||||||
// A bookmark, or the link in a verification email. There is no page behind
|
// 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.
|
// 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(header.siteTitle).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
|
|
||||||
|
|
||||||
await closeAccount(page);
|
await accountModal.close();
|
||||||
await expect(page).toHaveURL(/\/$/);
|
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 }) => {
|
test('survives a reload, since it is a route rather than view state', async ({
|
||||||
const email = await registerCustomer(page);
|
page,
|
||||||
|
customer,
|
||||||
|
accountModal,
|
||||||
|
header
|
||||||
|
}) => {
|
||||||
await page.goto('/?max_price=50000');
|
await page.goto('/?max_price=50000');
|
||||||
await page.getByRole('button', { name: 'My Account' }).click();
|
await header.waitForSignedIn();
|
||||||
await expect(accountModal(page)).toBeVisible();
|
await accountModal.openFromHeader();
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await expect(accountModal(page)).toBeVisible();
|
await expect(accountModal.dialog).toBeVisible();
|
||||||
await expect(accountModal(page)).toContainText(email);
|
await expect(accountModal.dialog).toContainText(customer.email);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows the signed-in account and its settings', async ({ page }) => {
|
test('shows the signed-in account and its settings', async ({ customer, accountModal }) => {
|
||||||
const email = await registerCustomer(page);
|
await accountModal.open();
|
||||||
|
|
||||||
await page.goto('/account');
|
await expect(accountModal.dialog).toContainText(customer.email);
|
||||||
|
|
||||||
const modal = accountModal(page);
|
|
||||||
await expect(modal).toContainText(email);
|
|
||||||
// The orders table lives at /orders now. What the account view still owes
|
// The orders table lives at /orders now. What the account view still owes
|
||||||
// the customer is a way to reach it.
|
// 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
|
// Scoped to the modal: the storefront behind it has a theme switch of its
|
||||||
// own, so an unscoped switch locator would be ambiguous.
|
// 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 }) => {
|
test('deleting the account does not leave the page behind it looking signed in', async ({
|
||||||
await registerCustomer(page);
|
page,
|
||||||
await page.goto('/account');
|
customer,
|
||||||
|
accountModal,
|
||||||
await accountModal(page).getByRole('button', { name: 'Delete my account' }).click();
|
header
|
||||||
await page.getByRole('dialog', { name: 'Delete your account?' })
|
}) => {
|
||||||
.getByRole('button', { name: 'Delete my account' }).click();
|
await accountModal.open();
|
||||||
|
await accountModal.deleteAccount();
|
||||||
|
|
||||||
// The storefront is rendered behind the modal, so a session left in place
|
// 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.
|
// would visibly go on offering My Account for an account that is gone.
|
||||||
await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible();
|
await expect(header.signUpButton).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden();
|
await expect(header.myAccountButton).toBeHidden();
|
||||||
await expect(page).toHaveURL(/\/$/);
|
await expect(page).toHaveURL(/\/$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stays usable on a phone, with the close control in reach', async ({ page }) => {
|
test('stays usable on a phone, with the close control in reach', async ({
|
||||||
await registerCustomer(page);
|
page,
|
||||||
|
customer,
|
||||||
|
accountModal
|
||||||
|
}) => {
|
||||||
await page.setViewportSize({ width: 390, height: 664 });
|
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
|
// The view is taller than the viewport, so the body scrolls rather than
|
||||||
// pushing the title and close control off-screen.
|
// pushing the title and close control off-screen.
|
||||||
await expect(modal.getByRole('button', { name: 'Close' })).toBeInViewport();
|
await expect(accountModal.closeButton).toBeInViewport();
|
||||||
await expect(modal.getByRole('button', { name: 'View order history' })).toBeVisible();
|
await expect(accountModal.orderHistoryButton).toBeVisible();
|
||||||
|
|
||||||
await closeAccount(page);
|
await accountModal.close();
|
||||||
await expect(page).toHaveURL(/\/$/);
|
await expect(page).toHaveURL(/\/$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,28 +12,35 @@ test.describe('Error boundaries', () => {
|
|||||||
await expect(page.getByRole('button', { name: 'Back to the shop' })).toBeVisible();
|
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 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
|
// The claim this boundary exists to make: a bad item no longer takes
|
||||||
// navigation down with it.
|
// navigation down with it.
|
||||||
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
|
await expect(header.siteTitle).toBeVisible();
|
||||||
await expect(page.getByRole('switch')).toBeVisible();
|
await expect(header.themeToggle).toBeVisible();
|
||||||
|
|
||||||
// And the root boundary did not also fire — only the nearest one should.
|
// And the root boundary did not also fire — only the nearest one should.
|
||||||
await expect(page.getByRole('heading', { name: 'Something went wrong' })).toHaveCount(0);
|
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 page.goto('/?boom=modal');
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: "Couldn't open that" })).toBeVisible();
|
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[] = [];
|
const reports: string[] = [];
|
||||||
page.on('request', (request) => {
|
page.on('request', (request) => {
|
||||||
if (request.url().includes('/api/client-errors')) {
|
if (request.url().includes('/api/client-errors')) {
|
||||||
@@ -42,7 +49,7 @@ test.describe('Error boundaries', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/?boom=catalogue');
|
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.
|
// Observed on the wire rather than trusting that the reporter was called.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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
|
// Playwright runs beforeAll once per worker, so the suffix mixes a timestamp
|
||||||
// with randomness — two workers starting in the same millisecond would
|
// with randomness — two workers starting in the same millisecond would
|
||||||
// otherwise seed colliding category names and 409 against each other.
|
// 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 = {
|
const NAMES = {
|
||||||
furniture: `Furniture ${RUN}`,
|
furniture: `Furniture ${RUN}`,
|
||||||
@@ -20,41 +28,8 @@ const NAMES = {
|
|||||||
dearItem: `Dear cabinet ${RUN}`
|
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 }) => {
|
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
|
// 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
|
// re-runs beforeAll against the module-cached suffix. Seeding twice would
|
||||||
@@ -67,130 +42,124 @@ test.beforeAll(async ({ playwright }) => {
|
|||||||
return;
|
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 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.vintage);
|
||||||
await createTag(api, NAMES.oak);
|
await createTag(api, NAMES.oak);
|
||||||
|
|
||||||
// Filed one level below the category the tests select, to prove descendant
|
// Filed one level below the category the tests select, to prove descendant
|
||||||
// matching rather than an exact-node match.
|
// matching rather than an exact-node match.
|
||||||
await createItem(api, NAMES.deepItem, '340', tables, [NAMES.vintage, NAMES.oak]);
|
await createItem(api, { name: NAMES.deepItem, price: '340', categoryId: tables, tags: [NAMES.vintage, NAMES.oak] });
|
||||||
await createItem(api, NAMES.midItem, '120', furniture, [NAMES.vintage]);
|
await createItem(api, { name: NAMES.midItem, price: '120', categoryId: furniture, tags: [NAMES.vintage] });
|
||||||
await createItem(api, NAMES.otherItem, '90', decor, [NAMES.vintage, NAMES.oak]);
|
await createItem(api, { name: NAMES.otherItem, price: '90', categoryId: decor, tags: [NAMES.vintage, NAMES.oak] });
|
||||||
await createItem(api, NAMES.dearItem, '5000', tables, [NAMES.vintage, NAMES.oak]);
|
await createItem(api, { name: NAMES.dearItem, price: '5000', categoryId: tables, tags: [NAMES.vintage, NAMES.oak] });
|
||||||
|
|
||||||
await api.dispose();
|
await api.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
function card(page: import('@playwright/test').Page, name: string) {
|
|
||||||
return page.getByRole('heading', { name });
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Storefront filters', () => {
|
test.describe('Storefront filters', () => {
|
||||||
test('filters by category, including everything filed beneath it', async ({ page }) => {
|
test('filters by category, including everything filed beneath it', async ({
|
||||||
await page.goto('/');
|
storefront,
|
||||||
await expect(card(page, NAMES.otherItem)).toBeVisible();
|
filterDrawer
|
||||||
|
}) => {
|
||||||
|
await storefront.goto();
|
||||||
|
await expect(storefront.card(NAMES.otherItem)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
await page.getByRole('treeitem', { name: NAMES.furniture }).click();
|
await filterDrawer.chooseCategory(NAMES.furniture);
|
||||||
|
|
||||||
// Both the item filed directly in Furniture and the one nested under
|
// Both the item filed directly in Furniture and the one nested under
|
||||||
// Furniture > Tables must survive.
|
// Furniture > Tables must survive.
|
||||||
await expect(card(page, NAMES.midItem)).toBeVisible();
|
await expect(storefront.card(NAMES.midItem)).toBeVisible();
|
||||||
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
await expect(card(page, NAMES.otherItem)).toBeHidden();
|
await expect(storefront.card(NAMES.otherItem)).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a nested category is reachable in the drawer', async ({ page }) => {
|
test('a nested category is reachable in the drawer', async ({ storefront, filterDrawer }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
|
|
||||||
// The tree loads after the drawer mounts, so anything below the roots is
|
// 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
|
// only reachable if expansion tracks the loaded data rather than the state
|
||||||
// at mount time.
|
// 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(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
await expect(card(page, NAMES.midItem)).toBeHidden();
|
await expect(storefront.card(NAMES.midItem)).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('requires every selected tag rather than any of them', async ({ page }) => {
|
test('requires every selected tag rather than any of them', async ({ storefront, filterDrawer }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
|
|
||||||
await page.getByRole('button', { name: NAMES.vintage }).click();
|
await filterDrawer.toggleTag(NAMES.vintage);
|
||||||
await expect(card(page, NAMES.midItem)).toBeVisible();
|
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.
|
// midItem carries only `vintage`, so adding `oak` must drop it.
|
||||||
await expect(card(page, NAMES.midItem)).toBeHidden();
|
await expect(storefront.card(NAMES.midItem)).toBeHidden();
|
||||||
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('filters by price range', async ({ page }) => {
|
test('filters by price range', async ({ storefront, filterDrawer }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
|
|
||||||
await page.getByLabel('Minimum price').fill('200');
|
await filterDrawer.setPriceRange('200', '1000');
|
||||||
await page.getByLabel('Maximum price').fill('1000');
|
|
||||||
|
|
||||||
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
await expect(card(page, NAMES.midItem)).toBeHidden();
|
await expect(storefront.card(NAMES.midItem)).toBeHidden();
|
||||||
await expect(card(page, NAMES.dearItem)).toBeHidden();
|
await expect(storefront.card(NAMES.dearItem)).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('removing a chip widens the results again', async ({ page }) => {
|
test('removing a chip widens the results again', async ({ storefront, filterDrawer }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
await page.getByRole('treeitem', { name: NAMES.decor }).click();
|
await filterDrawer.chooseCategory(NAMES.decor);
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
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 storefront.removeFilterChip(NAMES.decor).click();
|
||||||
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clear all removes every active filter', async ({ page }) => {
|
test('clear all removes every active filter', async ({ page, storefront, filterDrawer }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
await page.getByRole('treeitem', { name: NAMES.decor }).click();
|
await filterDrawer.chooseCategory(NAMES.decor);
|
||||||
await page.getByRole('button', { name: NAMES.vintage }).click();
|
await filterDrawer.toggleTag(NAMES.vintage);
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
await filterDrawer.close();
|
||||||
|
|
||||||
// Scoped to the chip row: the drawer carries a "Clear all" of its own.
|
await storefront.clearAllFilters();
|
||||||
await page
|
|
||||||
.getByRole('group', { name: 'Active filters' })
|
|
||||||
.getByRole('button', { name: 'Clear all' })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
await expect(card(page, NAMES.otherItem)).toBeVisible();
|
await expect(storefront.card(NAMES.otherItem)).toBeVisible();
|
||||||
await expect(page).toHaveURL(/\/$/);
|
await expect(page).toHaveURL(/\/$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a filtered view survives a reload', async ({ page }) => {
|
test('a filtered view survives a reload', async ({ page, storefront, filterDrawer }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
await page.getByRole('treeitem', { name: NAMES.furniture }).click();
|
await filterDrawer.chooseCategory(NAMES.furniture);
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
await filterDrawer.close();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/category=\d+/);
|
await expect(page).toHaveURL(/category=\d+/);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
await expect(storefront.card(NAMES.deepItem)).toBeVisible();
|
||||||
await expect(card(page, NAMES.otherItem)).toBeHidden();
|
await expect(storefront.card(NAMES.otherItem)).toBeHidden();
|
||||||
await expect(page.getByRole('button', { name: `Remove filter ${NAMES.furniture}` })).toBeVisible();
|
await expect(storefront.removeFilterChip(NAMES.furniture)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows an item\'s tags on its card', async ({ page }) => {
|
test("shows an item's tags on its card", async ({ storefront, filterDrawer }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('button', { name: /Filters/ }).click();
|
await storefront.openFilters();
|
||||||
await page.getByRole('treeitem', { name: NAMES.decor }).click();
|
await filterDrawer.chooseCategory(NAMES.decor);
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
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.vintage)).toBeVisible();
|
||||||
await expect(wallArt.getByText(NAMES.oak)).toBeVisible();
|
await expect(wallArt.getByText(NAMES.oak)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AccountModal } from './pages/AccountModal';
|
|||||||
import { StorefrontPage } from './pages/StorefrontPage';
|
import { StorefrontPage } from './pages/StorefrontPage';
|
||||||
import { AdminPage } from './pages/AdminPage';
|
import { AdminPage } from './pages/AdminPage';
|
||||||
import { PasswordResetPages } from './pages/PasswordResetPages';
|
import { PasswordResetPages } from './pages/PasswordResetPages';
|
||||||
|
import { FilterDrawer } from './pages/FilterDrawer';
|
||||||
import { uniqueEmail } from './support/api';
|
import { uniqueEmail } from './support/api';
|
||||||
|
|
||||||
// Re-exported so specs can import everything from here — expect, Page,
|
// Re-exported so specs can import everything from here — expect, Page,
|
||||||
@@ -40,6 +41,7 @@ interface Pages {
|
|||||||
storefront: StorefrontPage;
|
storefront: StorefrontPage;
|
||||||
admin: AdminPage;
|
admin: AdminPage;
|
||||||
passwordReset: PasswordResetPages;
|
passwordReset: PasswordResetPages;
|
||||||
|
filterDrawer: FilterDrawer;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
@@ -93,6 +95,9 @@ export const test = base.extend<Pages & Data & { collectCoverage: void }>({
|
|||||||
passwordReset: async ({ page }, use) => {
|
passwordReset: async ({ page }, use) => {
|
||||||
await use(new PasswordResetPages(page));
|
await use(new PasswordResetPages(page));
|
||||||
},
|
},
|
||||||
|
filterDrawer: async ({ page }, use) => {
|
||||||
|
await use(new FilterDrawer(page));
|
||||||
|
},
|
||||||
|
|
||||||
adminApi: async ({ playwright, baseURL }, use) => {
|
adminApi: async ({ playwright, baseURL }, use) => {
|
||||||
const context = await playwright.request.newContext({ baseURL });
|
const context = await playwright.request.newContext({ baseURL });
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* 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")
|
* Everything is scoped to the dialog. Several controls have a twin on the page
|
||||||
* share a label with the auth modal, and the two can both be in the DOM while
|
* behind it: the storefront has its own theme switch, and "Password" and "First
|
||||||
* one is closing.
|
* 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 {
|
export class AccountModal {
|
||||||
readonly dialog: Locator;
|
readonly dialog: Locator;
|
||||||
@@ -14,17 +16,26 @@ export class AccountModal {
|
|||||||
readonly orderHistoryButton: Locator;
|
readonly orderHistoryButton: Locator;
|
||||||
readonly closeButton: Locator;
|
readonly closeButton: Locator;
|
||||||
readonly resendVerificationButton: Locator;
|
readonly resendVerificationButton: Locator;
|
||||||
|
readonly themeSwitches: Locator;
|
||||||
|
readonly notVerifiedNotice: Locator;
|
||||||
|
|
||||||
readonly firstName: Locator;
|
readonly firstName: Locator;
|
||||||
readonly lastName: Locator;
|
readonly lastName: Locator;
|
||||||
readonly saveNameButton: Locator;
|
readonly saveNameButton: Locator;
|
||||||
|
|
||||||
|
readonly changePasswordDisclosure: Locator;
|
||||||
readonly currentPassword: Locator;
|
readonly currentPassword: Locator;
|
||||||
readonly newPassword: Locator;
|
readonly newPassword: Locator;
|
||||||
readonly confirmNewPassword: Locator;
|
readonly confirmNewPassword: Locator;
|
||||||
|
readonly submitPasswordChangeButton: Locator;
|
||||||
|
|
||||||
|
readonly changeEmailDisclosure: Locator;
|
||||||
readonly newEmail: Locator;
|
readonly newEmail: Locator;
|
||||||
readonly passwordForEmailChange: Locator;
|
readonly passwordForEmailChange: Locator;
|
||||||
|
readonly submitEmailChangeButton: Locator;
|
||||||
|
|
||||||
|
readonly deleteAccountButton: Locator;
|
||||||
|
readonly confirmDeleteDialog: Locator;
|
||||||
|
|
||||||
constructor(private readonly page: Page) {
|
constructor(private readonly page: Page) {
|
||||||
this.dialog = page.getByRole('dialog', { name: 'My Account' });
|
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.orderHistoryButton = this.dialog.getByRole('button', { name: 'View order history' });
|
||||||
this.closeButton = this.dialog.getByRole('button', { name: 'Close' });
|
this.closeButton = this.dialog.getByRole('button', { name: 'Close' });
|
||||||
this.resendVerificationButton = this.dialog.getByRole('button', { name: 'Send it again' });
|
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.firstName = this.dialog.getByLabel('First name', { exact: true });
|
||||||
this.lastName = this.dialog.getByLabel('Last name', { exact: true });
|
this.lastName = this.dialog.getByLabel('Last name', { exact: true });
|
||||||
this.saveNameButton = this.dialog.getByRole('button', { name: 'Save name' });
|
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.currentPassword = this.dialog.getByLabel('Current password', { exact: true });
|
||||||
this.newPassword = this.dialog.getByLabel('New password', { exact: true });
|
this.newPassword = this.dialog.getByLabel('New password', { exact: true });
|
||||||
this.confirmNewPassword = this.dialog.getByLabel('Confirm 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.newEmail = this.dialog.getByLabel('New email address', { exact: true });
|
||||||
this.passwordForEmailChange = this.dialog.getByLabel('Your password', { 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
|
* 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
|
* caller does next is scoped to this dialog, and a locator resolved before it
|
||||||
* exists finds nothing.
|
* 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<void> {
|
async open(): Promise<void> {
|
||||||
await this.page.goto('/account');
|
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<void> {
|
||||||
|
await this.page.getByRole('button', { name: 'My Account' }).click();
|
||||||
|
await expect(this.dialog).toBeVisible({ timeout: 20000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.closeButton.click();
|
||||||
|
await expect(this.dialog).toBeHidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
async logOut(): Promise<void> {
|
async logOut(): Promise<void> {
|
||||||
@@ -75,6 +111,33 @@ export class AccountModal {
|
|||||||
await this.logOut();
|
await this.logOut();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async saveName(firstName: string, lastName?: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.changeEmailDisclosure.click();
|
||||||
|
await this.newEmail.fill(newEmail);
|
||||||
|
await this.passwordForEmailChange.fill(password);
|
||||||
|
await this.submitEmailChangeButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccount(): Promise<void> {
|
||||||
|
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. */
|
/** The address the account view shows, which is how a test knows whose it is. */
|
||||||
emailText(email: string): Locator {
|
emailText(email: string): Locator {
|
||||||
return this.dialog.getByText(email);
|
return this.dialog.getByText(email);
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
await this.category(name).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleTag(name: string): Promise<void> {
|
||||||
|
await this.tag(name).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPriceRange(minimum?: string, maximum?: string): Promise<void> {
|
||||||
|
if (minimum !== undefined) await this.minimumPrice.fill(minimum);
|
||||||
|
if (maximum !== undefined) await this.maximumPrice.fill(maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.closeButton.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ export class Header {
|
|||||||
readonly signUpButton: Locator;
|
readonly signUpButton: Locator;
|
||||||
readonly cartButton: Locator;
|
readonly cartButton: Locator;
|
||||||
readonly themeToggle: Locator;
|
readonly themeToggle: Locator;
|
||||||
|
/**
|
||||||
|
* Where the theme actually lands. The switch is in the header, and the
|
||||||
|
* attribute it writes is on <body> — so the control and its observable
|
||||||
|
* effect are named together rather than a spec knowing about `body`.
|
||||||
|
*/
|
||||||
|
readonly themedBody: Locator;
|
||||||
|
|
||||||
constructor(page: Page) {
|
constructor(page: Page) {
|
||||||
this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' });
|
this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' });
|
||||||
@@ -23,6 +29,7 @@ export class Header {
|
|||||||
this.signUpButton = page.getByRole('button', { name: 'Sign up' });
|
this.signUpButton = page.getByRole('button', { name: 'Sign up' });
|
||||||
this.cartButton = page.getByRole('button', { name: /Cart/ });
|
this.cartButton = page.getByRole('button', { name: /Cart/ });
|
||||||
this.themeToggle = page.getByRole('switch');
|
this.themeToggle = page.getByRole('switch');
|
||||||
|
this.themedBody = page.locator('body');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,4 +47,9 @@ export class Header {
|
|||||||
async waitForSignedOut(): Promise<void> {
|
async waitForSignedOut(): Promise<void> {
|
||||||
await expect(this.myAccountButton).toHaveCount(0);
|
await expect(this.myAccountButton).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The theme currently applied, as the attribute a stylesheet reads. */
|
||||||
|
async currentTheme(): Promise<string | null> {
|
||||||
|
return this.themedBody.getAttribute('data-theme');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Locator, Page, expect } from '@playwright/test';
|
|||||||
import { Header } from './Header';
|
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
|
* 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,
|
* renders items into `.item-card` — three specs reached for that class directly,
|
||||||
@@ -13,14 +13,39 @@ import { Header } from './Header';
|
|||||||
export class StorefrontPage {
|
export class StorefrontPage {
|
||||||
readonly header: Header;
|
readonly header: Header;
|
||||||
readonly filtersButton: Locator;
|
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 retryButton: Locator;
|
||||||
readonly loadFailureHeading: Locator;
|
|
||||||
|
/** What the nearest error boundary renders when the grid itself throws. */
|
||||||
|
readonly catalogueBoundaryHeading: Locator;
|
||||||
|
|
||||||
constructor(private readonly page: Page) {
|
constructor(private readonly page: Page) {
|
||||||
this.header = new Header(page);
|
this.header = new Header(page);
|
||||||
this.filtersButton = page.getByRole('button', { name: /Filters/ });
|
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.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<void> {
|
async goto(): Promise<void> {
|
||||||
@@ -59,6 +84,14 @@ export class StorefrontPage {
|
|||||||
await this.filtersButton.click();
|
await this.filtersButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeFilterChip(name: string): Locator {
|
||||||
|
return this.page.getByRole('button', { name: `Remove filter ${name}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAllFilters(): Promise<void> {
|
||||||
|
await this.activeFilters.getByRole('button', { name: 'Clear all' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Waits until the catalogue has rendered something.
|
* Waits until the catalogue has rendered something.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
test.describe('Storefront failure states', () => {
|
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) =>
|
await page.route('**/api/items*', (route) =>
|
||||||
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' })
|
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
|
// 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.
|
// saying nothing — it reads as an empty catalogue and hides the outage.
|
||||||
await expect(page.getByText('No items yet')).toBeHidden();
|
await expect(storefront.emptyNotice).toBeHidden();
|
||||||
await expect(page.getByText("Couldn't load items")).toBeVisible();
|
await expect(storefront.loadFailureNotice).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Retry' })).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;
|
let failing = true;
|
||||||
await page.route('**/api/items*', (route) => {
|
await page.route('**/api/items*', (route) => {
|
||||||
if (failing) {
|
if (failing) {
|
||||||
@@ -24,22 +27,25 @@ test.describe('Storefront failure states', () => {
|
|||||||
return route.continue();
|
return route.continue();
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
|
await expect(storefront.retryButton).toBeVisible();
|
||||||
|
|
||||||
failing = false;
|
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
|
// Mirrors the real incident: an un-migrated database left every item query
|
||||||
// hanging with no response at all.
|
// hanging with no response at all.
|
||||||
await page.route('**/api/items*', () => { /* never fulfilled */ });
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
test.describe('Storefront', () => {
|
test.describe('Storefront', () => {
|
||||||
test('loads and shows the site title', async ({ page }) => {
|
test('loads and shows the site title', async ({ storefront }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await expect(page.getByText('Redefined Designs')).toBeVisible();
|
await expect(storefront.header.siteTitle).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('links to the privacy policy from the footer', async ({ page }) => {
|
test('links to the privacy policy from the footer', async ({ page, storefront }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('link', { name: 'Privacy Policy' }).click();
|
await storefront.privacyPolicyLink.click();
|
||||||
await expect(page).toHaveURL(/\/privacy/);
|
await expect(page).toHaveURL(/\/privacy/);
|
||||||
await expect(page.getByText('What we collect')).toBeVisible();
|
await expect(page.getByText('What we collect')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('offers login and sign up when logged out', async ({ page }) => {
|
test('offers login and sign up when logged out', async ({ storefront }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
await expect(storefront.header.logInButton).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible();
|
await expect(storefront.header.signUpButton).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { APIRequestContext, expect } from '@playwright/test';
|
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.
|
* 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.
|
* 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<APIRequestContext> } }
|
||||||
|
): Promise<APIRequestContext> {
|
||||||
|
return playwright.request.newContext({ baseURL: BASE_URL });
|
||||||
|
}
|
||||||
|
|
||||||
export interface SeededItem {
|
export interface SeededItem {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
test.describe('Theme switching', () => {
|
test.describe('Theme switching', () => {
|
||||||
test('toggling the switch changes the body theme attribute', async ({ page }) => {
|
test('toggling the switch changes the body theme attribute', async ({ storefront, header }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
const body = page.locator('body');
|
const initial = await header.currentTheme();
|
||||||
const initial = await body.getAttribute('data-theme');
|
|
||||||
|
|
||||||
await page.getByRole('switch').click();
|
await header.themeToggle.click();
|
||||||
|
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
const updated = await body.getAttribute('data-theme');
|
expect(await header.currentTheme()).not.toBe(initial);
|
||||||
expect(updated).not.toBe(initial);
|
|
||||||
}).toPass();
|
}).toPass();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('theme preference persists across a reload', async ({ page }) => {
|
test('theme preference persists across a reload', async ({ page, storefront, header }) => {
|
||||||
await page.goto('/');
|
await storefront.goto();
|
||||||
await page.getByRole('switch').click();
|
await header.themeToggle.click();
|
||||||
const chosen = await page.locator('body').getAttribute('data-theme');
|
const chosen = await header.currentTheme();
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.locator('body')).toHaveAttribute('data-theme', chosen || '');
|
await expect(header.themedBody).toHaveAttribute('data-theme', chosen || '');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user