The last nine: admin-taxonomy, admin-save-failures, admin-theme, admin-inline-category, admin-item-preview, admin-disable-customer, admin-reserved-items, admin-inventory-filters, email-templates and admin-email-settings. Every spec in tests/e2e now goes through page objects. Measured rather than asserted: - raw CSS and antd-internal locators in spec files: 0 (was 13) - local `register` helpers: 0 (was 9) - hardcoded http://localhost:5173: 0 (was 3) The antd knowledge that was spread across seven spec files is now in four page objects, each with the reason written next to it. Three of those are things no reader could have guessed from the locator: Segmented hides its real radio behind a styled label, so the input is found by role and cannot be clicked — the title attribute is the handle. The status multi-select renders an invisible role="listbox" shim beside the real list, so getByRole('option') finds something zero-sized; and a selected status renders again as a tag carrying the same title, so an unscoped getByTitle is ambiguous. Matching the visible option class avoids both. The dropdown is also opened only when closed, because antd keeps it open after a selection in multiple mode. Select popups render into a portal at the end of <body>, outside the tab panel they belong to, so a dropdown cannot be found by scoping to the panel. AdminPage.tab gained an `exact` flag for one specific collision: the Emails tab contains a rail that also renders tabs, and "Email verification" contains "Email". Without exact matching, opening the Emails tab is ambiguous with the template inside it. Two helpers stayed local rather than moving into page objects, because they belong to their file's subject rather than to a surface: favorites-filter's `favorite()`, which settles the opt-in modal and waits for its fade before the wrapper stops intercepting pointer events, and admin-theme's `luminance()`, which is a WCAG calculation and not a locator. Both now take page objects as parameters instead of reaching for locators themselves. Verified: 26/26 spec files converted, tsc clean over the whole tree, lint at the 30-warning src baseline with nothing added. Full suite 123 passed / 5 failed; all five pass in a 33/33 serial re-run, which is the load-related flakiness this suite has had throughout and not a change here — the backend hashes passwords with bcryptjs, a pure-JS implementation that blocks the event loop for every request while it runs. Closes #137
204 lines
7.2 KiB
TypeScript
204 lines
7.2 KiB
TypeScript
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 { PasswordResetPages } from './pages/PasswordResetPages';
|
|
import { FilterDrawer } from './pages/FilterDrawer';
|
|
import { FavoritePrompt } from './pages/FavoritePrompt';
|
|
import { OrdersPage } from './pages/OrdersPage';
|
|
import { AdminInventory } from './pages/AdminInventory';
|
|
import { AdminTaxonomy } from './pages/AdminTaxonomy';
|
|
import { AdminCustomers } from './pages/AdminCustomers';
|
|
import { AdminEmails } from './pages/AdminEmails';
|
|
import { AdminSettings } from './pages/AdminSettings';
|
|
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';
|
|
export { Header } from './pages/Header';
|
|
export { AuthModal } from './pages/AuthModal';
|
|
export { AccountModal } from './pages/AccountModal';
|
|
export { StorefrontPage } from './pages/StorefrontPage';
|
|
export { AdminPage } from './pages/AdminPage';
|
|
export { PasswordResetPages } from './pages/PasswordResetPages';
|
|
export { FilterDrawer } from './pages/FilterDrawer';
|
|
export { FavoritePrompt } from './pages/FavoritePrompt';
|
|
export { OrdersPage } from './pages/OrdersPage';
|
|
export { AdminInventory } from './pages/AdminInventory';
|
|
export { AdminTaxonomy } from './pages/AdminTaxonomy';
|
|
export { AdminCustomers } from './pages/AdminCustomers';
|
|
export { AdminEmails } from './pages/AdminEmails';
|
|
export { AdminSettings } from './pages/AdminSettings';
|
|
|
|
const NYC_OUTPUT = path.resolve(__dirname, '..', '..', '.nyc_output');
|
|
const collectingCoverage = process.env.COVERAGE === 'true';
|
|
|
|
// Set once any page reports instrumentation. Checked by the collection script so
|
|
// 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;
|
|
passwordReset: PasswordResetPages;
|
|
filterDrawer: FilterDrawer;
|
|
favoritePrompt: FavoritePrompt;
|
|
orders: OrdersPage;
|
|
adminInventory: AdminInventory;
|
|
adminTaxonomy: AdminTaxonomy;
|
|
adminCustomers: AdminCustomers;
|
|
adminEmails: AdminEmails;
|
|
adminSettings: AdminSettings;
|
|
}
|
|
|
|
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.
|
|
*
|
|
* Coverage lives in `window.__coverage__` on the page and dies with it, so it
|
|
* 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<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));
|
|
},
|
|
passwordReset: async ({ page }, use) => {
|
|
await use(new PasswordResetPages(page));
|
|
},
|
|
filterDrawer: async ({ page }, use) => {
|
|
await use(new FilterDrawer(page));
|
|
},
|
|
favoritePrompt: async ({ page }, use) => {
|
|
await use(new FavoritePrompt(page));
|
|
},
|
|
orders: async ({ page }, use) => {
|
|
await use(new OrdersPage(page));
|
|
},
|
|
adminInventory: async ({ page }, use) => {
|
|
await use(new AdminInventory(page));
|
|
},
|
|
adminTaxonomy: async ({ page }, use) => {
|
|
await use(new AdminTaxonomy(page));
|
|
},
|
|
adminCustomers: async ({ page }, use) => {
|
|
await use(new AdminCustomers(page));
|
|
},
|
|
adminEmails: async ({ page }, use) => {
|
|
await use(new AdminEmails(page));
|
|
},
|
|
adminSettings: async ({ page }, use) => {
|
|
await use(new AdminSettings(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();
|
|
|
|
if (!collectingCoverage) return;
|
|
|
|
// The page may already be closed by a test that navigated away or crashed;
|
|
// a missing sample is not worth failing a passing test over. The run-level
|
|
// check catches the case that actually matters — no samples at all.
|
|
const coverage = await page
|
|
.evaluate(() => (window as unknown as { __coverage__?: unknown }).__coverage__)
|
|
.catch(() => undefined);
|
|
|
|
if (!coverage) return;
|
|
|
|
mkdirSync(NYC_OUTPUT, { recursive: true });
|
|
writeFileSync(path.join(NYC_OUTPUT, `${randomUUID()}.json`), JSON.stringify(coverage));
|
|
writeFileSync(MARKER, 'ok');
|
|
},
|
|
{ auto: true }
|
|
]
|
|
});
|