feat: add testing infrastructure — Vitest, Playwright, CI, git hooks
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:
parent
036af95e00
commit
1e30ba23cd
35 changed files with 3637 additions and 5 deletions
112
e2e/a11y.spec.js
Normal file
112
e2e/a11y.spec.js
Normal 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
55
e2e/admin-events.spec.js
Normal 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
48
e2e/admin-members.spec.js
Normal 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
54
e2e/auth.spec.js
Normal 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
41
e2e/coming-soon.spec.js
Normal 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
64
e2e/events.spec.js
Normal 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
21
e2e/helpers/auth.js
Normal 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
23
e2e/helpers/fixtures.js
Normal 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
83
e2e/join-flow.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
35
e2e/member-dashboard.spec.js
Normal file
35
e2e/member-dashboard.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
51
e2e/member-profile.spec.js
Normal file
51
e2e/member-profile.spec.js
Normal 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
123
e2e/updates.spec.js
Normal 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
73
e2e/visual/pages.spec.js
Normal 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'),
|
||||
],
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue