feat(rbac): residential-partner route lockdown + role-aware mobile nav
UAT (residential partners must have zero access to anything non-residential; no marina dashboard). Server-side their permission map already 403s every marina domain — this locks the client surface to match: - AppShell: a residential-only user (residential_clients.view && !clients.view, non-super-admin) is redirected off ANY non-residential route to /residential/clients. Blocks the marina dashboard + every marina page in one place; personal surfaces (settings, inbox) stay reachable. (Fixes F4 — they no longer land on a marina dashboard of 403-ing empty widgets.) - Mobile bottom tabs were hardcoded Dashboard/Clients/Berths regardless of role; now role-aware — residential-only users get Residential Clients/Interests instead of marina tabs they 403 on. (Fixes F5.) - e2e: stale `#email` login selector → `#identifier` (smoke helper) — a real reason the smoke auth specs fail independent of the dev-server OOM. - New crash-safe `matrix` Playwright project (role×viewport access matrix + responsive overflow sweep) — lean alternative to the full suite which OOM-crashes next dev locally. Verified: matrix run shows residential_partner redirected to residential + residential-scoped mobile tabs; 403s unchanged; tsc + eslint + 42 permission tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
129
tests/e2e/matrix/responsive-overflow.spec.ts
Normal file
129
tests/e2e/matrix/responsive-overflow.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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 els = document.querySelectorAll('body *');
|
||||
for (const el of els) {
|
||||
const r = (el as HTMLElement).getBoundingClientRect();
|
||||
if (r.width === 0 || r.height === 0) continue;
|
||||
if (r.right > vpWidth + 2 && r.left < vpWidth) {
|
||||
// overflowing the right edge (partially clipped)
|
||||
const tag = el.tagName.toLowerCase();
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user