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>
124 lines
4.8 KiB
TypeScript
124 lines
4.8 KiB
TypeScript
import { Locator, Page, expect } from '@playwright/test';
|
|
|
|
/**
|
|
* The register / log in form.
|
|
*
|
|
* It is a modal wherever it appears — over the storefront at /register and
|
|
* /login, and over whatever the customer was doing when they clicked Add to
|
|
* Cart while signed out. One object covers all three, because it is one
|
|
* component (`AuthForm`, shared by `AuthRouteModal` and `AuthPromptModal`).
|
|
*
|
|
* Locators are scoped to the dialog rather than the page. The storefront behind
|
|
* the modal has a "Log in" button of its own — the one that opened this — so an
|
|
* unscoped `getByRole('button', { name: 'Log in' })` matches two things and
|
|
* fails on strict mode, which is a confusing way to learn that the modal is a
|
|
* modal.
|
|
*/
|
|
export class AuthModal {
|
|
readonly registerDialog: Locator;
|
|
readonly logInDialog: Locator;
|
|
readonly email: Locator;
|
|
readonly firstName: Locator;
|
|
readonly lastName: Locator;
|
|
readonly password: Locator;
|
|
readonly marketingConsent: Locator;
|
|
readonly analyticsConsent: Locator;
|
|
readonly createAccountButton: Locator;
|
|
readonly createAccountTab: Locator;
|
|
readonly logInTab: Locator;
|
|
readonly forgotPasswordButton: Locator;
|
|
|
|
constructor(private readonly page: Page) {
|
|
this.registerDialog = page.getByRole('dialog', { name: 'Create an account' });
|
|
this.logInDialog = page.getByRole('dialog', { name: 'Log in' });
|
|
this.email = page.getByRole('textbox', { name: 'Email' });
|
|
this.firstName = page.getByRole('textbox', { name: 'First name' });
|
|
this.lastName = page.getByRole('textbox', { name: 'Last name' });
|
|
this.password = page.getByLabel('Password');
|
|
// Named rather than "the checkbox on the form". There are two consents now
|
|
// and they are deliberately separate (#56), so an unscoped checkbox locator
|
|
// is ambiguous in strict mode — which is how this broke. Matched on the
|
|
// opening words of each sentence so the locator survives the wording being
|
|
// revised, which it has been once already.
|
|
this.marketingConsent = page.getByRole('checkbox', {
|
|
name: /^I want to receive occasional emails/
|
|
});
|
|
this.analyticsConsent = page.getByRole('checkbox', {
|
|
name: /^I agree that what I browse and buy/
|
|
});
|
|
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. */
|
|
get submitLogInButton(): Locator {
|
|
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> {
|
|
await this.page.goto('/register');
|
|
}
|
|
|
|
async gotoLogIn(): Promise<void> {
|
|
await this.page.goto('/login');
|
|
}
|
|
|
|
/** Fills the registration form without submitting, for tests about validation. */
|
|
async fillRegistration(details: {
|
|
email: string;
|
|
password: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
}): Promise<void> {
|
|
await this.email.fill(details.email);
|
|
if (details.firstName !== undefined) await this.firstName.fill(details.firstName);
|
|
if (details.lastName !== undefined) await this.lastName.fill(details.lastName);
|
|
await this.password.fill(details.password);
|
|
}
|
|
|
|
async submitRegistration(): Promise<void> {
|
|
await this.createAccountButton.click();
|
|
}
|
|
|
|
async fillCredentials(email: string, password: string): Promise<void> {
|
|
await this.email.fill(email);
|
|
await this.password.fill(password);
|
|
}
|
|
|
|
async submitLogIn(): Promise<void> {
|
|
await this.submitLogInButton.click();
|
|
}
|
|
|
|
/**
|
|
* Waits for the modal to close, which is what registering or logging in does.
|
|
*
|
|
* The dialog going away is the completion of the action rather than an
|
|
* assertion about it: a test that carries on while the modal is still over
|
|
* the page clicks the modal's backdrop instead of what it meant to.
|
|
*/
|
|
async waitForDismissal(): Promise<void> {
|
|
await expect(this.registerDialog).toHaveCount(0);
|
|
await expect(this.logInDialog).toHaveCount(0);
|
|
}
|
|
}
|