test(e2e): convert the account specs onto page objects (#137)
account-modal and account-details, taking two more copies of "register a customer" and two more of the `accountModal(page)` helper that every account-touching spec had rewritten.
Both files had grown their own vocabulary for the same dialog. account-details reached for `modal.getByLabel('Current password', { exact: true })` and its five siblings inline in each test, so a change to the account form meant editing six places in one file and more in the next. Those are named locators now, and the three multi-step operations — saving a name, changing a password, changing an email — are actions, because each is a disclosure to open and three fields to fill before the button does anything.
AccountModal.open() gains the 20s timeout the header already had, and for the same reason. Arriving at /account means booting the app and resolving the session against the server. The old specs never noticed because they registered through the form first, which loaded the app and confirmed the session before navigating; taking the API-registered `customer` fixture arrives cold, and the 5s default is comfortably beaten on an idle machine and missed on a loaded one.
Verified: 18/18 across three serial repeats, and 12/13 in parallel. The one failure is `changes the email address and marks it unverified again`, which fails identically on the unconverted file and passes whenever the suite is not saturated — the same load-related flakiness as the rest of the family, not something this commit introduced.
Refs #137
This commit is contained in:
@@ -4,9 +4,11 @@ import { Locator, Page, expect } from '@playwright/test';
|
||||
* The customer's own account view, which is a modal over the storefront rather
|
||||
* than a page of its own — /account renders the storefront with this on top.
|
||||
*
|
||||
* Fields are scoped to the dialog. Several of them ("Password", "First name")
|
||||
* share a label with the auth modal, and the two can both be in the DOM while
|
||||
* one is closing.
|
||||
* Everything is scoped to the dialog. Several controls have a twin on the page
|
||||
* behind it: the storefront has its own theme switch, and "Password" and "First
|
||||
* name" share a label with the auth modal, which can be in the DOM while one of
|
||||
* the two is closing. An unscoped locator matches both and fails on strict mode,
|
||||
* which is a confusing way to learn that a modal is a modal.
|
||||
*/
|
||||
export class AccountModal {
|
||||
readonly dialog: Locator;
|
||||
@@ -14,17 +16,26 @@ export class AccountModal {
|
||||
readonly orderHistoryButton: Locator;
|
||||
readonly closeButton: Locator;
|
||||
readonly resendVerificationButton: Locator;
|
||||
readonly themeSwitches: Locator;
|
||||
readonly notVerifiedNotice: Locator;
|
||||
|
||||
readonly firstName: Locator;
|
||||
readonly lastName: Locator;
|
||||
readonly saveNameButton: Locator;
|
||||
|
||||
readonly changePasswordDisclosure: Locator;
|
||||
readonly currentPassword: Locator;
|
||||
readonly newPassword: Locator;
|
||||
readonly confirmNewPassword: Locator;
|
||||
readonly submitPasswordChangeButton: Locator;
|
||||
|
||||
readonly changeEmailDisclosure: Locator;
|
||||
readonly newEmail: Locator;
|
||||
readonly passwordForEmailChange: Locator;
|
||||
readonly submitEmailChangeButton: Locator;
|
||||
|
||||
readonly deleteAccountButton: Locator;
|
||||
readonly confirmDeleteDialog: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.dialog = page.getByRole('dialog', { name: 'My Account' });
|
||||
@@ -32,17 +43,26 @@ export class AccountModal {
|
||||
this.orderHistoryButton = this.dialog.getByRole('button', { name: 'View order history' });
|
||||
this.closeButton = this.dialog.getByRole('button', { name: 'Close' });
|
||||
this.resendVerificationButton = this.dialog.getByRole('button', { name: 'Send it again' });
|
||||
this.themeSwitches = this.dialog.getByRole('switch');
|
||||
this.notVerifiedNotice = this.dialog.getByText('Email not verified');
|
||||
|
||||
this.firstName = this.dialog.getByLabel('First name', { exact: true });
|
||||
this.lastName = this.dialog.getByLabel('Last name', { exact: true });
|
||||
this.saveNameButton = this.dialog.getByRole('button', { name: 'Save name' });
|
||||
|
||||
this.changePasswordDisclosure = this.dialog.getByRole('button', { name: 'Change your password' });
|
||||
this.currentPassword = this.dialog.getByLabel('Current password', { exact: true });
|
||||
this.newPassword = this.dialog.getByLabel('New password', { exact: true });
|
||||
this.confirmNewPassword = this.dialog.getByLabel('Confirm new password', { exact: true });
|
||||
this.submitPasswordChangeButton = this.dialog.getByRole('button', { name: 'Change password' });
|
||||
|
||||
this.changeEmailDisclosure = this.dialog.getByRole('button', { name: 'Change your email address' });
|
||||
this.newEmail = this.dialog.getByLabel('New email address', { exact: true });
|
||||
this.passwordForEmailChange = this.dialog.getByLabel('Your password', { exact: true });
|
||||
this.submitEmailChangeButton = this.dialog.getByRole('button', { name: 'Change email' });
|
||||
|
||||
this.deleteAccountButton = this.dialog.getByRole('button', { name: 'Delete my account' });
|
||||
this.confirmDeleteDialog = page.getByRole('dialog', { name: 'Delete your account?' });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,10 +71,26 @@ export class AccountModal {
|
||||
* The wait is the action's contract rather than an assertion: everything a
|
||||
* caller does next is scoped to this dialog, and a locator resolved before it
|
||||
* exists finds nothing.
|
||||
*
|
||||
* The timeout is generous for the same reason the header's is. Arriving here
|
||||
* means booting the app and resolving the session against the server, and the
|
||||
* 5s default is comfortably beaten on an idle machine and missed on a loaded
|
||||
* one — the recipe for a test that fails only when the suite is busy.
|
||||
*/
|
||||
async open(): Promise<void> {
|
||||
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> {
|
||||
@@ -75,6 +111,33 @@ export class AccountModal {
|
||||
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. */
|
||||
emailText(email: string): Locator {
|
||||
return this.dialog.getByText(email);
|
||||
|
||||
Reference in New Issue
Block a user