/** * Mobile Audit Spec * ----------------- * Visits every user-facing page in the app at iPhone 14 Pro and iPhone SE * viewports (portrait), takes full-page screenshots to .audit/mobile/*, and * writes an index.md grouped by route area. * * Self-seeds the super-admin via better-auth's REST sign-up endpoint so it * does NOT depend on the docker-based smoke setup. * * Run: pnpm exec playwright test --project=mobile-audit */ import { test, type Page, type APIRequestContext } from '@playwright/test'; import { promises as fs } from 'node:fs'; import path from 'node:path'; const PORT_SLUG = 'port-nimara'; const ADMIN = { email: 'admin@portnimara.test', password: 'SuperAdmin12345!', name: 'Test Admin', }; const OUT_ROOT = path.resolve(process.cwd(), '.audit/mobile'); // Anchor viewports covering the active iPhone range (portrait CSS pixels). // See docs/superpowers/specs/2026-04-29-mobile-optimization-design.md §2.1. const VIEWPORTS = [ { name: 'iphone-se', label: 'iPhone SE 3 (375×667)', width: 375, height: 667 }, { name: 'iphone-16', label: 'iPhone 15/16 (393×852)', width: 393, height: 852 }, { name: 'iphone-16-pro', label: 'iPhone 16/17 Pro (402×874)', width: 402, height: 874 }, { name: 'iphone-pro-max', label: 'iPhone 16/17 Pro Max (440×956)', width: 440, height: 956 }, ] as const; type Auth = 'admin' | 'public'; type Route = { group: string; slug: string; path: string; auth: Auth; /** * If set, after navigating to `path` we click the first matching anchor and * screenshot the resulting detail page under the slug `slug + '-detail'`. */ detailLinkSelector?: string; /** Extra wait after navigation (ms) for content to settle. */ settleMs?: number; /** If true, skip — useful for known-broken or out-of-scope routes. */ skip?: boolean; }; const ROUTES: Route[] = [ // (auth) { group: 'auth', slug: 'auth-login', path: '/login', auth: 'public' }, { group: 'auth', slug: 'auth-reset-password', path: '/reset-password', auth: 'public' }, { group: 'auth', slug: 'auth-set-password', path: '/set-password', auth: 'public' }, // (portal) public { group: 'portal', slug: 'portal-login', path: '/portal/login', auth: 'public' }, { group: 'portal', slug: 'portal-activate', path: '/portal/activate', auth: 'public' }, { group: 'portal', slug: 'portal-forgot-password', path: '/portal/forgot-password', auth: 'public', }, { group: 'portal', slug: 'portal-reset-password', path: '/portal/reset-password', auth: 'public', }, // (dashboard) { group: 'dashboard', slug: 'dash-port-home', path: `/${PORT_SLUG}`, auth: 'admin' }, { group: 'dashboard', slug: 'dash-overview', path: `/${PORT_SLUG}/dashboard`, auth: 'admin' }, // CRUD lists + detail { group: 'clients', slug: 'clients-list', path: `/${PORT_SLUG}/clients`, auth: 'admin', detailLinkSelector: 'a[href*="/clients/"]:not([href$="/clients"])', }, { group: 'yachts', slug: 'yachts-list', path: `/${PORT_SLUG}/yachts`, auth: 'admin', detailLinkSelector: 'a[href*="/yachts/"]:not([href$="/yachts"])', }, { group: 'companies', slug: 'companies-list', path: `/${PORT_SLUG}/companies`, auth: 'admin', detailLinkSelector: 'a[href*="/companies/"]:not([href$="/companies"])', }, { group: 'berths', slug: 'berths-list', path: `/${PORT_SLUG}/berths`, auth: 'admin', detailLinkSelector: 'a[href*="/berths/"]:not([href$="/berths"])', }, { group: 'interests', slug: 'interests-list', path: `/${PORT_SLUG}/interests`, auth: 'admin', }, { group: 'invoices', slug: 'invoices-list', path: `/${PORT_SLUG}/invoices`, auth: 'admin', detailLinkSelector: 'a[href*="/invoices/"]:not([href$="/invoices"]):not([href$="/new"])', }, { group: 'invoices', slug: 'invoices-new', path: `/${PORT_SLUG}/invoices/new`, auth: 'admin' }, { group: 'expenses', slug: 'expenses-list', path: `/${PORT_SLUG}/expenses`, auth: 'admin', detailLinkSelector: 'a[href*="/expenses/"]:not([href$="/expenses"]):not([href$="/scan"])', }, { group: 'expenses', slug: 'expenses-scan', path: `/${PORT_SLUG}/expenses/scan`, auth: 'admin' }, // Cross-cutting features { group: 'documents', slug: 'documents', path: `/${PORT_SLUG}/documents`, auth: 'admin' }, { group: 'email', slug: 'email', path: `/${PORT_SLUG}/email`, auth: 'admin' }, { group: 'alerts', slug: 'alerts', path: `/${PORT_SLUG}/alerts`, auth: 'admin' }, { group: 'reports', slug: 'reports', path: `/${PORT_SLUG}/reports`, auth: 'admin' }, { group: 'reminders', slug: 'reminders', path: `/${PORT_SLUG}/reminders`, auth: 'admin' }, { group: 'settings', slug: 'settings-user', path: `/${PORT_SLUG}/settings`, auth: 'admin' }, // Admin { group: 'admin', slug: 'admin-home', path: `/${PORT_SLUG}/admin`, auth: 'admin' }, { group: 'admin', slug: 'admin-settings', path: `/${PORT_SLUG}/admin/settings`, auth: 'admin' }, { group: 'admin', slug: 'admin-branding', path: `/${PORT_SLUG}/admin/branding`, auth: 'admin' }, { group: 'admin', slug: 'admin-forms', path: `/${PORT_SLUG}/admin/forms`, auth: 'admin' }, { group: 'admin', slug: 'admin-ocr', path: `/${PORT_SLUG}/admin/ocr`, auth: 'admin' }, { group: 'admin', slug: 'admin-roles', path: `/${PORT_SLUG}/admin/roles`, auth: 'admin' }, { group: 'admin', slug: 'admin-tags', path: `/${PORT_SLUG}/admin/tags`, auth: 'admin' }, { group: 'admin', slug: 'admin-audit', path: `/${PORT_SLUG}/admin/audit`, auth: 'admin' }, { group: 'admin', slug: 'admin-documenso', path: `/${PORT_SLUG}/admin/documenso`, auth: 'admin' }, { group: 'admin', slug: 'admin-users', path: `/${PORT_SLUG}/admin/users`, auth: 'admin' }, { group: 'admin', slug: 'admin-templates', path: `/${PORT_SLUG}/admin/templates`, auth: 'admin' }, { group: 'admin', slug: 'admin-custom-fields', path: `/${PORT_SLUG}/admin/custom-fields`, auth: 'admin', }, { group: 'admin', slug: 'admin-monitoring', path: `/${PORT_SLUG}/admin/monitoring`, auth: 'admin', }, { group: 'admin', slug: 'admin-backup', path: `/${PORT_SLUG}/admin/backup`, auth: 'admin' }, { group: 'admin', slug: 'admin-webhooks', path: `/${PORT_SLUG}/admin/webhooks`, auth: 'admin' }, { group: 'admin', slug: 'admin-import', path: `/${PORT_SLUG}/admin/import`, auth: 'admin' }, { group: 'admin', slug: 'admin-ports', path: `/${PORT_SLUG}/admin/ports`, auth: 'admin' }, // Scanner PWA { group: 'scanner', slug: 'scanner-scan', path: `/${PORT_SLUG}/scan`, auth: 'admin' }, ]; type Capture = { group: string; slug: string; path: string; file: string; status: 'ok' | 'error'; error?: string; }; async function ensureAdminExists(request: APIRequestContext) { const headers = { 'Content-Type': 'application/json', Origin: 'http://localhost:3000', Referer: 'http://localhost:3000/', }; const signUp = await request.post('/api/auth/sign-up/email', { headers, data: { email: ADMIN.email, password: ADMIN.password, name: ADMIN.name }, failOnStatusCode: false, }); if (!signUp.ok()) { // Already exists — verify sign-in works const signIn = await request.post('/api/auth/sign-in/email', { headers, data: { email: ADMIN.email, password: ADMIN.password }, failOnStatusCode: false, }); if (!signIn.ok()) { const body = await signIn.text(); throw new Error(`Cannot sign in admin: ${signIn.status()} ${body}`); } } } async function loginThroughUI(page: Page) { await page.goto('/login'); await page.fill('input[type="email"], input[name="email"]', ADMIN.email); await page.fill('input[type="password"], input[name="password"]', ADMIN.password); await page.click('button[type="submit"]'); // Better-auth redirect can land on `/` or `/[portSlug]` depending on user_port_roles. await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {}); } async function captureRoute( page: Page, route: Route, outDir: string, captures: Capture[], ): Promise { if (route.skip) return; // Main capture const mainFile = path.join(outDir, `${route.slug}.png`); try { await page.goto(route.path, { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); await page.waitForTimeout(route.settleMs ?? 600); await page.screenshot({ path: mainFile, fullPage: true }); captures.push({ group: route.group, slug: route.slug, path: route.path, file: mainFile, status: 'ok', }); console.log(` ✓ ${route.path}`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); captures.push({ group: route.group, slug: route.slug, path: route.path, file: mainFile, status: 'error', error: msg.split('\n')[0], }); console.log(` ✗ ${route.path} — ${msg.split('\n')[0]}`); return; } // Detail capture (optional) if (route.detailLinkSelector) { const detailSlug = `${route.slug.replace(/-list$/, '')}-detail`; const detailFile = path.join(outDir, `${detailSlug}.png`); try { const link = page.locator(route.detailLinkSelector).first(); const count = await link.count(); if (count === 0) { captures.push({ group: route.group, slug: detailSlug, path: `${route.path} → (no rows seeded)`, file: detailFile, status: 'error', error: 'No detail rows present in list', }); console.log(` – ${route.path} detail skipped (empty list)`); return; } const href = await link.getAttribute('href'); const target = href ?? route.path; await page.goto(target, { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); await page.waitForTimeout(700); await page.screenshot({ path: detailFile, fullPage: true }); captures.push({ group: route.group, slug: detailSlug, path: target, file: detailFile, status: 'ok', }); console.log(` ✓ ${target} (detail)`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); captures.push({ group: route.group, slug: detailSlug, path: `${route.path} → detail`, file: detailFile, status: 'error', error: msg.split('\n')[0], }); console.log(` ✗ ${route.path} detail — ${msg.split('\n')[0]}`); } } } async function writeIndex(allByViewport: Map): Promise { const lines: string[] = [ '# Mobile Audit', '', `Generated: ${new Date().toISOString()}`, '', 'Captured at iPhone 14 Pro (393×852) and iPhone SE 3 (375×667), portrait, full-page.', '', ]; for (const [vpName, captures] of allByViewport) { lines.push(`## ${vpName}`, ''); const grouped = new Map(); for (const c of captures) { if (!grouped.has(c.group)) grouped.set(c.group, []); grouped.get(c.group)!.push(c); } for (const [group, items] of grouped) { lines.push(`### ${group}`, ''); lines.push('| route | shot |'); lines.push('| --- | --- |'); for (const c of items) { const rel = path.relative(OUT_ROOT, c.file); if (c.status === 'ok') { lines.push(`| \`${c.path}\` | ![${c.slug}](${rel}) |`); } else { lines.push(`| \`${c.path}\` | _error: ${c.error}_ |`); } } lines.push(''); } } await fs.writeFile(path.join(OUT_ROOT, 'index.md'), lines.join('\n'), 'utf-8'); } test('mobile audit — every page at iPhone viewports', async ({ browser, request }) => { test.setTimeout(600_000); await fs.mkdir(OUT_ROOT, { recursive: true }); await ensureAdminExists(request); const allByViewport = new Map(); for (const vp of VIEWPORTS) { console.log(`\n─── Viewport: ${vp.label} ───`); const outDir = path.join(OUT_ROOT, vp.name); await fs.mkdir(outDir, { recursive: true }); const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', }); const page = await context.newPage(); const captures: Capture[] = []; // Public pages first (no auth state) for (const route of ROUTES.filter((r) => r.auth === 'public')) { await captureRoute(page, route, outDir, captures); } // Sign in once via UI for authenticated pages await loginThroughUI(page); for (const route of ROUTES.filter((r) => r.auth === 'admin')) { await captureRoute(page, route, outDir, captures); } allByViewport.set(vp.label, captures); await context.close(); } await writeIndex(allByViewport); console.log(`\nIndex written to ${path.join(OUT_ROOT, 'index.md')}`); });