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. * * 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; readonly logOutButton: Locator; readonly orderHistoryButton: Locator; readonly closeButton: Locator; readonly resendVerificationButton: Locator; readonly themeSwitches: Locator; /** The second switch in the modal; the first is the theme. */ readonly favoriteAlertsSwitch: 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' }); this.logOutButton = this.dialog.getByRole('button', { name: 'Log out' }); 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.favoriteAlertsSwitch = this.dialog.getByRole('switch').last(); 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?' }); } /** * Opens the account view and waits for it. * * The wait is the action's contract rather than an assertion: everything a * caller does next is scoped to this dialog, and a locator resolved before it * exists finds nothing. * * The timeout is generous for the same reason the header's is. Arriving here * means booting the app and resolving the session against the server, and the * 5s default is comfortably beaten on an idle machine and missed on a loaded * one — the recipe for a test that fails only when the suite is busy. */ async open(): Promise { await this.page.goto('/account'); await expect(this.dialog).toBeVisible({ timeout: 20000 }); } /** Opens it from the header, from wherever the customer was browsing. */ async openFromHeader(): Promise { await this.page.getByRole('button', { name: 'My Account' }).click(); await expect(this.dialog).toBeVisible({ timeout: 20000 }); } async close(): Promise { await this.closeButton.click(); await expect(this.dialog).toBeHidden(); } async logOut(): Promise { 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 { await this.open(); await this.logOut(); } async saveName(firstName: string, lastName?: string): Promise { await this.firstName.fill(firstName); if (lastName !== undefined) await this.lastName.fill(lastName); await this.saveNameButton.click(); } /** Both password fields sit behind a disclosure, so it has to be opened first. */ async changePassword(current: string, next: string): Promise { await this.changePasswordDisclosure.click(); await this.currentPassword.fill(current); await this.newPassword.fill(next); await this.confirmNewPassword.fill(next); await this.submitPasswordChangeButton.click(); } async changeEmail(newEmail: string, password: string): Promise { await this.changeEmailDisclosure.click(); await this.newEmail.fill(newEmail); await this.passwordForEmailChange.fill(password); await this.submitEmailChangeButton.click(); } async deleteAccount(): Promise { await this.deleteAccountButton.click(); await this.confirmDeleteDialog.getByRole('button', { name: 'Delete my account' }).click(); } /** The address the account view shows, which is how a test knows whose it is. */ emailText(email: string): Locator { return this.dialog.getByText(email); } }