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>
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
/**
|
||
* 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}\` |  |`);
|
||
} 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')}`);
|
||
});
|