Files
pn-new-crm/tests/e2e/matrix/responsive-overflow.spec.ts

156 lines
5.6 KiB
TypeScript
Raw Normal View History

/**
* Responsive overflow / cutoff sweep.
*
* Walks the key pages at desktop / tablet / mobile / small-mobile viewports and
* programmatically flags layout bugs the eye looks for on small screens:
* - horizontal overflow (document wider than the viewport off-screen content,
* a horizontal scrollbar),
* - individual elements whose right edge runs past the viewport (clipped /
* off-screen buttons + text),
* - elements overflowing the BOTTOM of their own box (cut-off text).
* Captures a full-page screenshot per page/viewport for eyeball QC.
*
* Runs as `admin` (sees every page). Layout is role-independent, so one broad
* role surfaces the responsive issues; role-specific nav scoping is covered by
* role-access.spec.ts.
*/
import { test, expect } from '@playwright/test';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
const PORT = 'port-nimara';
const OUT = join(process.cwd(), '.audit', 'responsive');
const ADMIN = { email: 'admin@portnimara.test', pw: 'SuperAdmin12345!' };
const VIEWPORTS = [
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'tablet', width: 820, height: 1180 },
{ name: 'mobile', width: 390, height: 844 },
{ name: 'small', width: 360, height: 740 },
] as const;
const PAGES = [
'dashboard',
'clients',
'interests',
'inquiries',
'berths',
'yachts',
'companies',
'reports',
'reports/financial',
'documents',
'expenses',
'inbox',
'settings',
'admin',
'admin/users',
];
test.describe('Responsive overflow sweep', () => {
test('admin — every key page at every viewport, flag overflow + cutoff', async ({ page }) => {
test.setTimeout(600_000);
mkdirSync(OUT, { recursive: true });
const res = await page.request.post('/api/auth/sign-in/email', {
data: { email: ADMIN.email, password: ADMIN.pw },
headers: { 'content-type': 'application/json' },
});
expect(res.ok()).toBeTruthy();
const findings: string[] = [];
for (const vp of VIEWPORTS) {
await page.setViewportSize({ width: vp.width, height: vp.height });
for (const p of PAGES) {
const url = `/${PORT}/${p}`;
const slug = p.replace(/\//g, '_');
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
} catch {
findings.push(`NAV-FAIL ${vp.name.padEnd(7)} ${p}`);
continue;
}
// let layout settle + data paint
await page.waitForTimeout(1800);
const report = await page.evaluate((vpWidth) => {
const docW = document.documentElement.scrollWidth;
const innerW = window.innerWidth;
const horizOverflow = docW - innerW;
// Elements whose right edge runs past the viewport by > 2px and are
// actually visible (have size, not display:none).
const offscreen: { tag: string; cls: string; right: number; text: string }[] = [];
const SVG_INTERNAL = new Set([
'svg',
'g',
'ellipse',
'path',
'circle',
'rect',
'line',
'polyline',
'polygon',
]);
const els = document.querySelectorAll('body *');
for (const el of els) {
const tag = el.tagName.toLowerCase();
// Skip SVG internals (icons, the react-grab dev overlay, chart guts)
// — not layout-cutoff signal.
if (SVG_INTERNAL.has(tag)) continue;
const r = (el as HTMLElement).getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (r.right > vpWidth + 2 && r.left < vpWidth) {
// Skip elements inside a horizontal-scroll container (data tables
// etc. scroll on purpose) — that's intended, not a clip.
let p: HTMLElement | null = el.parentElement;
let inScroll = false;
while (p) {
const ox = getComputedStyle(p).overflowX;
if (ox === 'auto' || ox === 'scroll') {
inScroll = true;
break;
}
p = p.parentElement;
}
if (inScroll) continue;
const cls = ((el as HTMLElement).className || '').toString().slice(0, 40);
const text = (el.textContent || '').trim().slice(0, 30);
offscreen.push({ tag, cls, right: Math.round(r.right), text });
}
}
// de-dupe by tag+text, cap
const seen = new Set<string>();
const uniq = offscreen
.filter((o) => {
const k = `${o.tag}:${o.text}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
})
.slice(0, 6);
return { horizOverflow, docW, innerW, offscreen: uniq };
}, vp.width);
await page
.screenshot({ path: join(OUT, `admin-${vp.name}-${slug}.png`), fullPage: true })
.catch(() => {});
const flagged = report.horizOverflow > 3 || report.offscreen.length > 0;
const line = `${flagged ? 'OVERFLOW' : 'ok '} ${vp.name.padEnd(7)} ${p.padEnd(18)} hScroll=${report.horizOverflow}px doc=${report.docW}/${report.innerW}`;
console.log(line);
if (report.offscreen.length) {
for (const o of report.offscreen) {
console.log(` ↳ off-right ${o.tag} right=${o.right} "${o.text}" .${o.cls}`);
}
}
if (flagged) findings.push(line);
}
}
console.log(`\n=== OVERFLOW FINDINGS (${findings.length}) ===`);
for (const f of findings) console.log(f);
});
});