Files
pn-new-crm/tests/e2e/audit/mobile.spec.ts

385 lines
13 KiB
TypeScript
Raw Normal View History

/**
* 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(600_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')}`);
});