test(e2e): add page objects, fixtures and a typed test build (#137)
Linting / lint (pull_request) Successful in 2m13s
SonarQube Analysis / sonarqube (pull_request) Successful in 17m49s

Foundation only. No spec is converted in this commit, so the suite behaves exactly as before — the conversions follow in themed batches, each leaving the suite green.

The suite had grown by copy-paste. Registering a customer was implemented nine times, as `register` in five files and `registerCustomer` in four more, each carrying its own re-explanation of the same bcrypt wait and the same "the header, not the URL, proves the session exists" reasoning. `uniqueEmail` was reinvented per file with a different prefix and a different encoding each time. Thirteen locators reached into antd's internals — `.ant-tabs-tab-active .ant-tabs-tab-btn`, `.ant-select-item-option[title=...]`, `.ant-col` — spread across seven files, so an antd upgrade breaks tests that have nothing to do with it.

Page objects hold named locators and the actions that operate on them; assertions stay in the specs, so a test reads as its own statement of what it verifies. The exception is an action waiting for its own completion — registering waits for the account button, opening an admin tab waits for its panel — because that wait is the action's contract, and pushing it to callers would recreate the duplication being removed.

Fixtures carry the setup rather than the specs. `customer` registers through the API rather than the form: 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. `page.request` shares the browser context's cookie jar, so the session belongs to `page`. The specs that are genuinely about registration drive the form properly through `authModal`.

`adminApi` takes its base URL from the Playwright config. One spec built its own request context against a hardcoded http://localhost:5173, so changing the port in the config would have moved every test except that one.

The inline pg.Client in password-reset.spec.ts moves to support/db.ts. The reasoning for reading the database directly is unchanged and still right — an endpoint that returns a reset token for an arbitrary address is account takeover if it is ever reachable, and an environment gate is a thin thing to stand between that and production — but it no longer sits in a spec where it can be copied into the next one wanting a shortcut. Its default port becomes 55500, the local stack's, rather than 55432: that is the integration suite's disposable Postgres, a different database with different credentials that the app under test is not connected to, and it is Hyper-V-reserved on at least one machine here, so the spec failed with a bare ECONNREFUSED naming a port nobody had chosen.

tsconfig.test.json type-checks the tree and runs as part of `npm run build`. It is separate from tsconfig.json rather than widening its `include`, because scripts/check-sonar-tsconfig.js compares the two configs' include arrays, and pulling the Playwright suite into SonarQube's analysis program is a different decision from type-checking it. The whole existing suite type-checks clean on the first run.

Lint now covers tests/ with `project` rather than `projectService` — the service resolves a file to the nearest tsconfig.json, which for tests/ is the one that excludes them, and every file then errors as not part of a project. no-floating-promises is an error here: Playwright's API is almost entirely promises, and a missing await on an assertion does not fail, it passes having asserted nothing.

Four rule families are switched off for tests rather than left as warnings. Bringing these files in scope added 45, of which none were defects, and #60's argument is that a gate nobody reads is not a gate. There is no React in this directory, and the hooks rules fire on ordinary functions whose parameter is named `use` — which Playwright fixtures are, by its own API. Test credentials are the point of a test and the project's own rule is that they live only in test paths, which is here. Math.random builds unique fixture names so parallel workers do not collide, and a cryptographic generator would say something untrue about what the value is for. The count is back to the 30 that src carried before.

Refs #137
This commit is contained in:
2026-08-23 17:11:03 -05:00
parent b2afa40800
commit 5c907fcf9a
11 changed files with 652 additions and 7 deletions
+54 -3
View File
@@ -50,9 +50,6 @@ export default tseslint.config(
{ plugins: { 'jsx-a11y': jsxA11y } },
{
// `tests/` and the Playwright specs are deliberately out of scope for now:
// tsconfig.json only includes `src`, so type-aware linting has no program
// for them, and widening it is a separate change with its own count.
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
globals: globals.browser,
@@ -83,5 +80,59 @@ export default tseslint.config(
// the fix is one attribute.
'jsx-a11y/alt-text': 'error',
},
},
{
// The Playwright suite. Out of scope until #137, because tsconfig.json
// includes only `src` and type-aware linting had no program for these
// files. tsconfig.test.json is that program.
//
// `project` rather than `projectService`: the service resolves a file to the
// nearest tsconfig.json, which for tests/ is the one that excludes them, and
// every file then errors as not part of a project.
files: ['tests/**/*.ts'],
languageOptions: {
globals: globals.node,
parserOptions: {
project: ['./tsconfig.test.json'],
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// The one that matters most here. Playwright's API is almost entirely
// promises, and a missing `await` on an assertion does not fail — it
// passes, having asserted nothing, which is the worst outcome a test can
// have. The project's own Playwright notes already warn about it; this
// enforces it.
'@typescript-eslint/no-floating-promises': 'error',
// Switched off for tests rather than left as warnings. #60's whole
// argument is that a gate nobody reads is not a gate, and bringing these
// files in scope added 45 warnings of which none were defects. A rule
// that cannot be true here is noise that hides the rules that can.
//
// There is no React in this directory. The hooks rules fire on ordinary
// functions whose parameter happens to be named `use` — which Playwright
// fixtures are, by its own API.
'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/purity': 'off',
// Test credentials are the point of a test, and the project's own rule is
// that they must live only in test paths — which is here. Flagging them
// where they belong trains the reader to ignore the rule where they do
// not.
'sonarjs/no-hardcoded-passwords': 'off',
// Math.random builds unique fixture names so parallel workers do not
// collide. Nothing here is a secret, and a cryptographic generator would
// say something untrue about what the value is for.
'sonarjs/pseudo-random': 'off',
// Page objects hold locators built in the constructor and never
// reassigned. Flagging them as mutable props does not apply to a class.
'sonarjs/prefer-read-only-props': 'off',
},
}
);
+2 -2
View File
@@ -4,8 +4,8 @@
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src",
"build": "tsc && tsc -p tsconfig.test.json --noEmit && vite build",
"lint": "eslint src tests",
"test:e2e": "playwright test",
"test:e2e:cov": "cross-env COVERAGE=true playwright test",
"coverage:report": "node scripts/coverage-report.js"
+99 -2
View File
@@ -1,12 +1,19 @@
import { test as base } from '@playwright/test';
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 { 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';
@@ -15,6 +22,49 @@ const collectingCoverage = process.env.COVERAGE === 'true';
// 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;
}
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.
*
@@ -22,7 +72,54 @@ const MARKER = path.join(NYC_OUTPUT, '.collected');
* 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<{ collectCoverage: void }>({
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));
},
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();
+66
View File
@@ -0,0 +1,66 @@
import { Locator, Page, expect } from '@playwright/test';
/**
* The customer's own account view, which is a modal over the storefront rather
* than a page of its own — /account renders the storefront with this on top.
*
* Fields are scoped to the dialog. Several of them ("Password", "First name")
* share a label with the auth modal, and the two can both be in the DOM while
* one is closing.
*/
export class AccountModal {
readonly dialog: Locator;
readonly logOutButton: Locator;
readonly orderHistoryButton: Locator;
readonly closeButton: Locator;
readonly firstName: Locator;
readonly lastName: Locator;
readonly saveNameButton: Locator;
readonly currentPassword: Locator;
readonly newPassword: Locator;
readonly confirmNewPassword: Locator;
readonly newEmail: Locator;
readonly passwordForEmailChange: Locator;
constructor(private readonly page: Page) {
this.dialog = page.getByRole('dialog', { name: 'My Account' });
this.logOutButton = this.dialog.getByRole('button', { name: 'Log out' });
this.orderHistoryButton = this.dialog.getByRole('button', { name: 'View order history' });
this.closeButton = this.dialog.getByRole('button', { name: 'Close' });
this.firstName = this.dialog.getByLabel('First name', { exact: true });
this.lastName = this.dialog.getByLabel('Last name', { exact: true });
this.saveNameButton = this.dialog.getByRole('button', { name: 'Save name' });
this.currentPassword = this.dialog.getByLabel('Current password', { exact: true });
this.newPassword = this.dialog.getByLabel('New password', { exact: true });
this.confirmNewPassword = this.dialog.getByLabel('Confirm new password', { exact: true });
this.newEmail = this.dialog.getByLabel('New email address', { exact: true });
this.passwordForEmailChange = this.dialog.getByLabel('Your password', { exact: true });
}
/**
* Opens the account view and waits for it.
*
* The wait is the action's contract rather than an assertion: everything a
* caller does next is scoped to this dialog, and a locator resolved before it
* exists finds nothing.
*/
async open(): Promise<void> {
await this.page.goto('/account');
await expect(this.dialog).toBeVisible();
}
async logOut(): Promise<void> {
await this.logOutButton.click();
}
/** The address the account view shows, which is how a test knows whose it is. */
emailText(email: string): Locator {
return this.dialog.getByText(email);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { Locator, Page, expect } from '@playwright/test';
/** The admin tabs, in the order the shell renders them. */
export type AdminTab =
| 'Inventory'
| 'Categories'
| 'Tags'
| 'Customers'
| 'Emails'
| 'Settings';
/**
* The admin shell: the tab strip and the panel it swaps.
*
* Only the active tab's panel is mounted, which is what keeps the locators
* inside each panel unambiguous — the email editors used to be stacked and a
* locator for "Save" matched all six.
*
* `activePanel` exists because several specs reached for
* `.ant-tabs-tabpane-active .ant-table` and similar to scope themselves to the
* visible panel. That knowledge belongs here rather than in five spec files.
*/
export class AdminPage {
readonly tabList: Locator;
readonly activePanel: Locator;
readonly activeTabLabel: Locator;
constructor(private readonly page: Page) {
this.tabList = page.getByRole('tablist');
this.activePanel = page.locator('.ant-tabs-tabpane-active').first();
this.activeTabLabel = page.locator('.ant-tabs-tab-active .ant-tabs-tab-btn').first();
}
async goto(): Promise<void> {
await this.page.goto('/admin');
}
tab(name: AdminTab | RegExp): Locator {
return this.page.getByRole('tab', { name });
}
/**
* Opens a tab from wherever the browser is, navigating to /admin first.
*
* Takes the navigation rather than assuming it, because every caller in the
* suite did both and half of them wrote the goto themselves.
*/
async open(name: AdminTab): Promise<void> {
await this.goto();
await this.openTab(name);
}
/** Switches tabs without renavigating, for a test that visits two of them. */
async openTab(name: AdminTab): Promise<void> {
await this.tab(name).click();
// The panel being mounted is the completion of the click. Without this a
// caller's first locator resolves against the outgoing panel.
await expect(this.activePanel).toBeVisible();
}
}
+88
View File
@@ -0,0 +1,88 @@
import { Locator, Page, expect } from '@playwright/test';
/**
* The register / log in form.
*
* It is a modal wherever it appears — over the storefront at /register and
* /login, and over whatever the customer was doing when they clicked Add to
* Cart while signed out. One object covers all three, because it is one
* component (`AuthForm`, shared by `AuthRouteModal` and `AuthPromptModal`).
*
* Locators are scoped to the dialog rather than the page. The storefront behind
* the modal has a "Log in" button of its own — the one that opened this — so an
* unscoped `getByRole('button', { name: 'Log in' })` matches two things and
* fails on strict mode, which is a confusing way to learn that the modal is a
* modal.
*/
export class AuthModal {
readonly registerDialog: Locator;
readonly logInDialog: Locator;
readonly email: Locator;
readonly firstName: Locator;
readonly lastName: Locator;
readonly password: Locator;
readonly marketingConsent: Locator;
readonly createAccountButton: Locator;
constructor(private readonly page: Page) {
this.registerDialog = page.getByRole('dialog', { name: 'Create an account' });
this.logInDialog = page.getByRole('dialog', { name: 'Log in' });
this.email = page.getByRole('textbox', { name: 'Email' });
this.firstName = page.getByRole('textbox', { name: 'First name' });
this.lastName = page.getByRole('textbox', { name: 'Last name' });
this.password = page.getByLabel('Password');
this.marketingConsent = page.getByRole('checkbox');
this.createAccountButton = page.getByRole('button', { name: 'Create account' });
}
/** The submit button inside the log in dialog, not the one that opened it. */
get submitLogInButton(): Locator {
return this.logInDialog.getByRole('button', { name: 'Log in' });
}
async gotoRegister(): Promise<void> {
await this.page.goto('/register');
}
async gotoLogIn(): Promise<void> {
await this.page.goto('/login');
}
/** Fills the registration form without submitting, for tests about validation. */
async fillRegistration(details: {
email: string;
password: string;
firstName?: string;
lastName?: string;
}): Promise<void> {
await this.email.fill(details.email);
if (details.firstName !== undefined) await this.firstName.fill(details.firstName);
if (details.lastName !== undefined) await this.lastName.fill(details.lastName);
await this.password.fill(details.password);
}
async submitRegistration(): Promise<void> {
await this.createAccountButton.click();
}
async fillCredentials(email: string, password: string): Promise<void> {
await this.email.fill(email);
await this.password.fill(password);
}
async submitLogIn(): Promise<void> {
await this.submitLogInButton.click();
}
/**
* Waits for the modal to close, which is what registering or logging in does.
*
* The dialog going away is the completion of the action rather than an
* assertion about it: a test that carries on while the modal is still over
* the page clicks the modal's backdrop instead of what it meant to.
*/
async waitForDismissal(): Promise<void> {
await expect(this.registerDialog).toHaveCount(0);
await expect(this.logInDialog).toHaveCount(0);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Locator, Page, expect } from '@playwright/test';
/**
* The site header, which is where a test finds out whether anyone is signed in.
*
* Registering and logging in both close their modal and return the customer to
* the page behind it rather than navigating to /account, so the URL says
* nothing about whether a session exists. The account button in the header is
* what does.
*/
export class Header {
readonly siteTitle: Locator;
readonly myAccountButton: Locator;
readonly logInButton: Locator;
readonly cartButton: Locator;
readonly themeToggle: Locator;
constructor(page: Page) {
this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' });
this.myAccountButton = page.getByRole('button', { name: 'My Account' });
this.logInButton = page.getByRole('button', { name: 'Log in' });
this.cartButton = page.getByRole('button', { name: /Cart/ });
this.themeToggle = page.getByRole('switch');
}
/**
* Waits until the header shows a signed-in customer.
*
* The timeout is generous because this waits on a bcrypt round-trip rather
* than a render, and every worker in a parallel run registers at once. The
* default five seconds is comfortably beaten on an idle machine and missed on
* a loaded one, which is the recipe for a test that fails only in CI.
*/
async waitForSignedIn(): Promise<void> {
await expect(this.myAccountButton).toBeVisible({ timeout: 20000 });
}
async waitForSignedOut(): Promise<void> {
await expect(this.myAccountButton).toHaveCount(0);
}
}
@@ -0,0 +1,72 @@
import { Locator, Page, expect } from '@playwright/test';
import { Header } from './Header';
/**
* The public catalogue.
*
* The item card locator is the one place in the suite that knows the storefront
* renders items into `.item-card` — three specs reached for that class directly,
* and two others used `.ant-col`, which is antd's grid rather than anything this
* application owns. Both break on an antd upgrade, in files that have nothing to
* do with the upgrade.
*/
export class StorefrontPage {
readonly header: Header;
readonly filtersButton: Locator;
readonly retryButton: Locator;
readonly loadFailureHeading: Locator;
constructor(private readonly page: Page) {
this.header = new Header(page);
this.filtersButton = page.getByRole('button', { name: /Filters/ });
this.retryButton = page.getByRole('button', { name: 'Retry' });
this.loadFailureHeading = page.getByRole('heading', { name: "The item list didn't load" });
}
async goto(): Promise<void> {
await this.page.goto('/');
}
/**
* Goes to the storefront and waits for the session to settle.
*
* Navigating remounts the app, so the session is briefly still resolving. The
* favorite control deliberately ignores clicks in that window rather than
* wrongly prompting a signed-in customer to sign in, so a test that clicks
* immediately gets nothing and no error. Waiting for the header is what a real
* customer sees settle too.
*/
async gotoSignedIn(): Promise<void> {
await this.goto();
await this.header.waitForSignedIn();
}
/** One item's card, located by the name shown on it. */
card(name: string): Locator {
return this.page.locator('.item-card').filter({ hasText: name });
}
addToCartButton(name: string): Locator {
return this.card(name).getByRole('button', { name: 'Add to Cart' });
}
/** The heart. Named for what it does rather than what it looks like. */
favoriteToggle(name: string): Locator {
return this.card(name).getByRole('button', { name: /favorite/i });
}
async openFilters(): Promise<void> {
await this.filtersButton.click();
}
/**
* Waits until the catalogue has rendered something.
*
* A test that asserts an item is absent needs to know the list arrived and did
* not contain it, rather than that it asserted before the fetch resolved —
* which passes for the wrong reason and keeps passing when the filter breaks.
*/
async waitForAnyItem(): Promise<void> {
await expect(this.page.locator('.item-card').first()).toBeVisible();
}
}
+92
View File
@@ -0,0 +1,92 @@
import { APIRequestContext, expect } from '@playwright/test';
/**
* Seeding through the admin API.
*
* Every spec that needs stock used to write its own version of this, and the
* copies had drifted: some checked `res.ok()`, some checked `res.status()`,
* some checked nothing, and one built its own request context against a
* hardcoded `http://localhost:5173` rather than the configured baseURL.
*
* Seeding through the API rather than the UI on purpose. Creating an item by
* driving the admin form makes every storefront test depend on the admin form
* working, so a broken form fails a hundred tests that are not about it.
*/
export interface SeededItem {
id: number;
name: string;
}
/** Unique enough to survive a parallel run and a database nobody truncated. */
export function uniqueSuffix(): string {
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
}
export function uniqueEmail(prefix = 'pw'): string {
return `${prefix}-${uniqueSuffix()}@example.com`;
}
interface CreateItemOptions {
name: string;
price?: string;
description?: string;
categoryId?: number | null;
tags?: string[];
/**
* New items are created pending, and the storefront lists only published
* ones. Most fixtures stand in for ordinary stock rather than staged drafts,
* so publishing is the default — a spec that wants a draft asks for one.
*/
publish?: boolean;
}
export async function createItem(
api: APIRequestContext,
options: CreateItemOptions
): Promise<SeededItem> {
const res = await api.post('/api/admin/items', {
// multipart rather than JSON: the route accepts image uploads, so it reads
// its fields from a multipart body even when no image is attached.
multipart: {
name: options.name,
description: options.description ?? '',
price: options.price ?? '50',
category_id: options.categoryId == null ? '' : String(options.categoryId),
tags: JSON.stringify(options.tags ?? [])
}
});
expect(res.ok(), `creating item ${options.name}`).toBeTruthy();
const item = { id: (await res.json()).id as number, name: options.name };
if (options.publish ?? true) {
await publishItem(api, item.id);
}
return item;
}
export async function publishItem(api: APIRequestContext, id: number): Promise<void> {
const res = await api.post(`/api/admin/items/${id}/mark-available`);
expect(res.ok(), `publishing item ${id}`).toBeTruthy();
}
export async function sellItem(api: APIRequestContext, id: number): Promise<void> {
const res = await api.post(`/api/admin/items/${id}/mark-sold`);
expect(res.ok(), `selling item ${id}`).toBeTruthy();
}
export async function createCategory(
api: APIRequestContext,
name: string,
parentId: number | null = null
): Promise<number> {
const res = await api.post('/api/admin/categories', { data: { name, parent_id: parentId } });
expect(res.status(), `creating category ${name}`).toBe(201);
return (await res.json()).id as number;
}
export async function createTag(api: APIRequestContext, name: string): Promise<number> {
const res = await api.post('/api/admin/tags', { data: { name } });
expect(res.ok(), `creating tag ${name}`).toBeTruthy();
return (await res.json()).id as number;
}
+69
View File
@@ -0,0 +1,69 @@
import { Client } from 'pg';
/**
* Direct database access for the tests, for the one thing the API deliberately
* will not give them.
*
* The password-reset token is only ever delivered by email, which these tests
* cannot read. It is read from the database rather than through a helper
* endpoint because an endpoint that returns a reset token for an arbitrary
* address is account takeover for every customer if it is ever reachable, and
* an environment gate is a thin thing to stand between that and production.
* Doing it here keeps the capability entirely inside the test process.
*
* That reasoning is unchanged from when it lived inline in
* password-reset.spec.ts. What has changed is that it is no longer sitting in a
* spec where it can be copied into the next one that wants a shortcut.
*/
/**
* The default port is 55500, matching scripts/start-local.ps1.
*
* It used to be 55432, which is the integration suite's disposable Postgres —
* a different database, with different credentials, that the app under test is
* not connected to. Worse, 55432 is reserved by Hyper-V on at least one machine
* here, so the spec failed with a bare ECONNREFUSED naming a port nobody had
* chosen. TEST_PGPORT still overrides, for CI and for anyone running the stack
* somewhere else.
*/
function connectionSettings() {
return {
host: process.env.TEST_PGHOST || 'localhost',
port: parseInt(process.env.TEST_PGPORT || '55500', 10),
user: process.env.TEST_PGUSER || 'redefined_local',
password: process.env.TEST_PGPASSWORD || 'redefined_local',
database: process.env.TEST_PGDATABASE || 'redefined_local'
};
}
/** Opens a connection, runs the query, and closes it whatever happens. */
async function withClient<T>(run: (client: Client) => Promise<T>): Promise<T> {
const client = new Client(connectionSettings());
await client.connect();
try {
return await run(client);
} finally {
await client.end();
}
}
/**
* The most recent password-reset token issued to an address.
*
* Throws rather than returning null: every caller is about to build a URL from
* it, and a missing token means the request under test did not do what it said,
* which is worth failing loudly at the point it happened.
*/
export async function readPasswordResetToken(email: string): Promise<string> {
return withClient(async (client) => {
const { rows } = await client.query(
`SELECT t.token FROM customer_tokens t
JOIN customers c ON c.id = t.customer_id
WHERE c.email = $1 AND t.kind = 'password_reset'
ORDER BY t.created_at DESC LIMIT 1`,
[email]
);
if (!rows.length) throw new Error(`no password_reset token issued for ${email}`);
return rows[0].token as string;
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"//": "Type-checks tests/. Separate from tsconfig.json rather than widening its `include`, because scripts/check-sonar-tsconfig.js compares the two configs' `include` arrays and tsconfig.sonar.json exists only so SonarQube 9.9 can build a program over src — pulling the Playwright suite into that analysis is a different decision from type-checking it. Run by `npm run build`; see #137.",
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"]
},
"include": ["tests"]
}