2026-04-29 13:49:38 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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';
|
2026-04-29 14:31:51 +02:00
|
|
|
|
import { IPHONE_DEVICES } from '../fixtures/devices';
|
2026-04-29 13:49:38 +02:00
|
|
|
|
|
|
|
|
|
|
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 = [
|
2026-04-29 14:31:51 +02:00
|
|
|
|
{ ...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,
|
|
|
|
|
|
},
|
2026-04-29 13:49:38 +02:00
|
|
|
|
] 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(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({
|
2026-04-29 14:31:51 +02:00
|
|
|
|
viewport: vp.viewport,
|
|
|
|
|
|
deviceScaleFactor: vp.deviceScaleFactor,
|
|
|
|
|
|
isMobile: vp.isMobile,
|
|
|
|
|
|
hasTouch: vp.hasTouch,
|
|
|
|
|
|
userAgent: vp.userAgent,
|
2026-04-29 13:49:38 +02:00
|
|
|
|
});
|
|
|
|
|
|
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')}`);
|
|
|
|
|
|
});
|