favorites, favorites-filter, sold-filter, orders and pending-publish. Five more copies of "register a customer" and three more of the hardcoded base URL go with them. Two locators that were hiding real knowledge are now named. `gridCell` is the item's whole antd column rather than its card, needed because the SOLD ribbon renders outside the card — three specs reached for `.ant-col` directly to get at it. And `chooseAvailability` goes through the title attribute because antd's Segmented hides the real radio behind a styled label, so the input is found by role and cannot be clicked; that fact was written out twice in comments and is now written once in code. The favorite control is located page-wide rather than within a card. The storefront paginates as items accumulate and the control is named for its item anyway, so scoping to a card bought nothing and broke whenever the card was on another page. favorites-filter keeps its local `favorite()` helper. It is genuinely local — decline the opt-in, wait for the fading modal to stop intercepting pointer events, confirm the heart flipped — and belongs to that file's subject rather than to the storefront. It now takes page objects as parameters instead of reaching for locators itself, which is what a spec-level helper should look like. Two specs still drive the registration form rather than taking the `customer` fixture, and deliberately. Both are about a signed-out visitor being interrupted mid-action — favoriting an item, or switching on the favorites filter — and the claim is that the thing they asked for survives the interruption. Replacing the interruption with an API call would delete the test. Verified: favorites 6/6, favorites-filter 7/7, sold-filter 6/6, orders and pending-publish 8/8. Notably favorites-filter's "keeps showing a favorite after it sells" passes, which had been failing on a strict-mode violation from two items sharing a name across runs. Refs #137
180 lines
6.3 KiB
TypeScript
180 lines
6.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 { FavoritePrompt } from './pages/FavoritePrompt';
|
|
import { OrdersPage } from './pages/OrdersPage';
|
|
import { AdminInventory } from './pages/AdminInventory';
|
|
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';
|
|
|
|
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;
|
|
}
|
|
|
|
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));
|
|
},
|
|
|
|
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 }
|
|
]
|
|
});
|