/** * E2E assertion helpers. * * Used by the per-portal smoke specs to check that a page mounted * correctly, didn't bounce the user to a different portal, and didn't * spew console errors. These are intentionally narrow — they're the * floor, not the ceiling. Per-page business assertions live in the * happy-path tests. */ import { Page, expect, ConsoleMessage } from '@playwright/test'; /** * Track all console errors emitted by the page during a test. * Returns a disposer. Pair with `expectNoConsoleErrors` at the end. * * Filtered out: Vite HMR, React DevTools advisories, and network-level * errors (Failed to load resource, 4xx/5xx fetch failures). Those are * data-layer issues, not rendering bugs — and smoke tests are about * rendering, not data. The data layer is exercised by the flow tests. */ const NON_FATAL_PATTERNS = [ '[vite]', 'Download the React DevTools', 'Failed to load resource', // network fetch failure 'Failed to load ', // component-level "Failed to load X" warnings 'status of 4', // 4xx fetch 'status of 5', // 5xx fetch 'NetworkError', 'ERR_NETWORK', 'CONNECTION_REFUSED', 'AxiosError', 'paynow', // paynow webhook retry noise in dev 'sync', // sync engine retry noise in dev ]; export function captureConsoleErrors(page: Page): { errors: string[]; dispose: () => void } { const errors: string[] = []; const handler = (msg: ConsoleMessage) => { if (msg.type() === 'error') { const text = msg.text(); if (NON_FATAL_PATTERNS.some((p) => text.includes(p))) { return; } errors.push(text); } }; page.on('console', handler); return { errors, dispose: () => page.off('console', handler), }; } export function expectNoConsoleErrors(errors: string[]): void { if (errors.length > 0) { throw new Error( `Page produced ${errors.length} console error(s):\n - ${errors.join('\n - ')}` ); } } /** * Check that the page did not redirect to /login (auth failure) or to a * different role's dashboard (RBAC misconfig). Useful as a sanity gate * before deeper assertions. */ export async function expectNoAuthBounce(page: Page, expectedPath: string): Promise { const url = new URL(page.url()); if (url.pathname === '/login') { throw new Error(`Expected to land on ${expectedPath} but was redirected to /login (auth failure)`); } if (url.pathname.startsWith('/login')) { throw new Error(`Expected ${expectedPath} but got ${url.pathname}`); } } /** * Assert the page has at least one of the given heading / known text * fragments. This is the smoke test's "yes, this rendered" check — * looking for a recognizable string beats asserting on a hard-to-predict * DOM tree. */ export async function expectAnyText(page: Page, fragments: string[]): Promise { const body = (await page.locator('body').innerText()).toLowerCase(); const found = fragments.find((f) => body.includes(f.toLowerCase())); if (!found) { throw new Error( `None of the expected text fragments were found on ${page.url()}.\n` + `Looked for: ${fragments.join(', ')}\n` + `Page text (first 400 chars): ${body.slice(0, 400)}` ); } } /** * Wait for the page to settle: no more than 1 in-flight request for * 250ms, then resolve. Cheaper than `networkidle` and good enough for * SPA route changes. */ export async function waitForPageSettled(page: Page): Promise { await page.waitForLoadState('domcontentloaded'); // Give React a beat to mount after route change. await page.waitForTimeout(150); } /** * The page rendered, the URL is what we asked for, and there's no * uncaught error. Combine with captureConsoleErrors for a fuller check. */ export async function expectPageMounted( page: Page, expectedPath: string, recognizedText: string[] ): Promise { await waitForPageSettled(page); await expectNoAuthBounce(page, expectedPath); await expectAnyText(page, recognizedText); }