Files
redefined-designs/frontend/tests/e2e/fixtures.ts
bermudalamb 5ef97bef21
Linting / lint (pull_request) Successful in 2m10s
SonarQube Analysis / sonarqube (pull_request) Failing after 4m53s
fix(cart): make the reservation countdown tick, and warn against the real hold (#97)
The cart showed "2h 15m left" and turned it red in the final hour. Neither updated. Both readings happened during render from the wall clock, and nothing scheduled a re-render — no setInterval anywhere in the file — so the number a customer read was whatever it was when the page loaded, and the warning colour could only appear by accident, because the component had already rendered before the final stretch began.

That matters more here than in most shops: every item is one of a kind, so a lapsed reservation is not "buy it later", it is someone else buying the only one.

New useNow hook returns the time as state rather than merely forcing a re-render, and that is the point. A component reading Date.now() while rendering produces output that depends on the clock, which React is entitled to assume it does not — react-hooks/purity says so, and this was the only instance in the codebase precisely because it was the only place doing it. Reading `now` from state makes render a function of its inputs again, so the rule is satisfied rather than suppressed. It ticks every 30s, which matches the display's one-minute resolution, and only while the cart holds something, so an empty cart is not waking React forever.

A second defect the issue did not mention. The red warning was hardcoded to the final hour, but the hold became admin-configurable in #136 and accepts values as low as half an hour — so on any setting below an hour every item was red from the moment it was reserved, and a warning that is always on is not a warning. It now keys off the last tenth of the item's own added_at-to-expires_at span. Reading it from the item rather than from the setting also means an admin changing the value does not retroactively relabel a reservation granted under the old one.

At zero the row keeps saying "expiring…" and the page refetches on each tick while anything is lapsed, so it clears within one interval of the server's sweep actually releasing it. The client cannot know when that lands — the sweep runs every few minutes — so the wording claims imminence, which is true, rather than completion, which is not ours to say. The header badge is refreshed alongside, since it counts held items and goes stale the same way.

Verified against the unfixed component, not just the fixed one: two of the three new tests fail on the old code. The third documents the "expiring…" wording rather than the fix, and passes either way — worth having, but it is not evidence.

The tests use Playwright's clock control rather than waiting in real time, which also makes them deterministic: without it, "the text changed" would depend on where in the minute the run happened to start.

Refs #97
2026-08-24 08:56:45 -05:00

210 lines
7.4 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 { CartPage } from './pages/CartPage';
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';
export { CartPage } from './pages/CartPage';
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;
cart: CartPage;
}
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));
},
cart: async ({ page }, use) => {
await use(new CartPage(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 }
]
});