119 lines
4.0 KiB
TypeScript
119 lines
4.0 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
await waitForPageSettled(page);
|
|
await expectNoAuthBounce(page, expectedPath);
|
|
await expectAnyText(page, recognizedText);
|
|
}
|