Files
redefined-designs/frontend/tests/e2e/fixtures.ts
T
bermudalamb 9b3a03d1bf
Linting / lint (pull_request) Successful in 2m2s
SonarQube Analysis / sonarqube (pull_request) Successful in 17m17s
test(e2e): convert the storefront and filter specs onto page objects (#137)
storefront, storefront-errors, theme, error-boundary and filters.

filters.spec.ts carried the last hand-rolled copies of createCategory, createTag and createItem, and the hardcoded `http://localhost:5173` that meant changing the port in the config would have moved every test except this one. Seeding now goes through support/api, and the host it needs lives in one constant. It has to be a constant rather than the config's baseURL: beforeAll runs with worker-scoped fixtures only and cannot read a test-scoped option, which is why the URL was inlined there in the first place.

New FilterDrawer object. The two rules it encodes are the ones the tests exist to pin down and neither is guessable from a locator: categories are a tree because the filter matches a node and everything filed beneath it, and tags combine with AND rather than OR, so selecting two means "must have both".

The active-filter chips go on StorefrontPage rather than the drawer, because that is where they render — and the scoping matters, since the drawer carries a "Clear all" of its own that an unscoped locator also matches.

StorefrontPage gains the three things the catalogue says instead of listing items. They are named together deliberately: the distinction between "No items yet" and "Couldn't load items" is the point, and several tests assert one is showing while the other is not, because telling a customer the shop is empty when the server is broken hides the outage.

The theme switch and the attribute it writes are both on Header now. The switch is in the header and `data-theme` lands on <body>, so a spec previously had to know about `body` to observe the control it had just clicked.

Verified: 12/12 across the four small specs, 8/8 on filters, tsc clean, lint unchanged at the 30-warning src baseline.

Refs #137
2026-08-23 17:50:59 -05:00

155 lines
5.3 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 { 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';
// 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;
}
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));
},
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 }
]
});