import { test, expect } from '@playwright/test' import { loginAsAdmin } from '../helpers/auth.js' import path from 'path' import fs from 'fs' const viewports = { desktop: { width: 1280, height: 720 }, mobile: { width: 375, height: 667 }, } const publicPages = [ { name: 'home', path: '/' }, { name: 'join', path: '/join' }, { name: 'events', path: '/events' }, { name: 'coming-soon', path: '/coming-soon' }, // about and members have no auth middleware — accessible publicly { name: 'about', path: '/about' }, { name: 'members', path: '/members' }, ] const authenticatedPages = [ { name: 'member-dashboard', path: '/member/dashboard' }, { name: 'member-profile', path: '/member/profile' }, { name: 'admin-members', path: '/admin/members' }, { name: 'admin-events-create', path: '/admin/events/create' }, // New authenticated pages { name: 'member-account', path: '/member/account' }, { name: 'member-activity', path: '/member/activity' }, { name: 'connections', path: '/connections' }, { name: 'admin-dashboard', path: '/admin' }, ] // Pages that need mobile coverage captured while authenticated. // These cover column-collapse breakpoints critical for the page-shell refactor. // Snapshots use the -mobile-auth suffix to distinguish from the public mobile loop // (which also captures about-mobile unauthenticated, so names must not collide). const authenticatedMobilePages = [ { name: 'about', path: '/about' }, { name: 'member-dashboard', path: '/member/dashboard' }, { name: 'member-profile', path: '/member/profile' }, { name: 'member-account', path: '/member/account' }, { name: 'connections', path: '/connections' }, ] // Path where the saved admin auth state (cookies) will be stored within a run. const authStatePath = path.resolve('e2e/.auth/admin.json') // Wait for fonts and images to load before taking screenshots async function waitForStable(page) { await page.waitForLoadState('networkidle') // Wait for web fonts to load await page.evaluate(() => document.fonts.ready) } // Common mask selectors for dynamic content function commonMasks(page) { return [ // Dates and times throughout the app page.locator('.event-date'), page.locator('.event-count'), page.locator('time'), page.locator('.member-since'), // Activity log timestamps page.locator('.tl-time'), // Admin dashboard stat values (member counts, revenue, etc.) page.locator('.stat-val'), // Recent member join dates in admin dashboard page.locator('.item-date'), // Member avatars (ghost images may not load deterministically) page.locator('.mc-avatar'), page.locator('.cc-avatar'), page.locator('.profile-avatar'), // Member count text in members page filter bar page.locator('.filter-count'), // Connections page: filter bar and suggestions vary based on tag/topic // state and async fetch ordering. Mask them to keep the structural // (PageShell + page-level) regression coverage stable. page.locator('.filter-bar'), page.locator('.skills-bar'), page.locator('.connections-section'), page.locator('.loading-state'), ] } // All visual tests run serially in a single top-level describe block. // // Auth is handled with a beforeAll that saves the cookie to disk once. All // authenticated sub-describes load from that saved state, avoiding repeated // /api/dev/test-login calls that exhaust the dev server's MongoDB connections. test.describe('visual regression', () => { test.describe.configure({ mode: 'serial' }) // Log in once before all tests and save the auth cookie. // serial mode guarantees this runs before any test in this describe tree. test.beforeAll(async ({ browser }) => { fs.mkdirSync(path.dirname(authStatePath), { recursive: true }) const page = await browser.newPage() await loginAsAdmin(page) await page.context().storageState({ path: authStatePath }) await page.close() }) // ── Public pages (desktop + mobile) ────────────────────────────────────── test.describe('public pages', () => { for (const { name, path } of publicPages) { for (const [viewportName, viewport] of Object.entries(viewports)) { test(`${name} — ${viewportName}`, async ({ page }) => { await page.setViewportSize(viewport) await page.goto(path) await waitForStable(page) await expect(page).toHaveScreenshot(`${name}-${viewportName}.png`, { maxDiffPixelRatio: 0.01, mask: commonMasks(page), }) }) } } }) // ── Authenticated pages (desktop) ───────────────────────────────────────── // Loads saved auth cookie — no repeated /api/dev/test-login calls. test.describe('authenticated pages', () => { test.use({ storageState: authStatePath }) for (const { name, path } of authenticatedPages) { test(`${name} — desktop`, async ({ page }) => { await page.setViewportSize(viewports.desktop) await page.goto(path) await waitForStable(page) await expect(page).toHaveScreenshot(`${name}-desktop.png`, { maxDiffPixelRatio: 0.01, mask: commonMasks(page), }) }) } // members-detail: navigate to the test admin's own profile page. // The test admin is created by /api/dev/test-login (email: test-admin@ghostguild.dev, // status: active). We fetch their _id from /api/auth/member using the saved cookie. // Even if showInDirectory is false, the page renders a stable error or profile shell. test('members-detail — desktop', async ({ page }) => { await page.setViewportSize(viewports.desktop) const response = await page.request.get('/api/auth/member') // /api/auth/member returns the member object directly (not nested under a 'member' key) const authData = response.ok() ? await response.json() : null const memberId = authData?._id || authData?.id if (!memberId) { // Skip gracefully if we can't retrieve the member ID test.skip(true, 'Could not retrieve test admin member ID from /api/auth/member') return } await page.goto(`/members/${memberId}`) await waitForStable(page) await expect(page).toHaveScreenshot('members-detail-desktop.png', { maxDiffPixelRatio: 0.01, mask: commonMasks(page), }) }) }) // ── Authenticated pages (mobile — column-collapse coverage) ─────────────── // Loads saved auth cookie — no repeated /api/dev/test-login calls. test.describe('authenticated pages (mobile)', () => { test.use({ storageState: authStatePath }) for (const { name, path } of authenticatedMobilePages) { test(`${name} — mobile`, async ({ page }) => { await page.setViewportSize(viewports.mobile) await page.goto(path) await waitForStable(page) await expect(page).toHaveScreenshot(`${name}-mobile-auth.png`, { maxDiffPixelRatio: 0.01, mask: commonMasks(page), }) }) } }) })