First conversion batch: auth, password-reset, resend-verification. verify-email is left alone — it already used semantic locators and duplicated nothing, and rewriting it to prove a point would be churn. Four of the nine copies of "register a customer" go here. auth.spec.ts and password-reset.spec.ts each carried their own `register`, and resend-verification.spec.ts its own `registerCustomer`, all three re-explaining the same bcrypt wait and the same "the header, not the URL, proves the session exists" reasoning in slightly different words. Two also carried their own `logout`, and two their own `uniqueEmail` with different prefixes. Most of those tests were not about registering. They needed an account to exist so they could test logging out, resetting a password, or resending a verification email, and they paid for a bcrypt round-trip through the form to get one. Those now take the `customer` fixture, which registers through the API. The three tests that genuinely are about the registration form still drive it, because the thing under test has to be the thing exercised. The batch drops from 47s to 26s as a side effect, which is the cost of that round-trip made visible. password-reset.spec.ts loses its inline pg.Client. The reasoning for reading the database directly is unchanged and still right — an endpoint returning a reset token for an arbitrary address is account takeover if it is ever reachable — but it now lives in support/db.ts where it cannot be copied into the next spec wanting a shortcut. It also stops defaulting to port 55432, which is the integration suite's disposable Postgres rather than the database the app under test is connected to, and is Hyper-V-reserved on at least one machine here. Both tests in that file previously failed with a bare ECONNREFUSED unless TEST_PGPORT was set by hand; they now pass with no environment at all. New PasswordResetPages object covers both halves of recovery — requesting a link, and using one — because they are one flow and a test usually crosses between them. One lint decision worth recording. Requesting a Playwright fixture IS using it: destructuring `customer` is what makes the account exist, whether or not the body then reads the address. The linter cannot see that side effect and reports every such fixture as an unused variable. The first attempt at appeasing it was a `void customer;` line per test, which is noise standing in for a comment — and sonarjs flags that too, so it traded one warning for another. `no-unused-vars` is now configured with `args: 'none'` for tests only, with the reason written next to it. Variables are still checked; only parameters are exempt. Verified: 26/26 in the converted batch, and 127 passed in the full suite with four failures — three in the known #116 flaky family, and resend-verification's rate-limit test, which passes 9/9 across three repeats in isolation and is timing-sensitive under parallel load rather than changed by this commit. Refs #137
150 lines
5.1 KiB
TypeScript
150 lines
5.1 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 { 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;
|
|
}
|
|
|
|
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));
|
|
},
|
|
|
|
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 }
|
|
]
|
|
});
|