geocrop-platform./apps/nextgen/client/e2e/helpers/assertions.ts

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);
}