Files
pn-new-crm/tests/e2e/audit/mobile.spec.ts
Matt Ciaccio 5d44f3cfa4 fix(test): raise mobile-audit timeout to 30min for 4-viewport runs
Task 24 audit run hit the 10-minute test.setTimeout ceiling after capturing
2 of 4 viewport passes (iphone-se complete, iphone-16 complete-ish, 16-pro
partial, pro-max not started). 4 viewports × ~45 routes × slowMo: 200 needs
more headroom than 600s gave. 30min is comfortable headroom; the per-test
project timeout is matched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:15:26 +02:00

385 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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';
import { IPHONE_DEVICES } from '../fixtures/devices';
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 = [
{ ...IPHONE_DEVICES.iphoneSe, name: 'iphone-se', label: IPHONE_DEVICES.iphoneSe.name },
{ ...IPHONE_DEVICES.iphone16, name: 'iphone-16', label: IPHONE_DEVICES.iphone16.name },
{ ...IPHONE_DEVICES.iphone16Pro, name: 'iphone-16-pro', label: IPHONE_DEVICES.iphone16Pro.name },
{
...IPHONE_DEVICES.iphoneProMax,
name: 'iphone-pro-max',
label: IPHONE_DEVICES.iphoneProMax.name,
},
] 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<void> {
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<string, Capture[]>): Promise<void> {
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<string, Capture[]>();
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(1_800_000);
await fs.mkdir(OUT_ROOT, { recursive: true });
await ensureAdminExists(request);
const allByViewport = new Map<string, Capture[]>();
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: vp.viewport,
deviceScaleFactor: vp.deviceScaleFactor,
isMobile: vp.isMobile,
hasTouch: vp.hasTouch,
userAgent: vp.userAgent,
});
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')}`);
});