Wrap members directory page in PageShell. Also expand visual mask selectors to cover .filter-bar, .skills-bar, and .connections-section because filter content varies based on dynamic tag/topic state and async fetch ordering. Rebaselines several existing snapshots that now mask wider regions but capture the same structural layout.
180 lines
7.1 KiB
JavaScript
180 lines
7.1 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: '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'),
|
|
]
|
|
}
|
|
|
|
// 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),
|
|
})
|
|
})
|
|
}
|
|
})
|
|
})
|