Merge pull request 'test(e2e): add page objects, fixtures and a typed test build (#137)' (#149) from feature/137-playwright-page-objects into main
Linting / lint (push) Successful in 2m50s
SonarQube Analysis / sonarqube (push) Failing after 18m50s

Reviewed-on: #149
This commit was merged in pull request #149.
This commit is contained in:
2026-08-23 17:19:49 -05:00
11 changed files with 652 additions and 7 deletions
+54 -3
View File
@@ -50,9 +50,6 @@ export default tseslint.config(
{ plugins: { 'jsx-a11y': jsxA11y } },
{
// `tests/` and the Playwright specs are deliberately out of scope for now:
// tsconfig.json only includes `src`, so type-aware linting has no program
// for them, and widening it is a separate change with its own count.
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
globals: globals.browser,
@@ -83,5 +80,59 @@ export default tseslint.config(
// the fix is one attribute.
'jsx-a11y/alt-text': 'error',
},
},
{
// The Playwright suite. Out of scope until #137, because tsconfig.json
// includes only `src` and type-aware linting had no program for these
// files. tsconfig.test.json is that program.
//
// `project` rather than `projectService`: the service resolves a file to the
// nearest tsconfig.json, which for tests/ is the one that excludes them, and
// every file then errors as not part of a project.
files: ['tests/**/*.ts'],
languageOptions: {
globals: globals.node,
parserOptions: {
project: ['./tsconfig.test.json'],
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// The one that matters most here. Playwright's API is almost entirely
// promises, and a missing `await` on an assertion does not fail — it
// passes, having asserted nothing, which is the worst outcome a test can
// have. The project's own Playwright notes already warn about it; this
// enforces it.
'@typescript-eslint/no-floating-promises': 'error',
// Switched off for tests rather than left as warnings. #60's whole
// argument is that a gate nobody reads is not a gate, and bringing these
// files in scope added 45 warnings of which none were defects. A rule
// that cannot be true here is noise that hides the rules that can.
//
// There is no React in this directory. The hooks rules fire on ordinary
// functions whose parameter happens to be named `use` — which Playwright
// fixtures are, by its own API.
'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/purity': 'off',
// Test credentials are the point of a test, and the project's own rule is
// that they must live only in test paths — which is here. Flagging them
// where they belong trains the reader to ignore the rule where they do
// not.
'sonarjs/no-hardcoded-passwords': 'off',
// Math.random builds unique fixture names so parallel workers do not
// collide. Nothing here is a secret, and a cryptographic generator would
// say something untrue about what the value is for.
'sonarjs/pseudo-random': 'off',
// Page objects hold locators built in the constructor and never
// reassigned. Flagging them as mutable props does not apply to a class.
'sonarjs/prefer-read-only-props': 'off',
},
}
);
+2 -2
View File
@@ -4,8 +4,8 @@
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src",
"build": "tsc && tsc -p tsconfig.test.json --noEmit && vite build",
"lint": "eslint src tests",
"test:e2e": "playwright test",
"test:e2e:cov": "cross-env COVERAGE=true playwright test",
"coverage:report": "node scripts/coverage-report.js"
+99 -2
View File
@@ -1,12 +1,19 @@
import { test as base } from '@playwright/test';
import { test as base, APIRequestContext, expect } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'fs';
import { randomUUID } from 'crypto';
import path from 'path';
import { Header } from './pages/Header';
import { AuthModal } from './pages/AuthModal';
import { AccountModal } from './pages/AccountModal';
import { StorefrontPage } from './pages/StorefrontPage';
import { AdminPage } from './pages/AdminPage';
import { uniqueEmail } from './support/api';
// Re-exported so specs can import everything from here — expect, Page,
// APIRequestContext — and get the coverage-collecting `test` at the same time.
// The explicit `test` below wins over the star export.
export * from '@playwright/test';
export * from './support/api';
const NYC_OUTPUT = path.resolve(__dirname, '..', '..', '.nyc_output');
const collectingCoverage = process.env.COVERAGE === 'true';
@@ -15,6 +22,49 @@ const collectingCoverage = process.env.COVERAGE === 'true';
// an empty run fails loudly instead of publishing 0% — see the note below.
const MARKER = path.join(NYC_OUTPUT, '.collected');
/** The password every seeded customer gets. Long enough to pass the 8-char rule. */
export const PASSWORD = 'supersecret123';
export interface RegisteredCustomer {
email: string;
password: string;
firstName: string;
lastName: string;
}
interface Pages {
header: Header;
authModal: AuthModal;
accountModal: AccountModal;
storefront: StorefrontPage;
admin: AdminPage;
}
interface Data {
/**
* A request context for the admin API, on the configured baseURL.
*
* Specs used to build their own with `playwright.request.newContext(...)`, one
* of them against a hardcoded `http://localhost:5173`, which meant changing
* the port in the config moved every test except that one.
*/
adminApi: APIRequestContext;
/**
* A freshly registered customer, already signed in on `page`.
*
* Registering through the API rather than the form, deliberately. Nine specs
* drove the registration form purely to arrive at a signed-in session, so a
* broken form failed a hundred tests that were not about it — and each paid
* for a bcrypt round-trip through the UI. The specs that are about
* registration use `authModal` and drive it properly.
*
* `page.request` shares the browser context's cookie jar, so the session
* cookie the endpoint sets belongs to `page`. The caller still has to
* navigate: the session exists, but a page loaded before it does not know.
*/
customer: RegisteredCustomer;
}
/**
* Flushes istanbul's per-page counters after each test.
*
@@ -22,7 +72,54 @@ const MARKER = path.join(NYC_OUTPUT, '.collected');
* has to be read before the page closes — one file per test, because the suite
* runs fullyParallel and workers would otherwise overwrite each other.
*/
export const test = base.extend<{ collectCoverage: void }>({
export const test = base.extend<Pages & Data & { collectCoverage: void }>({
header: async ({ page }, use) => {
await use(new Header(page));
},
authModal: async ({ page }, use) => {
await use(new AuthModal(page));
},
accountModal: async ({ page }, use) => {
await use(new AccountModal(page));
},
storefront: async ({ page }, use) => {
await use(new StorefrontPage(page));
},
admin: async ({ page }, use) => {
await use(new AdminPage(page));
},
adminApi: async ({ playwright, baseURL }, use) => {
const context = await playwright.request.newContext({ baseURL });
await use(context);
await context.dispose();
},
customer: async ({ page }, use) => {
const details: RegisteredCustomer = {
email: uniqueEmail(),
password: PASSWORD,
firstName: 'Test',
lastName: 'Customer'
};
const res = await page.request.post('/api/customers/register', {
data: {
email: details.email,
password: details.password,
firstName: details.firstName,
lastName: details.lastName,
marketingConsent: false
}
});
// Loudly, and naming the address: a failure here is a fixture failing to
// build, and every assertion after it would fail for a reason that has
// nothing to do with what the test is about.
expect(res.ok(), `registering ${details.email}`).toBeTruthy();
await use(details);
},
collectCoverage: [
async ({ page }, use) => {
await use();
+66
View File
@@ -0,0 +1,66 @@
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.
*/
export class AccountModal {
readonly dialog: Locator;
readonly logOutButton: Locator;
readonly orderHistoryButton: Locator;
readonly closeButton: Locator;
readonly firstName: Locator;
readonly lastName: Locator;
readonly saveNameButton: Locator;
readonly currentPassword: Locator;
readonly newPassword: Locator;
readonly confirmNewPassword: Locator;
readonly newEmail: Locator;
readonly passwordForEmailChange: 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.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.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.newEmail = this.dialog.getByLabel('New email address', { exact: true });
this.passwordForEmailChange = this.dialog.getByLabel('Your password', { exact: true });
}
/**
* 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.
*/
async open(): Promise<void> {
await this.page.goto('/account');
await expect(this.dialog).toBeVisible();
}
async logOut(): Promise<void> {
await this.logOutButton.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);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { Locator, Page, expect } from '@playwright/test';
/** The admin tabs, in the order the shell renders them. */
export type AdminTab =
| 'Inventory'
| 'Categories'
| 'Tags'
| 'Customers'
| 'Emails'
| 'Settings';
/**
* The admin shell: the tab strip and the panel it swaps.
*
* Only the active tab's panel is mounted, which is what keeps the locators
* inside each panel unambiguous — the email editors used to be stacked and a
* locator for "Save" matched all six.
*
* `activePanel` exists because several specs reached for
* `.ant-tabs-tabpane-active .ant-table` and similar to scope themselves to the
* visible panel. That knowledge belongs here rather than in five spec files.
*/
export class AdminPage {
readonly tabList: Locator;
readonly activePanel: Locator;
readonly activeTabLabel: Locator;
constructor(private readonly page: Page) {
this.tabList = page.getByRole('tablist');
this.activePanel = page.locator('.ant-tabs-tabpane-active').first();
this.activeTabLabel = page.locator('.ant-tabs-tab-active .ant-tabs-tab-btn').first();
}
async goto(): Promise<void> {
await this.page.goto('/admin');
}
tab(name: AdminTab | RegExp): Locator {
return this.page.getByRole('tab', { name });
}
/**
* Opens a tab from wherever the browser is, navigating to /admin first.
*
* Takes the navigation rather than assuming it, because every caller in the
* suite did both and half of them wrote the goto themselves.
*/
async open(name: AdminTab): Promise<void> {
await this.goto();
await this.openTab(name);
}
/** Switches tabs without renavigating, for a test that visits two of them. */
async openTab(name: AdminTab): Promise<void> {
await this.tab(name).click();
// The panel being mounted is the completion of the click. Without this a
// caller's first locator resolves against the outgoing panel.
await expect(this.activePanel).toBeVisible();
}
}
+88
View File
@@ -0,0 +1,88 @@
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 createAccountButton: 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');
this.marketingConsent = page.getByRole('checkbox');
this.createAccountButton = page.getByRole('button', { name: 'Create account' });
}
/** 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' });
}
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);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Locator, Page, expect } from '@playwright/test';
/**
* The site header, which is where a test finds out whether anyone is signed in.
*
* Registering and logging in both close their modal and return the customer to
* the page behind it rather than navigating to /account, so the URL says
* nothing about whether a session exists. The account button in the header is
* what does.
*/
export class Header {
readonly siteTitle: Locator;
readonly myAccountButton: Locator;
readonly logInButton: Locator;
readonly cartButton: Locator;
readonly themeToggle: Locator;
constructor(page: Page) {
this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' });
this.myAccountButton = page.getByRole('button', { name: 'My Account' });
this.logInButton = page.getByRole('button', { name: 'Log in' });
this.cartButton = page.getByRole('button', { name: /Cart/ });
this.themeToggle = page.getByRole('switch');
}
/**
* Waits until the header shows a signed-in customer.
*
* The timeout is generous because this waits on a bcrypt round-trip rather
* than a render, and every worker in a parallel run registers at once. The
* default five seconds is comfortably beaten on an idle machine and missed on
* a loaded one, which is the recipe for a test that fails only in CI.
*/
async waitForSignedIn(): Promise<void> {
await expect(this.myAccountButton).toBeVisible({ timeout: 20000 });
}
async waitForSignedOut(): Promise<void> {
await expect(this.myAccountButton).toHaveCount(0);
}
}
@@ -0,0 +1,72 @@
import { Locator, Page, expect } from '@playwright/test';
import { Header } from './Header';
/**
* The public catalogue.
*
* The item card locator is the one place in the suite that knows the storefront
* renders items into `.item-card` — three specs reached for that class directly,
* and two others used `.ant-col`, which is antd's grid rather than anything this
* application owns. Both break on an antd upgrade, in files that have nothing to
* do with the upgrade.
*/
export class StorefrontPage {
readonly header: Header;
readonly filtersButton: Locator;
readonly retryButton: Locator;
readonly loadFailureHeading: Locator;
constructor(private readonly page: Page) {
this.header = new Header(page);
this.filtersButton = page.getByRole('button', { name: /Filters/ });
this.retryButton = page.getByRole('button', { name: 'Retry' });
this.loadFailureHeading = page.getByRole('heading', { name: "The item list didn't load" });
}
async goto(): Promise<void> {
await this.page.goto('/');
}
/**
* Goes to the storefront and waits for the session to settle.
*
* Navigating remounts the app, so the session is briefly still resolving. The
* favorite control deliberately ignores clicks in that window rather than
* wrongly prompting a signed-in customer to sign in, so a test that clicks
* immediately gets nothing and no error. Waiting for the header is what a real
* customer sees settle too.
*/
async gotoSignedIn(): Promise<void> {
await this.goto();
await this.header.waitForSignedIn();
}
/** One item's card, located by the name shown on it. */
card(name: string): Locator {
return this.page.locator('.item-card').filter({ hasText: name });
}
addToCartButton(name: string): Locator {
return this.card(name).getByRole('button', { name: 'Add to Cart' });
}
/** The heart. Named for what it does rather than what it looks like. */
favoriteToggle(name: string): Locator {
return this.card(name).getByRole('button', { name: /favorite/i });
}
async openFilters(): Promise<void> {
await this.filtersButton.click();
}
/**
* Waits until the catalogue has rendered something.
*
* A test that asserts an item is absent needs to know the list arrived and did
* not contain it, rather than that it asserted before the fetch resolved —
* which passes for the wrong reason and keeps passing when the filter breaks.
*/
async waitForAnyItem(): Promise<void> {
await expect(this.page.locator('.item-card').first()).toBeVisible();
}
}
+92
View File
@@ -0,0 +1,92 @@
import { APIRequestContext, expect } from '@playwright/test';
/**
* Seeding through the admin API.
*
* Every spec that needs stock used to write its own version of this, and the
* copies had drifted: some checked `res.ok()`, some checked `res.status()`,
* some checked nothing, and one built its own request context against a
* hardcoded `http://localhost:5173` rather than the configured baseURL.
*
* Seeding through the API rather than the UI on purpose. Creating an item by
* driving the admin form makes every storefront test depend on the admin form
* working, so a broken form fails a hundred tests that are not about it.
*/
export interface SeededItem {
id: number;
name: string;
}
/** Unique enough to survive a parallel run and a database nobody truncated. */
export function uniqueSuffix(): string {
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
}
export function uniqueEmail(prefix = 'pw'): string {
return `${prefix}-${uniqueSuffix()}@example.com`;
}
interface CreateItemOptions {
name: string;
price?: string;
description?: string;
categoryId?: number | null;
tags?: string[];
/**
* New items are created pending, and the storefront lists only published
* ones. Most fixtures stand in for ordinary stock rather than staged drafts,
* so publishing is the default — a spec that wants a draft asks for one.
*/
publish?: boolean;
}
export async function createItem(
api: APIRequestContext,
options: CreateItemOptions
): Promise<SeededItem> {
const res = await api.post('/api/admin/items', {
// multipart rather than JSON: the route accepts image uploads, so it reads
// its fields from a multipart body even when no image is attached.
multipart: {
name: options.name,
description: options.description ?? '',
price: options.price ?? '50',
category_id: options.categoryId == null ? '' : String(options.categoryId),
tags: JSON.stringify(options.tags ?? [])
}
});
expect(res.ok(), `creating item ${options.name}`).toBeTruthy();
const item = { id: (await res.json()).id as number, name: options.name };
if (options.publish ?? true) {
await publishItem(api, item.id);
}
return item;
}
export async function publishItem(api: APIRequestContext, id: number): Promise<void> {
const res = await api.post(`/api/admin/items/${id}/mark-available`);
expect(res.ok(), `publishing item ${id}`).toBeTruthy();
}
export async function sellItem(api: APIRequestContext, id: number): Promise<void> {
const res = await api.post(`/api/admin/items/${id}/mark-sold`);
expect(res.ok(), `selling item ${id}`).toBeTruthy();
}
export async function createCategory(
api: APIRequestContext,
name: string,
parentId: number | null = null
): Promise<number> {
const res = await api.post('/api/admin/categories', { data: { name, parent_id: parentId } });
expect(res.status(), `creating category ${name}`).toBe(201);
return (await res.json()).id as number;
}
export async function createTag(api: APIRequestContext, name: string): Promise<number> {
const res = await api.post('/api/admin/tags', { data: { name } });
expect(res.ok(), `creating tag ${name}`).toBeTruthy();
return (await res.json()).id as number;
}
+69
View File
@@ -0,0 +1,69 @@
import { Client } from 'pg';
/**
* Direct database access for the tests, for the one thing the API deliberately
* will not give them.
*
* The password-reset token is only ever delivered by email, which these tests
* cannot read. It is read from the database rather than through a helper
* endpoint because an endpoint that returns a reset token for an arbitrary
* address is account takeover for every customer if it is ever reachable, and
* an environment gate is a thin thing to stand between that and production.
* Doing it here keeps the capability entirely inside the test process.
*
* That reasoning is unchanged from when it lived inline in
* password-reset.spec.ts. What has changed is that it is no longer sitting in a
* spec where it can be copied into the next one that wants a shortcut.
*/
/**
* The default port is 55500, matching scripts/start-local.ps1.
*
* It used to be 55432, which is the integration suite's disposable Postgres —
* a different database, with different credentials, that the app under test is
* not connected to. Worse, 55432 is reserved by Hyper-V on at least one machine
* here, so the spec failed with a bare ECONNREFUSED naming a port nobody had
* chosen. TEST_PGPORT still overrides, for CI and for anyone running the stack
* somewhere else.
*/
function connectionSettings() {
return {
host: process.env.TEST_PGHOST || 'localhost',
port: parseInt(process.env.TEST_PGPORT || '55500', 10),
user: process.env.TEST_PGUSER || 'redefined_local',
password: process.env.TEST_PGPASSWORD || 'redefined_local',
database: process.env.TEST_PGDATABASE || 'redefined_local'
};
}
/** Opens a connection, runs the query, and closes it whatever happens. */
async function withClient<T>(run: (client: Client) => Promise<T>): Promise<T> {
const client = new Client(connectionSettings());
await client.connect();
try {
return await run(client);
} finally {
await client.end();
}
}
/**
* The most recent password-reset token issued to an address.
*
* Throws rather than returning null: every caller is about to build a URL from
* it, and a missing token means the request under test did not do what it said,
* which is worth failing loudly at the point it happened.
*/
export async function readPasswordResetToken(email: string): Promise<string> {
return withClient(async (client) => {
const { rows } = await client.query(
`SELECT t.token FROM customer_tokens t
JOIN customers c ON c.id = t.customer_id
WHERE c.email = $1 AND t.kind = 'password_reset'
ORDER BY t.created_at DESC LIMIT 1`,
[email]
);
if (!rows.length) throw new Error(`no password_reset token issued for ${email}`);
return rows[0].token as string;
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"//": "Type-checks tests/. Separate from tsconfig.json rather than widening its `include`, because scripts/check-sonar-tsconfig.js compares the two configs' `include` arrays and tsconfig.sonar.json exists only so SonarQube 9.9 can build a program over src — pulling the Playwright suite into that analysis is a different decision from type-checking it. Run by `npm run build`; see #137.",
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"]
},
"include": ["tests"]
}