test(e2e): convert the auth specs onto page objects (#137) #150
@@ -133,6 +133,14 @@ export default tseslint.config(
|
|||||||
// Page objects hold locators built in the constructor and never
|
// Page objects hold locators built in the constructor and never
|
||||||
// reassigned. Flagging them as mutable props does not apply to a class.
|
// reassigned. Flagging them as mutable props does not apply to a class.
|
||||||
'sonarjs/prefer-read-only-props': 'off',
|
'sonarjs/prefer-read-only-props': 'off',
|
||||||
|
|
||||||
|
// Requesting a Playwright fixture IS using it — destructuring `customer`
|
||||||
|
// is what makes the account exist, whether or not the body then reads the
|
||||||
|
// address. The linter cannot see that side effect and reports every such
|
||||||
|
// fixture as an unused variable. Left on, the alternative is a `void
|
||||||
|
// customer;` line in each test, which is noise standing in for a comment.
|
||||||
|
// Variables are still checked; only parameters are exempt.
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
+113
-117
@@ -1,56 +1,27 @@
|
|||||||
import { test, expect, Page } from './fixtures';
|
import { test, expect, PASSWORD, uniqueEmail } from './fixtures';
|
||||||
|
|
||||||
const PASSWORD = 'supersecret123';
|
|
||||||
|
|
||||||
function uniqueEmail(): string {
|
|
||||||
return `playwright-${Date.now()}-${Math.floor(Math.random() * 10000)}@example.com`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registering closes the auth modal and returns the customer to the page behind
|
|
||||||
// it — the storefront, for a direct visit to /register — rather than navigating
|
|
||||||
// to /account. So the header, not the URL, is what proves the session exists.
|
|
||||||
//
|
|
||||||
// The wait is generous because this is a bcrypt round-trip rather than a render,
|
|
||||||
// and the suite's workers all register at once.
|
|
||||||
async function register(page: Page, email: string) {
|
|
||||||
await page.goto('/register');
|
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
|
||||||
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
|
||||||
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log out lives inside the account view, which is a modal over the storefront.
|
|
||||||
async function openAccount(page: Page) {
|
|
||||||
await page.goto('/account');
|
|
||||||
await expect(page.getByRole('dialog', { name: 'My Account' })).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Customer accounts', () => {
|
test.describe('Customer accounts', () => {
|
||||||
// Both names are required from anyone new so every email has a first name to
|
// Both names are required from anyone new so every email has a first name to
|
||||||
// greet with (#106). The server refuses without them; this is the form
|
// greet with (#106). The server refuses without them; this is the form
|
||||||
// refusing first, so nobody gets a round trip to find out.
|
// refusing first, so nobody gets a round trip to find out.
|
||||||
test('will not submit a registration without both names', async ({ page }) => {
|
test('will not submit a registration without both names', async ({ authModal, header }) => {
|
||||||
await page.goto('/register');
|
await authModal.gotoRegister();
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
|
await authModal.fillRegistration({ email: uniqueEmail(), password: PASSWORD });
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
await authModal.submitRegistration();
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText('First name is required')).toBeVisible();
|
await expect(authModal.registerDialog.getByText('First name is required')).toBeVisible();
|
||||||
await expect(page.getByText('Last name is required')).toBeVisible();
|
await expect(authModal.registerDialog.getByText('Last name is required')).toBeVisible();
|
||||||
// Still on the form rather than signed in.
|
// Still on the form rather than signed in.
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toHaveCount(0);
|
await expect(header.myAccountButton).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('marketing consent checkbox is unchecked by default', async ({ page }) => {
|
test('marketing consent checkbox is unchecked by default', async ({ authModal }) => {
|
||||||
await page.goto('/register');
|
await authModal.gotoRegister();
|
||||||
await expect(page.getByRole('checkbox')).not.toBeChecked();
|
await expect(authModal.marketingConsent).not.toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the consent label is the exact wording the server records', async ({ page }) => {
|
test('the consent label is the exact wording the server records', async ({ authModal }) => {
|
||||||
await page.goto('/register');
|
await authModal.gotoRegister();
|
||||||
|
|
||||||
// The stored consent text is kept verbatim so the record says what the
|
// The stored consent text is kept verbatim so the record says what the
|
||||||
// customer actually saw. Three different wordings were in circulation
|
// customer actually saw. Three different wordings were in circulation
|
||||||
@@ -58,83 +29,95 @@ test.describe('Customer accounts', () => {
|
|||||||
// prompt, and none of them matched what was stored.
|
// prompt, and none of them matched what was stored.
|
||||||
const consent =
|
const consent =
|
||||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||||
await expect(page.getByRole('dialog', { name: 'Create an account' })).toContainText(consent);
|
await expect(authModal.registerDialog).toContainText(consent);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registering signs the customer in and returns them where they were', async ({ page }) => {
|
// Drives the form rather than taking the `customer` fixture: this test is
|
||||||
|
// about registering, so the thing under test has to be the thing exercised.
|
||||||
|
test('registering signs the customer in and returns them where they were', async ({
|
||||||
|
page,
|
||||||
|
authModal,
|
||||||
|
header,
|
||||||
|
accountModal
|
||||||
|
}) => {
|
||||||
const email = uniqueEmail();
|
const email = uniqueEmail();
|
||||||
await register(page, email);
|
await authModal.gotoRegister();
|
||||||
|
await authModal.fillRegistration({
|
||||||
|
email,
|
||||||
|
password: PASSWORD,
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'Customer'
|
||||||
|
});
|
||||||
|
await authModal.submitRegistration();
|
||||||
|
await header.waitForSignedIn();
|
||||||
|
|
||||||
// Back on the storefront, signed in — not moved to the account page.
|
// Back on the storefront, signed in — not moved to the account page.
|
||||||
await expect(page).toHaveURL(/\/$/);
|
await expect(page).toHaveURL(/\/$/);
|
||||||
await openAccount(page);
|
await accountModal.open();
|
||||||
await expect(page.getByText(email)).toBeVisible();
|
await expect(accountModal.emailText(email)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects login with the wrong password', async ({ page }) => {
|
test('rejects login with the wrong password', async ({ page, customer, accountModal, authModal, header }) => {
|
||||||
const email = uniqueEmail();
|
await accountModal.openAndLogOut();
|
||||||
await register(page, email);
|
await expect(header.logInButton).toBeVisible();
|
||||||
await openAccount(page);
|
|
||||||
await page.getByRole('button', { name: 'Log out' }).click();
|
|
||||||
|
|
||||||
await page.goto('/login');
|
await authModal.gotoLogIn();
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await authModal.logIn(customer.email, 'wrong-password');
|
||||||
await page.getByLabel('Password').fill('wrong-password');
|
|
||||||
// Scoped to the modal: the storefront rendered behind it has a "Log in"
|
|
||||||
// button of its own, which is what opened this one.
|
|
||||||
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText('invalid email or password')).toBeVisible();
|
await expect(page.getByText('invalid email or password')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('logging out returns to the home page and resets the header', async ({ page }) => {
|
test('logging out returns to the home page and resets the header', async ({
|
||||||
await register(page, uniqueEmail());
|
page,
|
||||||
await openAccount(page);
|
customer,
|
||||||
|
accountModal,
|
||||||
await page.getByRole('button', { name: 'Log out' }).click();
|
header
|
||||||
|
}) => {
|
||||||
|
await accountModal.openAndLogOut();
|
||||||
|
|
||||||
// A server round-trip followed by re-rendering the storefront behind the
|
// A server round-trip followed by re-rendering the storefront behind the
|
||||||
// modal, so the 5s default is too tight when workers run concurrently.
|
// modal, so the 5s default is too tight when workers run concurrently.
|
||||||
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
||||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
await expect(header.logInButton).toBeVisible();
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the logged-out header survives a reload', async ({ page }) => {
|
test('the logged-out header survives a reload', async ({ page, customer, accountModal, header }) => {
|
||||||
await register(page, uniqueEmail());
|
await accountModal.openAndLogOut();
|
||||||
await openAccount(page);
|
await expect(header.logInButton).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Log out' }).click();
|
|
||||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
|
||||||
|
|
||||||
// Proves the server session was actually destroyed, rather than the header
|
// Proves the server session was actually destroyed, rather than the header
|
||||||
// merely being repainted from stale client state.
|
// merely being repainted from stale client state.
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
await expect(header.logInButton).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden();
|
await expect(header.myAccountButton).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('logging out does not leave the account page on the back stack', async ({ page }) => {
|
test('logging out does not leave the account page on the back stack', async ({
|
||||||
await register(page, uniqueEmail());
|
page,
|
||||||
await openAccount(page);
|
customer,
|
||||||
|
accountModal
|
||||||
await page.getByRole('button', { name: 'Log out' }).click();
|
}) => {
|
||||||
|
await accountModal.openAndLogOut();
|
||||||
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
||||||
|
|
||||||
await page.goBack();
|
await page.goBack();
|
||||||
await expect(page).not.toHaveURL(/\/account/);
|
await expect(page).not.toHaveURL(/\/account/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a failed logout says so instead of appearing to succeed', async ({ page }) => {
|
test('a failed logout says so instead of appearing to succeed', async ({
|
||||||
await register(page, uniqueEmail());
|
page,
|
||||||
await openAccount(page);
|
customer,
|
||||||
|
accountModal
|
||||||
|
}) => {
|
||||||
|
await accountModal.open();
|
||||||
|
|
||||||
await page.route('**/api/customers/logout', (route) =>
|
await page.route('**/api/customers/logout', (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.getByRole('button', { name: 'Log out' }).click();
|
await accountModal.logOut();
|
||||||
|
|
||||||
// The session cookie is still valid, so pretending to be logged out would
|
// The session cookie is still valid, so pretending to be logged out would
|
||||||
// silently log the customer back in on their next reload.
|
// silently log the customer back in on their next reload.
|
||||||
@@ -144,69 +127,82 @@ test.describe('Customer accounts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Auth routes are not dead ends', () => {
|
test.describe('Auth routes are not dead ends', () => {
|
||||||
test('opening Log in from the header closes back to where browsing left off', async ({ page }) => {
|
test('opening Log in from the header closes back to where browsing left off', async ({
|
||||||
|
page,
|
||||||
|
header,
|
||||||
|
authModal
|
||||||
|
}) => {
|
||||||
await page.goto('/?max_price=50000');
|
await page.goto('/?max_price=50000');
|
||||||
await page.getByRole('button', { name: 'Log in' }).click();
|
await header.logInButton.click();
|
||||||
|
|
||||||
const modal = page.getByRole('dialog', { name: 'Log in' });
|
await expect(authModal.logInDialog).toBeVisible();
|
||||||
await expect(modal).toBeVisible();
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
|
||||||
await modal.getByRole('button', { name: 'Close' }).click();
|
await authModal.closeButton.click();
|
||||||
|
|
||||||
await expect(modal).toBeHidden();
|
await expect(authModal.logInDialog).toBeHidden();
|
||||||
await expect(page).toHaveURL(/max_price=50000/);
|
await expect(page).toHaveURL(/max_price=50000/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a direct visit opens over the storefront rather than a blank page', async ({ page }) => {
|
test('a direct visit opens over the storefront rather than a blank page', async ({
|
||||||
await page.goto('/login');
|
authModal,
|
||||||
|
header
|
||||||
|
}) => {
|
||||||
|
await authModal.gotoLogIn();
|
||||||
|
|
||||||
await expect(page.getByRole('dialog', { name: 'Log in' })).toBeVisible();
|
await expect(authModal.logInDialog).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
|
await expect(header.siteTitle).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('switching between sign in and sign up keeps one history entry', async ({ page }) => {
|
test('switching between sign in and sign up keeps one history entry', async ({
|
||||||
|
page,
|
||||||
|
header,
|
||||||
|
authModal
|
||||||
|
}) => {
|
||||||
await page.goto('/?max_price=50000');
|
await page.goto('/?max_price=50000');
|
||||||
await page.getByRole('button', { name: 'Log in' }).click();
|
await header.logInButton.click();
|
||||||
|
|
||||||
await page.getByRole('tab', { name: 'Create Account' }).click();
|
await authModal.createAccountTab.click();
|
||||||
await expect(page).toHaveURL(/\/register/);
|
await expect(page).toHaveURL(/\/register/);
|
||||||
await page.getByRole('tab', { name: 'Log In' }).click();
|
await authModal.logInTab.click();
|
||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
|
||||||
// Back returns to browsing rather than walking through each tab visited.
|
// Back returns to browsing rather than walking through each tab visited.
|
||||||
await page.goBack();
|
await page.goBack();
|
||||||
await expect(page).toHaveURL(/max_price=50000/);
|
await expect(page).toHaveURL(/max_price=50000/);
|
||||||
await expect(page.getByRole('dialog', { name: 'Log in' })).toBeHidden();
|
await expect(authModal.logInDialog).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('signing in from the header returns to the page behind, signed in', async ({ page }) => {
|
test('signing in from the header returns to the page behind, signed in', async ({
|
||||||
const email = uniqueEmail();
|
page,
|
||||||
await register(page, email);
|
customer,
|
||||||
await page.goto('/account');
|
accountModal,
|
||||||
await page.getByRole('button', { name: 'Log out' }).click();
|
header,
|
||||||
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
authModal
|
||||||
|
}) => {
|
||||||
|
await accountModal.openAndLogOut();
|
||||||
|
await expect(header.logInButton).toBeVisible();
|
||||||
|
|
||||||
await page.goto('/?max_price=50000');
|
await page.goto('/?max_price=50000');
|
||||||
await page.getByRole('button', { name: 'Log in' }).click();
|
await header.logInButton.click();
|
||||||
const modal = page.getByRole('dialog', { name: 'Log in' });
|
await authModal.logIn(customer.email, customer.password);
|
||||||
await modal.getByRole('textbox', { name: 'Email' }).fill(email);
|
|
||||||
await modal.getByLabel('Password').fill(PASSWORD);
|
|
||||||
await modal.getByRole('button', { name: 'Log in' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
await header.waitForSignedIn();
|
||||||
await expect(page).toHaveURL(/max_price=50000/);
|
await expect(page).toHaveURL(/max_price=50000/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reaching password recovery from the login form keeps a way back', async ({ page }) => {
|
test('reaching password recovery from the login form keeps a way back', async ({
|
||||||
await page.goto('/login');
|
page,
|
||||||
await page.getByRole('button', { name: 'Forgot password?' }).click();
|
authModal,
|
||||||
|
passwordReset
|
||||||
|
}) => {
|
||||||
|
await authModal.gotoLogIn();
|
||||||
|
await authModal.forgotPasswordButton.click();
|
||||||
|
|
||||||
const modal = page.getByRole('dialog', { name: 'Reset your password' });
|
await expect(passwordReset.requestDialog).toBeVisible();
|
||||||
await expect(modal).toBeVisible();
|
|
||||||
await expect(page).toHaveURL(/\/forgot-password/);
|
await expect(page).toHaveURL(/\/forgot-password/);
|
||||||
|
|
||||||
await modal.getByRole('button', { name: 'Sign in' }).click();
|
await passwordReset.signInButton.click();
|
||||||
await expect(page.getByRole('dialog', { name: 'Log in' })).toBeVisible();
|
await expect(authModal.logInDialog).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AuthModal } from './pages/AuthModal';
|
|||||||
import { AccountModal } from './pages/AccountModal';
|
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 { 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,
|
||||||
@@ -38,6 +39,7 @@ interface Pages {
|
|||||||
accountModal: AccountModal;
|
accountModal: AccountModal;
|
||||||
storefront: StorefrontPage;
|
storefront: StorefrontPage;
|
||||||
admin: AdminPage;
|
admin: AdminPage;
|
||||||
|
passwordReset: PasswordResetPages;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
@@ -88,6 +90,9 @@ export const test = base.extend<Pages & Data & { collectCoverage: void }>({
|
|||||||
admin: async ({ page }, use) => {
|
admin: async ({ page }, use) => {
|
||||||
await use(new AdminPage(page));
|
await use(new AdminPage(page));
|
||||||
},
|
},
|
||||||
|
passwordReset: async ({ page }, use) => {
|
||||||
|
await use(new PasswordResetPages(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 });
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export class AccountModal {
|
|||||||
readonly logOutButton: Locator;
|
readonly logOutButton: Locator;
|
||||||
readonly orderHistoryButton: Locator;
|
readonly orderHistoryButton: Locator;
|
||||||
readonly closeButton: Locator;
|
readonly closeButton: Locator;
|
||||||
|
readonly resendVerificationButton: Locator;
|
||||||
|
|
||||||
readonly firstName: Locator;
|
readonly firstName: Locator;
|
||||||
readonly lastName: Locator;
|
readonly lastName: Locator;
|
||||||
@@ -30,6 +31,7 @@ export class AccountModal {
|
|||||||
this.logOutButton = this.dialog.getByRole('button', { name: 'Log out' });
|
this.logOutButton = this.dialog.getByRole('button', { name: 'Log out' });
|
||||||
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.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 });
|
||||||
@@ -59,6 +61,20 @@ export class AccountModal {
|
|||||||
await this.logOutButton.click();
|
await this.logOutButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the account view and logs out, which is the only route to signing out
|
||||||
|
* — there is no header control for it.
|
||||||
|
*
|
||||||
|
* Does not wait for the result. Several tests assert different things about
|
||||||
|
* what logging out does: the URL it lands on, the header it leaves behind, a
|
||||||
|
* failure it reports. Waiting here would make one of those the action's
|
||||||
|
* contract and quietly weaken the others.
|
||||||
|
*/
|
||||||
|
async openAndLogOut(): Promise<void> {
|
||||||
|
await this.open();
|
||||||
|
await this.logOut();
|
||||||
|
}
|
||||||
|
|
||||||
/** 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);
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export class AuthModal {
|
|||||||
readonly password: Locator;
|
readonly password: Locator;
|
||||||
readonly marketingConsent: Locator;
|
readonly marketingConsent: Locator;
|
||||||
readonly createAccountButton: Locator;
|
readonly createAccountButton: Locator;
|
||||||
|
readonly createAccountTab: Locator;
|
||||||
|
readonly logInTab: Locator;
|
||||||
|
readonly forgotPasswordButton: Locator;
|
||||||
|
|
||||||
constructor(private readonly page: Page) {
|
constructor(private readonly page: Page) {
|
||||||
this.registerDialog = page.getByRole('dialog', { name: 'Create an account' });
|
this.registerDialog = page.getByRole('dialog', { name: 'Create an account' });
|
||||||
@@ -33,6 +36,9 @@ export class AuthModal {
|
|||||||
this.password = page.getByLabel('Password');
|
this.password = page.getByLabel('Password');
|
||||||
this.marketingConsent = page.getByRole('checkbox');
|
this.marketingConsent = page.getByRole('checkbox');
|
||||||
this.createAccountButton = page.getByRole('button', { name: 'Create account' });
|
this.createAccountButton = page.getByRole('button', { name: 'Create account' });
|
||||||
|
this.createAccountTab = page.getByRole('tab', { name: 'Create Account' });
|
||||||
|
this.logInTab = page.getByRole('tab', { name: 'Log In' });
|
||||||
|
this.forgotPasswordButton = page.getByRole('button', { name: 'Forgot password?' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The submit button inside the log in dialog, not the one that opened it. */
|
/** The submit button inside the log in dialog, not the one that opened it. */
|
||||||
@@ -40,6 +46,24 @@ export class AuthModal {
|
|||||||
return this.logInDialog.getByRole('button', { name: 'Log in' });
|
return this.logInDialog.getByRole('button', { name: 'Log in' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Closes whichever of the two dialogs is open. */
|
||||||
|
get closeButton(): Locator {
|
||||||
|
return this.page.getByRole('dialog').getByRole('button', { name: 'Close' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs in through the modal, wherever it was opened from.
|
||||||
|
*
|
||||||
|
* Scoped to the dialog throughout: the storefront behind it has its own
|
||||||
|
* "Log in" button — the one that opened this — and an unscoped fill or click
|
||||||
|
* matches both.
|
||||||
|
*/
|
||||||
|
async logIn(email: string, password: string): Promise<void> {
|
||||||
|
await this.logInDialog.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
|
await this.logInDialog.getByLabel('Password').fill(password);
|
||||||
|
await this.submitLogInButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
async gotoRegister(): Promise<void> {
|
async gotoRegister(): Promise<void> {
|
||||||
await this.page.goto('/register');
|
await this.page.goto('/register');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export class Header {
|
|||||||
readonly siteTitle: Locator;
|
readonly siteTitle: Locator;
|
||||||
readonly myAccountButton: Locator;
|
readonly myAccountButton: Locator;
|
||||||
readonly logInButton: Locator;
|
readonly logInButton: Locator;
|
||||||
|
readonly signUpButton: Locator;
|
||||||
readonly cartButton: Locator;
|
readonly cartButton: Locator;
|
||||||
readonly themeToggle: Locator;
|
readonly themeToggle: Locator;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export class Header {
|
|||||||
this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' });
|
this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' });
|
||||||
this.myAccountButton = page.getByRole('button', { name: 'My Account' });
|
this.myAccountButton = page.getByRole('button', { name: 'My Account' });
|
||||||
this.logInButton = page.getByRole('button', { name: 'Log in' });
|
this.logInButton = page.getByRole('button', { name: 'Log in' });
|
||||||
|
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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Locator, Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two halves of password recovery: asking for a link, and using one.
|
||||||
|
*
|
||||||
|
* They are one object because they are one flow and a test usually crosses
|
||||||
|
* between them. Requesting is a modal reached from the login form; setting the
|
||||||
|
* new password is a route the customer arrives at from their email, with no
|
||||||
|
* page behind it to return to.
|
||||||
|
*/
|
||||||
|
export class PasswordResetPages {
|
||||||
|
readonly requestDialog: Locator;
|
||||||
|
readonly email: Locator;
|
||||||
|
readonly sendResetLinkButton: Locator;
|
||||||
|
readonly signInButton: Locator;
|
||||||
|
|
||||||
|
readonly newPassword: Locator;
|
||||||
|
readonly confirmNewPassword: Locator;
|
||||||
|
readonly setNewPasswordButton: Locator;
|
||||||
|
|
||||||
|
constructor(private readonly page: Page) {
|
||||||
|
this.requestDialog = page.getByRole('dialog', { name: 'Reset your password' });
|
||||||
|
this.email = page.getByRole('textbox', { name: 'Email' });
|
||||||
|
this.sendResetLinkButton = page.getByRole('button', { name: 'Send reset link' });
|
||||||
|
this.signInButton = this.requestDialog.getByRole('button', { name: 'Sign in' });
|
||||||
|
|
||||||
|
this.newPassword = page.getByLabel('New password', { exact: true });
|
||||||
|
this.confirmNewPassword = page.getByLabel('Confirm new password');
|
||||||
|
this.setNewPasswordButton = page.getByRole('button', { name: 'Set new password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async gotoRequest(): Promise<void> {
|
||||||
|
await this.page.goto('/forgot-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the reset form, with or without a token.
|
||||||
|
*
|
||||||
|
* Omitting it is a real case rather than a degenerate one: a link that lost
|
||||||
|
* its token has to explain itself instead of failing on submit.
|
||||||
|
*/
|
||||||
|
async gotoReset(token?: string): Promise<void> {
|
||||||
|
await this.page.goto(token ? `/reset-password?token=${token}` : '/reset-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
async setNewPassword(password: string, confirmation = password): Promise<void> {
|
||||||
|
await this.newPassword.fill(password);
|
||||||
|
await this.confirmNewPassword.fill(confirmation);
|
||||||
|
await this.setNewPasswordButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestLinkFor(email: string): Promise<void> {
|
||||||
|
await this.email.fill(email);
|
||||||
|
await this.sendResetLinkButton.click();
|
||||||
|
await expect(this.page.getByText('Check your email')).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,163 +1,121 @@
|
|||||||
import { test, expect, Page } from './fixtures';
|
import { test, expect } from './fixtures';
|
||||||
import { Client } from 'pg';
|
import { readPasswordResetToken } from './support/db';
|
||||||
|
|
||||||
const PASSWORD = 'supersecret123';
|
|
||||||
const NEW_PASSWORD = 'a-brand-new-password';
|
const NEW_PASSWORD = 'a-brand-new-password';
|
||||||
|
|
||||||
const uniqueEmail = () => `reset-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
|
|
||||||
|
|
||||||
async function register(page: Page, email: string) {
|
|
||||||
await page.goto('/register');
|
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
|
||||||
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
|
|
||||||
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
|
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
|
||||||
// Registering now closes the auth modal and returns to the page behind it, so
|
|
||||||
// the header rather than the URL is what proves the session exists. The wait
|
|
||||||
// is generous because this is a bcrypt round-trip rather than a render.
|
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log out lives inside the account view, which is a modal over the storefront.
|
|
||||||
async function logout(page: Page) {
|
|
||||||
await page.goto('/account');
|
|
||||||
await page.getByRole('dialog', { name: 'My Account' })
|
|
||||||
.getByRole('button', { name: 'Log out' }).click();
|
|
||||||
// A server round-trip followed by re-rendering the storefront behind the
|
|
||||||
// modal, so the 5s default is too tight when workers run concurrently.
|
|
||||||
await expect(page).toHaveURL(/\/$/, { timeout: 20000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Password reset', () => {
|
test.describe('Password reset', () => {
|
||||||
test('the login page offers a way to recover a forgotten password', async ({ page }) => {
|
test('the login page offers a way to recover a forgotten password', async ({
|
||||||
await page.goto('/login');
|
page,
|
||||||
// Recovery is now reached from the login modal rather than a link on a
|
authModal,
|
||||||
// page, and its title is the modal's rather than a heading.
|
passwordReset
|
||||||
await page.getByRole('button', { name: 'Forgot password?' }).click();
|
}) => {
|
||||||
|
await authModal.gotoLogIn();
|
||||||
|
// Recovery is reached from the login modal rather than a link on a page,
|
||||||
|
// and its title is the modal's rather than a heading.
|
||||||
|
await authModal.forgotPasswordButton.click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/forgot-password/);
|
await expect(page).toHaveURL(/\/forgot-password/);
|
||||||
await expect(page.getByRole('dialog', { name: 'Reset your password' })).toBeVisible();
|
await expect(passwordReset.requestDialog).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('requesting a reset confirms without revealing whether the account exists', async ({ page }) => {
|
test('requesting a reset confirms without revealing whether the account exists', async ({
|
||||||
await page.goto('/forgot-password');
|
page,
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill('definitely-nobody@example.com');
|
passwordReset
|
||||||
await page.getByRole('button', { name: 'Send reset link' }).click();
|
}) => {
|
||||||
|
await passwordReset.gotoRequest();
|
||||||
|
await passwordReset.requestLinkFor('definitely-nobody@example.com');
|
||||||
|
|
||||||
// Identical wording either way; a differing message would make this an
|
// Identical wording either way; a differing message would make this an
|
||||||
// account-enumeration oracle.
|
// account-enumeration oracle.
|
||||||
await expect(page.getByText('Check your email')).toBeVisible();
|
|
||||||
await expect(page.getByText(/If an account exists/)).toBeVisible();
|
await expect(page.getByText(/If an account exists/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a reset link with no token explains itself instead of failing on submit', async ({ page }) => {
|
test('a reset link with no token explains itself instead of failing on submit', async ({
|
||||||
await page.goto('/reset-password');
|
page,
|
||||||
|
passwordReset
|
||||||
|
}) => {
|
||||||
|
await passwordReset.gotoReset();
|
||||||
|
|
||||||
await expect(page.getByText('This link is incomplete')).toBeVisible();
|
await expect(page.getByText('This link is incomplete')).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Set new password' })).toHaveCount(0);
|
await expect(passwordReset.setNewPasswordButton).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects a mismatched confirmation before contacting the server', async ({ page }) => {
|
test('rejects a mismatched confirmation before contacting the server', async ({
|
||||||
await page.goto('/reset-password?token=whatever');
|
page,
|
||||||
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
|
passwordReset
|
||||||
await page.getByLabel('Confirm new password').fill('something-else-entirely');
|
}) => {
|
||||||
await page.getByRole('button', { name: 'Set new password' }).click();
|
await passwordReset.gotoReset('whatever');
|
||||||
|
await passwordReset.setNewPassword(NEW_PASSWORD, 'something-else-entirely');
|
||||||
|
|
||||||
await expect(page.getByText('The passwords do not match')).toBeVisible();
|
await expect(page.getByText('The passwords do not match')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reports an invalid token rather than appearing to succeed', async ({ page }) => {
|
test('reports an invalid token rather than appearing to succeed', async ({
|
||||||
await page.goto('/reset-password?token=not-a-real-token');
|
page,
|
||||||
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
|
passwordReset
|
||||||
await page.getByLabel('Confirm new password').fill(NEW_PASSWORD);
|
}) => {
|
||||||
await page.getByRole('button', { name: 'Set new password' }).click();
|
await passwordReset.gotoReset('not-a-real-token');
|
||||||
|
await passwordReset.setNewPassword(NEW_PASSWORD);
|
||||||
|
|
||||||
await expect(page.getByText('invalid or expired token')).toBeVisible();
|
await expect(page.getByText('invalid or expired token')).toBeVisible();
|
||||||
await expect(page).toHaveURL(/\/reset-password/);
|
await expect(page).toHaveURL(/\/reset-password/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a customer can reset their password and sign in with the new one', async ({ page, request }) => {
|
test('a customer can reset their password and sign in with the new one', async ({
|
||||||
const email = uniqueEmail();
|
request,
|
||||||
await register(page, email);
|
customer,
|
||||||
await logout(page);
|
accountModal,
|
||||||
|
header,
|
||||||
|
authModal,
|
||||||
|
passwordReset
|
||||||
|
}) => {
|
||||||
|
await accountModal.openAndLogOut();
|
||||||
|
await expect(header.logInButton).toBeVisible();
|
||||||
|
|
||||||
// The reset link arrives by email, which the tests can't read. Request the
|
// The reset link arrives by email, which the tests can't read. Request the
|
||||||
// reset through the real endpoint, then read the issued token the way the
|
// reset through the real endpoint, then read the issued token the way the
|
||||||
// customer's mail client would deliver it.
|
// customer's mail client would deliver it.
|
||||||
const requested = await request.post('/api/customers/request-password-reset', { data: { email } });
|
const requested = await request.post('/api/customers/request-password-reset', {
|
||||||
|
data: { email: customer.email }
|
||||||
|
});
|
||||||
expect(requested.ok()).toBeTruthy();
|
expect(requested.ok()).toBeTruthy();
|
||||||
|
|
||||||
const token = await readResetToken(email);
|
await passwordReset.gotoReset(await readPasswordResetToken(customer.email));
|
||||||
await page.goto(`/reset-password?token=${token}`);
|
await passwordReset.setNewPassword(NEW_PASSWORD);
|
||||||
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
|
|
||||||
await page.getByLabel('Confirm new password').fill(NEW_PASSWORD);
|
|
||||||
await page.getByRole('button', { name: 'Set new password' }).click();
|
|
||||||
|
|
||||||
// The reset signs them in and closes back to the storefront — the link came
|
// The reset signs them in and closes back to the storefront — the link came
|
||||||
// from an email, so there is no page behind it to return to.
|
// from an email, so there is no page behind it to return to.
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
await header.waitForSignedIn();
|
||||||
await page.goto('/account');
|
await accountModal.open();
|
||||||
await expect(page.getByText(email)).toBeVisible();
|
await expect(accountModal.emailText(customer.email)).toBeVisible();
|
||||||
|
|
||||||
// And the new password actually works on a fresh sign-in.
|
// And the new password actually works on a fresh sign-in.
|
||||||
await logout(page);
|
await accountModal.logOut();
|
||||||
await page.goto('/login');
|
await expect(header.logInButton).toBeVisible();
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await authModal.gotoLogIn();
|
||||||
await page.getByLabel('Password').fill(NEW_PASSWORD);
|
await authModal.logIn(customer.email, NEW_PASSWORD);
|
||||||
// Scoped to the modal: the storefront rendered behind it has a "Log in"
|
await header.waitForSignedIn();
|
||||||
// button of its own, which is what opened this one.
|
|
||||||
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
|
|
||||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the old password stops working after a reset', async ({ page, request }) => {
|
test('the old password stops working after a reset', async ({
|
||||||
const email = uniqueEmail();
|
page,
|
||||||
await register(page, email);
|
request,
|
||||||
await logout(page);
|
customer,
|
||||||
|
accountModal,
|
||||||
|
header,
|
||||||
|
authModal
|
||||||
|
}) => {
|
||||||
|
await accountModal.openAndLogOut();
|
||||||
|
await expect(header.logInButton).toBeVisible();
|
||||||
|
|
||||||
await request.post('/api/customers/request-password-reset', { data: { email } });
|
await request.post('/api/customers/request-password-reset', { data: { email: customer.email } });
|
||||||
const token = await readResetToken(email);
|
const token = await readPasswordResetToken(customer.email);
|
||||||
await request.post('/api/customers/reset-password', { data: { token, password: NEW_PASSWORD } });
|
await request.post('/api/customers/reset-password', { data: { token, password: NEW_PASSWORD } });
|
||||||
|
|
||||||
await page.goto('/login');
|
await authModal.gotoLogIn();
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
await authModal.logIn(customer.email, customer.password);
|
||||||
await page.getByLabel('Password').fill(PASSWORD);
|
|
||||||
// Scoped to the modal: the storefront rendered behind it has a "Log in"
|
|
||||||
// button of its own, which is what opened this one.
|
|
||||||
await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText('invalid email or password')).toBeVisible();
|
await expect(page.getByText('invalid email or password')).toBeVisible();
|
||||||
|
await expect(header.myAccountButton).toHaveCount(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The token is only ever delivered by email, which these tests cannot read.
|
|
||||||
//
|
|
||||||
// It is read straight from the database rather than through a helper endpoint:
|
|
||||||
// an endpoint that returns a password-reset token for an arbitrary address is
|
|
||||||
// account takeover for every customer if it is ever reachable, and an
|
|
||||||
// environment gate is a thin thing to stand between that and production. Doing
|
|
||||||
// it here keeps the capability entirely inside the test process.
|
|
||||||
async function readResetToken(email: string): Promise<string> {
|
|
||||||
const client = new Client({
|
|
||||||
host: process.env.TEST_PGHOST || 'localhost',
|
|
||||||
port: parseInt(process.env.TEST_PGPORT || '55432', 10),
|
|
||||||
user: process.env.TEST_PGUSER || 'redefined_test',
|
|
||||||
password: process.env.TEST_PGPASSWORD || 'redefined_test',
|
|
||||||
database: process.env.TEST_PGDATABASE || 'redefined_test'
|
|
||||||
});
|
|
||||||
await client.connect();
|
|
||||||
try {
|
|
||||||
const { rows } = await client.query(
|
|
||||||
`SELECT t.token FROM customer_tokens t
|
|
||||||
JOIN customers c ON c.id = t.customer_id
|
|
||||||
WHERE c.email = $1 AND t.kind = 'password_reset'
|
|
||||||
ORDER BY t.created_at DESC LIMIT 1`,
|
|
||||||
[email]
|
|
||||||
);
|
|
||||||
if (!rows.length) throw new Error(`no password_reset token issued for ${email}`);
|
|
||||||
return rows[0].token as string;
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,41 +1,20 @@
|
|||||||
import { test, expect, Page } from './fixtures';
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
const PASSWORD = 'supersecret123';
|
|
||||||
|
|
||||||
const uniqueEmail = () => `resend-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
|
|
||||||
|
|
||||||
// The generous wait matches the other account specs: registration is a bcrypt
|
|
||||||
// round-trip rather than 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' });
|
|
||||||
|
|
||||||
test.describe('Resending your verification email', () => {
|
test.describe('Resending your verification email', () => {
|
||||||
test('the account page offers it while the address is unverified', async ({ page }) => {
|
test('the account page offers it while the address is unverified', async ({
|
||||||
await registerCustomer(page);
|
customer,
|
||||||
await page.goto('/account');
|
accountModal
|
||||||
|
}) => {
|
||||||
|
await accountModal.open();
|
||||||
|
|
||||||
const modal = accountModal(page);
|
await expect(accountModal.dialog.getByText('Email not verified')).toBeVisible();
|
||||||
await expect(modal.getByText('Email not verified')).toBeVisible();
|
await expect(accountModal.resendVerificationButton).toBeVisible();
|
||||||
await expect(modal.getByRole('button', { name: 'Send it again' })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('confirms when it has sent', async ({ page }) => {
|
test('confirms when it has sent', async ({ page, customer, accountModal }) => {
|
||||||
await registerCustomer(page);
|
await accountModal.open();
|
||||||
await page.goto('/account');
|
|
||||||
|
|
||||||
await accountModal(page).getByRole('button', { name: 'Send it again' }).click();
|
await accountModal.resendVerificationButton.click();
|
||||||
|
|
||||||
await expect(page.getByText('Check your inbox, and your spam folder.')).toBeVisible();
|
await expect(page.getByText('Check your inbox, and your spam folder.')).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -43,11 +22,14 @@ test.describe('Resending your verification email', () => {
|
|||||||
// The message the customer gets on the fourth attempt is the point of the
|
// The message the customer gets on the fourth attempt is the point of the
|
||||||
// limiter's copy: it says the mail probably did send and where to look,
|
// limiter's copy: it says the mail probably did send and where to look,
|
||||||
// rather than only that a limit exists.
|
// rather than only that a limit exists.
|
||||||
test('says something useful once the allowance runs out', async ({ page }) => {
|
test('says something useful once the allowance runs out', async ({
|
||||||
await registerCustomer(page);
|
page,
|
||||||
await page.goto('/account');
|
customer,
|
||||||
|
accountModal
|
||||||
|
}) => {
|
||||||
|
await accountModal.open();
|
||||||
|
|
||||||
const resend = accountModal(page).getByRole('button', { name: 'Send it again' });
|
const resend = accountModal.resendVerificationButton;
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
await resend.click();
|
await resend.click();
|
||||||
await expect(resend).toBeEnabled();
|
await expect(resend).toBeEnabled();
|
||||||
|
|||||||
Reference in New Issue
Block a user