180 lines
7 KiB
JavaScript
180 lines
7 KiB
JavaScript
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: '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),
|
|
})
|
|
})
|
|
}
|
|
})
|
|
})
|