favorites, favorites-filter, sold-filter, orders and pending-publish. Five more copies of "register a customer" and three more of the hardcoded base URL go with them. Two locators that were hiding real knowledge are now named. `gridCell` is the item's whole antd column rather than its card, needed because the SOLD ribbon renders outside the card — three specs reached for `.ant-col` directly to get at it. And `chooseAvailability` goes through the title attribute because antd's Segmented hides the real radio behind a styled label, so the input is found by role and cannot be clicked; that fact was written out twice in comments and is now written once in code. The favorite control is located page-wide rather than within a card. The storefront paginates as items accumulate and the control is named for its item anyway, so scoping to a card bought nothing and broke whenever the card was on another page. favorites-filter keeps its local `favorite()` helper. It is genuinely local — decline the opt-in, wait for the fading modal to stop intercepting pointer events, confirm the heart flipped — and belongs to that file's subject rather than to the storefront. It now takes page objects as parameters instead of reaching for locators itself, which is what a spec-level helper should look like. Two specs still drive the registration form rather than taking the `customer` fixture, and deliberately. Both are about a signed-out visitor being interrupted mid-action — favoriting an item, or switching on the favorites filter — and the claim is that the thing they asked for survives the interruption. Replacing the interruption with an API call would delete the test. Verified: favorites 6/6, favorites-filter 7/7, sold-filter 6/6, orders and pending-publish 8/8. Notably favorites-filter's "keeps showing a favorite after it sells" passes, which had been failing on a strict-mode violation from two items sharing a name across runs. Refs #137
149 lines
6.2 KiB
TypeScript
149 lines
6.2 KiB
TypeScript
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<void> {
|
|
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<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> {
|
|
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();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|