Files
redefined-designs/frontend/tests/e2e/pages/AccountModal.ts
T
synAdminandClaude Opus 5 5b40ac25db
Linting / lint (pull_request) Successful in 3m24s
SonarQube Analysis / sonarqube (pull_request) Successful in 24m51s
fix(tests): regenerate the schema mirror and name the controls #56 moved (#320)
PR #317 was merged while its run was still failing, so four failures landed on main: one integration and three end-to-end. All of them are consequences of #56, and none could have been caught on the dev machine, which has no database and no browser to run those suites with.

The schema mirror was never regenerated. The migration added three columns to customers and src/db-kysely/schema.ts still described the table without them, which is the drift guard from #305 doing exactly what it exists for. Hand-edited to match what kysely-codegen emits — alphabetical, and Generated on the column that has a default — because regenerating properly needs a live database.

The other three are the same mistake three times: a control addressed by position, and the position moved. An unscoped getByRole('checkbox') became ambiguous once the register form had two consents. A toHaveCount(2) on the account modal's switches became three. And favoriteAlertsSwitch was getByRole('switch').last(), which did not error when a switch was appended below it — it silently retargeted, toggled analytics consent instead of favourite alerts, and then failed on a text assertion in favorites.spec.ts, naming neither the file nor the control actually at fault.

The reason position was ever used is that antd's Switch renders a bare role="switch" with no accessible name; the adjacent Text is a sibling, not a label. So each one now carries an explicit aria-label and is addressed by it. That is what makes them addressable from a test, and it is what a screen reader needed regardless — the fix and the accessibility improvement are the same change.

The count assertion stays, but alongside naming each switch, because a count on its own would pass if two of them were swapped for each other.

Two coverage gaps closed while here, both properties the compliance work in #56 depends on and neither previously asserted anywhere a customer could see: the analytics checkbox is unchecked on the register form, and the account toggle is off for a new customer. Quebec's Law 25 s.8.1 requires profiling to start off, the integration suite asserts the server half of that, and nothing asserted the half rendered on screen.

The fourth Playwright entry, the logged-out header surviving a reload, is reported flaky rather than failed and passed on retry. Left alone; it is unrelated to #56 and #257 covers flakes in this suite.

Verified: backend tsc clean, frontend tsc against the test config clean, production build green, both lint suites 0 errors, 478 unit tests passing. The integration and e2e suites still cannot run here, so whether this actually clears run 875's failures is for CI to say — which is the same gap that produced them.

Closes #320

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:09:37 -05:00

173 lines
7.5 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;
/**
* Every switch in the modal. Kept for the count assertion that guards against
* a control appearing or vanishing unnoticed — the individual switches below
* are addressed by name, not by position.
*/
readonly themeSwitches: Locator;
readonly marketingConsentSwitch: Locator;
readonly favoriteAlertsSwitch: Locator;
readonly analyticsConsentSwitch: 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');
// Addressed by accessible name rather than by position. `favoriteAlertsSwitch`
// used to be `.last()`, which silently retargeted the moment the analytics
// consent switch was added below it (#56): the test that meant to turn
// favourite alerts off toggled analytics consent on instead, and failed on
// the message rather than on the switch, which said nothing about why.
//
// antd's Switch renders a bare `role="switch"` with no accessible name — the
// adjacent Text is a sibling, not a label — so each one carries an explicit
// aria-label in Account.tsx. That is what makes these addressable, and it is
// also what a screen reader needed.
this.marketingConsentSwitch = this.dialog.getByRole('switch', {
name: 'Receive emails about new items'
});
this.favoriteAlertsSwitch = this.dialog.getByRole('switch', {
name: 'Email me when an item I favorited is sold'
});
this.analyticsConsentSwitch = this.dialog.getByRole('switch', {
name: 'Share what I browse and buy with Brevo'
});
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);
}
}