feat: add testing infrastructure — Vitest, Playwright, CI, git hooks
Some checks are pending
Test / vitest (push) Waiting to run
Test / playwright (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions

Add comprehensive testing covering 420 unit/handler tests across 24 Vitest
files, 9 Playwright E2E specs, accessibility scans, and visual regression.
Includes GitHub Actions CI, Husky pre-push hook, and TESTING.md docs.
This commit is contained in:
Jennie Robinson Faber 2026-04-04 16:07:21 +01:00
parent 036af95e00
commit 1e30ba23cd
35 changed files with 3637 additions and 5 deletions

112
e2e/a11y.spec.js Normal file
View file

@ -0,0 +1,112 @@
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
import { loginAsAdmin } from './helpers/auth.js'
const publicPages = [
{ name: 'Home', path: '/' },
{ name: 'Join', path: '/join' },
{ name: 'Events', path: '/events' },
{ name: 'Coming Soon', path: '/coming-soon' },
]
const memberPages = [
{ name: 'Member Dashboard', path: '/member/dashboard' },
{ name: 'Member Profile', path: '/member/profile' },
]
const adminPages = [
{ name: 'Admin Members', path: '/admin/members' },
{ name: 'Admin Events Create', path: '/admin/events/create' },
]
test.describe('accessibility — public pages', () => {
for (const { name, path } of publicPages) {
test(`${name} has no critical a11y violations`, async ({ page }) => {
await page.goto(path)
await page.waitForLoadState('networkidle')
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze()
const critical = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious'
)
expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
})
}
})
test.describe('accessibility — member pages', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page)
})
for (const { name, path } of memberPages) {
test(`${name} has no critical a11y violations`, async ({ page }) => {
await page.goto(path)
await page.waitForLoadState('networkidle')
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze()
const critical = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious'
)
expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
})
}
})
test.describe('accessibility — admin pages', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page)
})
for (const { name, path } of adminPages) {
test(`${name} has no critical a11y violations`, async ({ page }) => {
await page.goto(path)
await page.waitForLoadState('networkidle')
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze()
const critical = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious'
)
expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
})
}
})
test.describe('keyboard navigation', () => {
test('tab through join form fields in order', async ({ page }) => {
await page.goto('/join')
await page.waitForLoadState('networkidle')
// Focus the name field and tab through
await page.locator('#join-name').focus()
expect(await page.locator('#join-name').evaluate((el) => el === document.activeElement)).toBe(true)
await page.keyboard.press('Tab')
// Email field should receive focus next
expect(await page.locator('#join-email').evaluate((el) => el === document.activeElement)).toBe(true)
})
test('escape closes login modal', async ({ page }) => {
await page.goto('/member/dashboard')
// Wait for login modal to appear
const modal = page.locator('text=Sign in to continue').or(page.locator('text=Sign in to your dashboard'))
await expect(modal.first()).toBeVisible({ timeout: 10000 })
await page.keyboard.press('Escape')
// Modal should close
await expect(modal.first()).not.toBeVisible({ timeout: 5000 })
})
})

55
e2e/admin-events.spec.js Normal file
View file

@ -0,0 +1,55 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('Admin events list', () => {
test('events list loads for admin', async ({ adminPage }) => {
await adminPage.goto('/admin/events')
await expect(adminPage.locator('h1')).toContainText('Events')
})
test('create event button present', async ({ adminPage }) => {
await adminPage.goto('/admin/events')
await expect(
adminPage.getByRole('link', { name: 'Create Event' })
).toBeVisible()
})
})
test.describe('Admin events create', () => {
test('create event page loads', async ({ adminPage }) => {
await adminPage.goto('/admin/events/create')
await expect(adminPage.locator('h1')).toContainText('Create Event')
})
test('create event form has required fields', async ({ adminPage }) => {
await adminPage.goto('/admin/events/create')
// Title input
await expect(
adminPage.getByPlaceholder('Enter a clear, descriptive event title')
).toBeVisible()
// Description textarea
await expect(
adminPage.getByPlaceholder(
'Provide a clear description of what attendees can expect from this event'
)
).toBeVisible()
// Event type select (USelect renders a button with the selected value)
await expect(adminPage.getByText('Event Type')).toBeVisible()
})
})
test.describe('Admin events access control', () => {
test('non-admin redirect', async ({ page }) => {
await page.goto('/admin/events')
// Admin middleware redirects unauthenticated users to /
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
expect(page.url()).not.toContain('/admin/events')
})
})

48
e2e/admin-members.spec.js Normal file
View file

@ -0,0 +1,48 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('Admin members page', () => {
test('members list loads for admin', async ({ adminPage }) => {
await adminPage.goto('/admin/members')
await expect(adminPage.locator('h1')).toHaveText('Members')
await expect(adminPage.getByText('Manage members, contributions, and access')).toBeVisible()
})
test('search bar works', async ({ adminPage }) => {
await adminPage.goto('/admin/members')
const searchInput = adminPage.getByPlaceholder('Search members...')
await expect(searchInput).toBeVisible()
await searchInput.fill('nonexistent-query-xyz')
// Page should not crash -- either shows filtered results or the empty state
await expect(
adminPage.locator('table').or(adminPage.getByText('No members found matching your criteria'))
).toBeVisible()
})
test('non-admin redirect', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('/admin/members')
// Admin middleware redirects non-admin users to / or /members
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
expect(page.url()).not.toContain('/admin/members')
await context.close()
})
test('add member button opens modal', async ({ adminPage }) => {
await adminPage.goto('/admin/members')
await adminPage.getByRole('button', { name: 'Add Member' }).click()
// Modal should appear with the form heading and fields
await expect(adminPage.getByText('Add New Member')).toBeVisible()
await expect(adminPage.getByPlaceholder('Full name')).toBeVisible()
await expect(adminPage.getByPlaceholder('email@example.com')).toBeVisible()
})
})

54
e2e/auth.spec.js Normal file
View file

@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test'
import { loginAsAdmin, loginAsMember } from './helpers/auth.js'
test.describe('Authentication flows', () => {
test('protected page shows login modal when logged out', async ({ page }) => {
// Navigate to a protected member page without being logged in
await page.goto('/member/dashboard')
// The auth middleware aborts navigation and shows the login modal
// Look for the modal title and email input
await expect(page.getByText('Sign in to continue')).toBeVisible()
await expect(page.locator('input[type="email"]')).toBeVisible()
await expect(page.getByRole('button', { name: 'Send magic link' })).toBeVisible()
})
test('admin login and redirect', async ({ page }) => {
await loginAsAdmin(page)
// loginAsAdmin waits for /admin URL
await expect(page).toHaveURL(/\/admin/)
// Admin layout should show admin sidebar content
await expect(page.locator('.sidebar-nav').getByText('Members')).toBeVisible()
await expect(page.locator('.admin-tag')).toBeVisible()
})
test('member login and redirect', async ({ page }) => {
await loginAsMember(page, 'test-admin@ghostguild.dev')
// loginAsMember waits for /member/ URL
await expect(page).toHaveURL(/\/member\//)
})
test('logout clears auth', async ({ page }) => {
// Login as admin first
await loginAsAdmin(page)
await expect(page).toHaveURL(/\/admin/)
// Click the "Sign out" link in the sidebar meta area
await page.locator('.sidebar-meta a').filter({ hasText: 'Sign out' }).click()
// Should redirect to home after logout
await page.waitForURL('/')
// Verify the auth-token cookie is cleared
const cookies = await page.context().cookies()
const authCookie = cookies.find((c) => c.name === 'auth-token')
expect(!authCookie || authCookie.value === '').toBeTruthy()
// Navigating to a protected page should show the login modal
await page.goto('/member/dashboard')
await expect(page.getByText('Sign in to continue')).toBeVisible()
})
})

41
e2e/coming-soon.spec.js Normal file
View file

@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test'
test.describe('coming-soon page', () => {
test('renders with heading and login form', async ({ page }) => {
await page.goto('/coming-soon')
await expect(page.locator('h1')).toContainText('Ghost Guild')
await expect(page.locator('input[type="email"]')).toBeVisible()
await expect(page.getByRole('button', { name: 'Send Magic Link' })).toBeVisible()
})
test('shows "Coming Soon" text for unauthenticated visitors', async ({ page }) => {
await page.goto('/coming-soon')
await expect(page.getByText('Coming Soon')).toBeVisible()
})
})
test.describe('public routes accessible when gate is off', () => {
test('home page loads', async ({ page }) => {
await page.goto('/')
// Should not redirect to /coming-soon
expect(page.url()).not.toContain('/coming-soon')
await expect(page.getByText('Ghost Guild')).toBeVisible()
})
test('events page loads', async ({ page }) => {
await page.goto('/events')
expect(page.url()).not.toContain('/coming-soon')
await expect(page.locator('h1')).toContainText('Events')
})
test('join page loads', async ({ page }) => {
await page.goto('/join')
expect(page.url()).not.toContain('/coming-soon')
await expect(page.locator('h1')).toContainText('Join Ghost Guild')
})
})

64
e2e/events.spec.js Normal file
View file

@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test'
test.describe('Events list page', () => {
test('events list loads', async ({ page }) => {
await page.goto('/events')
await expect(page.locator('h1', { hasText: 'Events' })).toBeVisible()
})
test('filter bar has type filters', async ({ page }) => {
await page.goto('/events')
const filterBar = page.locator('.filter-bar')
await expect(filterBar).toBeVisible()
for (const label of ['All', 'Workshops', 'Community', 'Social', 'Showcase']) {
await expect(filterBar.locator('button', { hasText: label })).toBeVisible()
}
})
test('past events toggle exists and can be checked', async ({ page }) => {
await page.goto('/events')
const checkbox = page.locator('input[type="checkbox"]')
await expect(checkbox).toBeVisible()
await expect(page.locator('text=Show past events')).toBeVisible()
await checkbox.check()
await expect(checkbox).toBeChecked()
// Page should still render without errors after toggling
await expect(page.locator('h1', { hasText: 'Events' })).toBeVisible()
})
test('clicking a filter button activates it', async ({ page }) => {
await page.goto('/events')
const workshopsBtn = page.locator('.filter-bar button', { hasText: 'Workshops' })
await workshopsBtn.click()
await expect(workshopsBtn).toHaveClass(/active/)
})
test('event links navigate to detail page', async ({ page }) => {
await page.goto('/events')
// Check the past events toggle so we see all events
await page.locator('input[type="checkbox"]').check()
const eventLinks = page.locator('.event-row a')
const count = await eventLinks.count()
if (count === 0) {
// No events in the database — just verify the empty state renders
await expect(page.locator('.empty', { hasText: 'No events found' })).toBeVisible()
return
}
// Click the first event link and verify navigation
const firstLink = eventLinks.first()
const href = await firstLink.getAttribute('href')
await firstLink.click()
await page.waitForURL(/\/events\//)
expect(page.url()).toContain('/events/')
// Detail page should have an h1 with the event title
await expect(page.locator('h1')).toBeVisible()
})
})

21
e2e/helpers/auth.js Normal file
View file

@ -0,0 +1,21 @@
/**
* Login helpers using dev endpoints.
* These set real httpOnly JWT cookies so all middleware works naturally.
*/
/**
* Login as admin via the dev test-login endpoint.
* Creates a test admin user if none exists.
*/
export async function loginAsAdmin(page) {
await page.goto('/api/dev/test-login')
await page.waitForURL('**/admin**')
}
/**
* Login as a specific member by email via the dev member-login endpoint.
*/
export async function loginAsMember(page, email) {
await page.goto(`/api/dev/member-login?email=${encodeURIComponent(email)}`)
await page.waitForURL('**/member/**')
}

23
e2e/helpers/fixtures.js Normal file
View file

@ -0,0 +1,23 @@
import { test as base } from '@playwright/test'
import { loginAsAdmin, loginAsMember } from './auth.js'
/**
* Extended test fixtures with pre-authenticated pages.
*/
export const test = base.extend({
adminPage: async ({ page }, use) => {
await loginAsAdmin(page)
await use(page)
},
memberPage: async ({ browser }, use) => {
// Uses a default test member — tests needing a specific member
// should use loginAsMember directly
const context = await browser.newContext()
const page = await context.newPage()
await loginAsMember(page, 'test-admin@ghostguild.dev')
await use(page)
await context.close()
},
})
export { expect } from '@playwright/test'

83
e2e/join-flow.spec.js Normal file
View file

@ -0,0 +1,83 @@
import { test, expect } from '@playwright/test'
test.describe('Join page — member signup flow', () => {
test('join form loads with all fields', async ({ page }) => {
await page.goto('/join')
await expect(page.locator('#join-name')).toBeVisible()
await expect(page.locator('#join-email')).toBeVisible()
await expect(page.locator('#circle-community')).toBeAttached()
await expect(page.locator('#circle-founder')).toBeAttached()
await expect(page.locator('#circle-practitioner')).toBeAttached()
await expect(page.locator('#join-contribution')).toBeVisible()
await expect(page.locator('.form-submit')).toBeVisible()
})
test('submit button disabled when form incomplete', async ({ page }) => {
await page.goto('/join')
// Clear name and email — circle defaults to community, contribution defaults to $15
await page.locator('#join-name').fill('')
await page.locator('#join-email').fill('')
// Button should be disabled with empty required fields
await expect(page.locator('.form-submit')).toBeDisabled()
// Fill only name — still incomplete
await page.locator('#join-name').fill('Test User')
await expect(page.locator('.form-submit')).toBeDisabled()
// Fill email too — now all fields are populated and button should be enabled
await page.locator('#join-email').fill('incomplete-test@example.com')
await expect(page.locator('.form-submit')).toBeEnabled()
})
test('fill and submit free tier', async ({ page }) => {
const uniqueEmail = `test-e2e-${Date.now()}@example.com`
await page.goto('/join')
await page.locator('#join-name').fill('E2E Test User')
await page.locator('#join-email').fill(uniqueEmail)
await page.locator('#circle-community').check({ force: true })
await page.locator('#join-contribution').selectOption('0')
await expect(page.locator('.form-submit')).toBeEnabled()
await page.locator('.form-submit').click()
// Free tier skips payment (step 2) and goes to confirmation (step 3)
// or redirects to /welcome. Wait for either outcome.
await expect(
page.getByText('Welcome to Ghost Guild!').or(page.locator('.success-box'))
).toBeVisible({ timeout: 15000 })
})
test('duplicate email shows error', async ({ page }) => {
// First submission — create a member
const duplicateEmail = `test-e2e-dup-${Date.now()}@example.com`
await page.goto('/join')
await page.locator('#join-name').fill('Dup Test User')
await page.locator('#join-email').fill(duplicateEmail)
await page.locator('#circle-community').check({ force: true })
await page.locator('#join-contribution').selectOption('0')
await page.locator('.form-submit').click()
// Wait for first submission to succeed
await expect(
page.getByText('Welcome to Ghost Guild!').or(page.locator('.success-box'))
).toBeVisible({ timeout: 15000 })
// Navigate back and try to register the same email again
await page.goto('/join')
await page.locator('#join-name').fill('Dup Test User Again')
await page.locator('#join-email').fill(duplicateEmail)
await page.locator('#circle-community').check({ force: true })
await page.locator('#join-contribution').selectOption('0')
await page.locator('.form-submit').click()
// Should show an error about the email already existing
await expect(page.locator('.error-box')).toBeVisible({ timeout: 10000 })
await expect(page.locator('.error-box')).toContainText(/already/i)
})
})

View file

@ -0,0 +1,35 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('Member dashboard', () => {
test('dashboard loads for authenticated user', async ({ adminPage }) => {
await adminPage.goto('/member/dashboard')
await expect(adminPage.getByText('Welcome back')).toBeVisible({ timeout: 10000 })
})
test('shows navigation links', async ({ adminPage }) => {
await adminPage.goto('/member/dashboard')
// Wait for dashboard content to render
await expect(adminPage.getByText('Welcome back')).toBeVisible({ timeout: 10000 })
// Verify quick action links are present
await expect(adminPage.getByText('Update your profile')).toBeVisible()
await expect(adminPage.getByText('Browse members')).toBeVisible()
await expect(adminPage.getByText('Manage account')).toBeVisible()
})
test('unauthenticated shows sign-in prompt', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('/member/dashboard')
// Should show the sign-in required message or a login modal
await expect(
page.getByText('Sign in required').or(page.getByText('Sign in to your dashboard'))
).toBeVisible({ timeout: 10000 })
await context.close()
})
})

View file

@ -0,0 +1,51 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('Member profile page', () => {
test('profile page loads', async ({ adminPage }) => {
await adminPage.goto('/member/profile')
await expect(adminPage.getByText('Edit Profile')).toBeVisible()
await expect(adminPage.getByText('How you appear to other members')).toBeVisible()
})
test('form fields are present', async ({ adminPage }) => {
await adminPage.goto('/member/profile')
// Name input
await expect(adminPage.locator('input[placeholder="Your name"]')).toBeVisible()
// Bio textarea
await expect(adminPage.locator('textarea[placeholder*="Share your background"]')).toBeVisible()
// Save button
await expect(adminPage.getByRole('button', { name: 'Save Profile' })).toBeVisible()
})
test('bio field accepts input', async ({ adminPage }) => {
await adminPage.goto('/member/profile')
const bio = adminPage.locator('textarea[placeholder*="Share your background"]')
const saveBtn = adminPage.getByRole('button', { name: 'Save Profile' })
// Save button should start disabled (no changes yet)
await expect(saveBtn).toBeDisabled()
// Clear and type new text
await bio.clear()
await bio.fill('Game designer exploring cooperative structures')
// Save button should now be enabled
await expect(saveBtn).toBeEnabled()
})
test('pronouns field editable', async ({ adminPage }) => {
await adminPage.goto('/member/profile')
const pronouns = adminPage.locator('input[placeholder="e.g., she/her, they/them"]')
await expect(pronouns).toBeVisible()
await pronouns.clear()
await pronouns.fill('they/them')
await expect(pronouns).toHaveValue('they/them')
})
})

123
e2e/updates.spec.js Normal file
View file

@ -0,0 +1,123 @@
import { test, expect } from './helpers/fixtures.js'
test.describe('My Updates page', () => {
test('authenticated user sees the my-updates page', async ({ adminPage }) => {
await adminPage.goto('/member/my-updates')
await expect(adminPage.locator('h1', { hasText: 'My Updates' })).toBeVisible({
timeout: 10000,
})
})
test('authenticated user sees the new update link', async ({ adminPage }) => {
await adminPage.goto('/member/my-updates')
// Wait for ClientOnly content to hydrate
await expect(adminPage.locator('h1', { hasText: 'My Updates' })).toBeVisible({
timeout: 10000,
})
// The page shows either the "+ New Update" button (stats row) or
// the "+ Post Your First Update" link (empty state) — both go to /updates/new
const newUpdateLink = adminPage.locator('a[href="/updates/new"]')
await expect(newUpdateLink.first()).toBeVisible({ timeout: 10000 })
})
test('unauthenticated user sees sign-in prompt', async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('/member/my-updates')
await expect(
page
.getByText('Sign in required')
.or(page.getByText('Sign in to view your updates'))
).toBeVisible({ timeout: 10000 })
await context.close()
})
})
test.describe('New Update page', () => {
test('loads the new update form', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
timeout: 10000,
})
// Form elements are present
await expect(adminPage.locator('textarea')).toBeVisible()
await expect(adminPage.locator('select')).toBeVisible()
// Submit button exists and starts disabled (empty textarea)
const submitBtn = adminPage.locator('button[type="submit"]')
await expect(submitBtn).toBeVisible()
await expect(submitBtn).toBeDisabled()
})
test('submit button enables when content is entered', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
timeout: 10000,
})
const textarea = adminPage.locator('textarea')
const submitBtn = adminPage.locator('button[type="submit"]')
await expect(submitBtn).toBeDisabled()
await textarea.fill('Test update content')
await expect(submitBtn).toBeEnabled()
})
test('privacy selector defaults to members and has all options', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
timeout: 10000,
})
const select = adminPage.locator('select')
await expect(select).toHaveValue('members')
// Verify all three privacy options exist
await expect(select.locator('option[value="members"]')).toBeAttached()
await expect(select.locator('option[value="public"]')).toBeAttached()
await expect(select.locator('option[value="private"]')).toBeAttached()
})
test('cancel link navigates back to my-updates', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
await expect(adminPage.locator('h1', { hasText: 'New Update' })).toBeVisible({
timeout: 10000,
})
const cancelLink = adminPage.locator('a', { hasText: 'Cancel' })
await expect(cancelLink).toHaveAttribute('href', '/member/my-updates')
})
test('back link points to my-updates', async ({ adminPage }) => {
await adminPage.goto('/updates/new')
const backLink = adminPage.locator('.back-link a')
await expect(backLink).toBeVisible({ timeout: 10000 })
await expect(backLink).toHaveAttribute('href', '/member/my-updates')
})
})
test.describe('Updates API (public access)', () => {
test('public updates endpoint returns data', async ({ page }) => {
const response = await page.request.get('/api/updates')
expect(response.ok()).toBe(true)
const data = await response.json()
expect(data).toHaveProperty('updates')
expect(data).toHaveProperty('total')
expect(data).toHaveProperty('hasMore')
expect(Array.isArray(data.updates)).toBe(true)
})
})

73
e2e/visual/pages.spec.js Normal file
View file

@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test'
import { loginAsAdmin } from '../helpers/auth.js'
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' },
]
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' },
]
// 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)
}
test.describe('visual regression — 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: [
// Mask dynamic content like dates and counts
page.locator('.event-date'),
page.locator('.event-count'),
page.locator('time'),
],
})
})
}
}
})
test.describe('visual regression — authenticated pages', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page)
})
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: [
page.locator('.event-date'),
page.locator('time'),
page.locator('.member-since'),
],
})
})
}
})