/** * 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(); 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); }); });