import { test, expect, type Page, type Route } from '@playwright/test'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; /** * E2E tests for the Tracker dashboard. * * These tests are deterministic: every call that would hit the backend * platform-service is mocked at the Next.js proxy boundary (`/api/tracker/**`, * `/api/auth/**`). No live platform-service is required. The only real server * handler exercised end-to-end is `/api/health`, whose required env vars are * provided by the Playwright `webServer.env` config. */ /** * Locate the axe-core browser source so we can inject it into the page. * * axe-core is already present in this pnpm monorepo as a transitive dependency * of eslint-plugin-jsx-a11y (pulled in by eslint-config-next), so we resolve it * from the store/node_modules rather than declaring a redundant dependency. * Playwright runs this suite with cwd at the package root (dashboards/tracker-web). */ function resolveAxeSource(): string { const candidates = [ join(process.cwd(), 'node_modules', 'axe-core', 'axe.js'), join(process.cwd(), '..', '..', 'node_modules', 'axe-core', 'axe.js'), ]; for (const candidate of candidates) { if (existsSync(candidate)) return readFileSync(candidate, 'utf-8'); } // Fall back to scanning the pnpm store at the monorepo root. const storeDir = join(process.cwd(), '..', '..', 'node_modules', '.pnpm'); const entry = readdirSync(storeDir).find(d => d.startsWith('axe-core@')); if (!entry) { throw new Error('Could not locate axe-core in node_modules or the pnpm store'); } return readFileSync(join(storeDir, entry, 'node_modules', 'axe-core', 'axe.js'), 'utf-8'); } const AXE_SOURCE = resolveAxeSource(); // ── Fixtures ──────────────────────────────────────────────────────── type RoadmapItem = { id: string; productId: string; type: 'bug' | 'feature' | 'task'; status: 'open' | 'in_progress' | 'done'; priority: 'critical' | 'high' | 'medium' | 'low'; title: string; description: string; labels: string[]; assignee: string | null; reportedBy: string; source: 'internal' | 'user_submitted' | 'auto_detected'; visibility: 'internal' | 'public'; voteCount: number; commentCount: number; targetRelease: string | null; createdAt: string; updatedAt: string; }; function makeItem(partial: Partial & { id: string; title: string }): RoadmapItem { return { productId: 'tracker-e2e', type: 'feature', status: 'open', priority: 'medium', description: 'A sample roadmap item used in e2e tests.', labels: [], assignee: null, reportedBy: 'system', source: 'internal', visibility: 'public', voteCount: 0, commentCount: 0, targetRelease: null, createdAt: '2025-01-01T00:00:00.000Z', updatedAt: '2025-01-01T00:00:00.000Z', ...partial, }; } const ROADMAP_ITEMS: RoadmapItem[] = [ makeItem({ id: '1', title: 'Dark mode toggle', status: 'open', type: 'feature', voteCount: 12 }), makeItem({ id: '2', title: 'Faster sync engine', status: 'in_progress', type: 'feature', priority: 'high', voteCount: 8, }), makeItem({ id: '3', title: 'Export to CSV', status: 'done', type: 'task', voteCount: 5 }), ]; const ROADMAP_STATS = { total: 3, byStatus: { open: 1, in_progress: 1, done: 1 }, byType: { feature: 2, task: 1 }, totalVotes: 25, }; /** Mock the public roadmap proxy endpoints so the page renders deterministically. */ async function mockRoadmap(page: Page): Promise { await page.route('**/api/tracker/**', async (route: Route) => { const url = route.request().url(); if (url.includes('/public/roadmap/stats')) { return route.fulfill({ json: ROADMAP_STATS }); } if (url.includes('/public/items/')) { const id = url.split('/public/items/')[1]?.split(/[?#]/)[0]; const item = ROADMAP_ITEMS.find(entry => entry.id === id); return item ? route.fulfill({ json: item }) : route.fulfill({ status: 404, json: { error: 'Item not found' } }); } if (url.includes('/public/roadmap')) { return route.fulfill({ json: { items: ROADMAP_ITEMS, total: ROADMAP_ITEMS.length, limit: 100, offset: 0 }, }); } // Default: empty success so nothing else hangs on the network. return route.fulfill({ json: {} }); }); } /** Inject axe-core and return only serious/critical accessibility violations. */ async function seriousA11yViolations(page: Page): Promise<{ id: string; impact: string }[]> { await page.addScriptTag({ content: AXE_SOURCE }); const violations = await page.evaluate(async () => { // axe is injected onto window by the script tag above. const axe = ( window as unknown as { axe: { run: ( ctx: unknown, opts: unknown ) => Promise<{ violations: { id: string; impact: string | null }[] }>; }; } ).axe; const result = await axe.run(document, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] }, }); return result.violations.map(v => ({ id: v.id, impact: v.impact ?? 'unknown' })); }); return violations.filter(v => v.impact === 'serious' || v.impact === 'critical'); } /** Collect uncaught page errors and console.error messages (minus dev-only noise). */ function collectPageErrors(page: Page): string[] { const errors: string[] = []; const benign = ['favicon', 'Download the React DevTools', 'Failed to load resource', 'net::ERR_']; page.on('pageerror', err => errors.push(`pageerror: ${err.message}`)); page.on('console', msg => { if (msg.type() !== 'error') return; const text = msg.text(); if (benign.some(b => text.includes(b))) return; errors.push(`console.error: ${text}`); }); return errors; } // ── Login page ────────────────────────────────────────────────────── test.describe('Tracker Login Page', () => { test('shows login form with correct branding', async ({ page }) => { await page.goto('/login'); await expect(page.getByRole('heading', { name: 'Tracker' })).toBeVisible(); await expect(page.getByText('Feature requests, bugs & task management')).toBeVisible(); await expect(page.getByLabel('Email')).toBeVisible(); await expect(page.getByLabel('Password')).toBeVisible(); await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible(); }); test('shows credentials hint', async ({ page }) => { await page.goto('/login'); await expect(page.getByText(/platform-service credentials/i)).toBeVisible(); }); test('shows error for invalid credentials', async ({ page }) => { // Mock the login proxy to reject the credentials deterministically. await page.route('**/api/auth/login', (route: Route) => route.fulfill({ status: 401, json: { error: 'Invalid email or password' } }) ); await page.goto('/login'); await page.getByLabel('Email').fill('bad@user.com'); await page.getByLabel('Password').fill('wrongpassword'); await page.getByRole('button', { name: /sign in/i }).click(); await expect(page.getByText('Invalid email or password')).toBeVisible({ timeout: 10000 }); }); test('shows loading state on submit', async ({ page }) => { // Delay the login response so the loading state stays visible. await page.route( '**/api/auth/login', route => new Promise(resolve => setTimeout(() => { resolve(); route.fulfill({ status: 401, json: { error: 'nope' } }); }, 3000) ) ); await page.goto('/login'); await page.getByLabel('Email').fill('test@example.com'); await page.getByLabel('Password').fill('password123'); await page.getByRole('button', { name: /sign in/i }).click(); await expect(page.getByText('Signing in...')).toBeVisible(); }); test('has no serious accessibility violations', async ({ page }) => { await page.goto('/login'); await expect(page.getByRole('heading', { name: 'Tracker' })).toBeVisible(); const violations = await seriousA11yViolations(page); expect(violations, JSON.stringify(violations, null, 2)).toEqual([]); }); }); // ── Protected routes ──────────────────────────────────────────────── test.describe('Tracker — Protected Routes', () => { test('/ redirects to login when not authenticated', async ({ page }) => { await page.goto('/'); await expect(page).toHaveURL(/\/login/, { timeout: 10000 }); }); test('/dashboard redirects to login when not authenticated', async ({ page }) => { await page.goto('/dashboard'); await expect(page).toHaveURL(/\/login/, { timeout: 10000 }); }); }); // ── Login → dashboard happy path (fully mocked) ───────────────────── test.describe('Tracker — Authenticated dashboard', () => { test('login redirects to dashboard and renders stats', async ({ page }) => { await page.route('**/api/auth/login', (route: Route) => route.fulfill({ json: { accessToken: 'fake-e2e-token', user: { id: 'u1', email: 'admin@example.com', role: 'admin', displayName: 'Admin' }, }, }) ); await page.route('**/api/auth/me', (route: Route) => route.fulfill({ json: { id: 'u1', email: 'admin@example.com', role: 'admin', displayName: 'Admin' }, }) ); await page.route('**/api/tracker/**', (route: Route) => { if (route.request().url().includes('/items/stats')) { return route.fulfill({ json: { productId: 'tracker-e2e', total: 42, byType: { bug: 10, feature: 30, task: 2 }, byStatus: { open: 20, in_progress: 12, done: 10 }, byPriority: { critical: 2, high: 10, medium: 20, low: 10 }, }, }); } return route.fulfill({ json: {} }); }); await page.goto('/login'); await page.getByLabel('Email').fill('admin@example.com'); await page.getByLabel('Password').fill('correct-password'); await page.getByRole('button', { name: /sign in/i }).click(); await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); await expect( page.getByTestId('bl-number-flow').filter({ hasText: '42' }).first() ).toBeVisible(); await expect(page.getByText('admin@example.com')).toBeVisible(); }); test('renders settings for admin configuration', async ({ page }) => { await page.route('**/api/auth/me', (route: Route) => route.fulfill({ json: { id: 'u1', email: 'admin@example.com', role: 'admin', displayName: 'Admin' }, }) ); await page.addInitScript(() => localStorage.setItem('tracker_token', 'fake-e2e-token')); await page.goto('/dashboard/settings'); await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Product context' })).toBeVisible(); await expect(page.getByLabel('Default product ID')).toBeVisible(); await expect(page.getByText('/api/tracker/[...path]')).toBeVisible(); await expect(page.getByRole('link', { name: /open public roadmap/i })).toHaveAttribute( 'href', '/roadmap' ); }); }); // ── Public roadmap (mocked) ───────────────────────────────────────── test.describe('Tracker — Public Roadmap', () => { test.beforeEach(async ({ page }) => { await mockRoadmap(page); }); test('renders header and stats from mocked data', async ({ page }) => { await page.goto('/roadmap'); await expect(page.getByRole('heading', { name: /product roadmap/i })).toBeVisible(); await expect(page.getByText('Total Items')).toBeVisible(); // totalVotes stat from ROADMAP_STATS await expect(page.getByText('25')).toBeVisible(); }); test('has search and filter controls', async ({ page }) => { await page.goto('/roadmap'); await expect(page.getByPlaceholder(/search/i).first()).toBeVisible(); }); test('has a submit-idea button', async ({ page }) => { await page.goto('/roadmap'); await expect(page.getByRole('button', { name: /submit idea/i })).toBeVisible(); }); test('shows status columns in board view with items', async ({ page }) => { await page.goto('/roadmap'); await expect(page.getByRole('heading', { name: 'Planned' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'In Progress' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Complete' })).toBeVisible(); // Items land in the correct columns await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Faster sync engine' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Export to CSV' })).toBeVisible(); }); test('can toggle between board and list view', async ({ page }) => { await page.goto('/roadmap'); await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible(); // The view toggle is a shared SegmentedControl (role=radio), UX-12.1. await page.getByRole('radio', { name: 'List', exact: true }).click(); // List view still shows the items (rendered as rows, not a ) await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Export to CSV' })).toBeVisible(); }); test('opens the submit-idea modal', async ({ page }) => { await page.goto('/roadmap'); await page.getByRole('button', { name: /submit idea/i }).click(); await expect(page.getByRole('heading', { name: /submit an idea/i })).toBeVisible(); await expect(page.getByPlaceholder('Your email')).toBeVisible(); }); test('prompts for email when voting without a saved email', async ({ page }) => { await page.goto('/roadmap'); await page.getByRole('button', { name: /upvote dark mode toggle/i }).click(); await expect(page.getByRole('heading', { name: /enter your email to vote/i })).toBeVisible(); }); test('links submitted users to a public status page for an item', async ({ page }) => { await page.goto('/status/1'); await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible(); await expect(page.getByText('Submission status')).toBeVisible(); await expect( page.getByText('We received this submission and it is waiting for triage.') ).toBeVisible(); await expect(page.getByText('12 votes')).toBeVisible(); await expect(page.getByRole('link', { name: /back to roadmap/i })).toBeVisible(); }); test('shows a helpful message for an unknown public status item', async ({ page }) => { await page.goto('/status/missing'); await expect(page.getByRole('heading', { name: /submission not found/i })).toBeVisible(); await expect(page.getByRole('link', { name: /view roadmap/i })).toBeVisible(); }); test('has no serious accessibility violations', async ({ page }) => { await page.goto('/roadmap'); await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible(); const violations = await seriousA11yViolations(page); expect(violations, JSON.stringify(violations, null, 2)).toEqual([]); }); test('logs no unexpected console errors', async ({ page }) => { const errors = collectPageErrors(page); await page.goto('/roadmap'); await expect(page.getByRole('heading', { name: 'Dark mode toggle' })).toBeVisible(); expect(errors, errors.join('\n')).toEqual([]); }); }); // ── Health endpoint (real handler, env provided by webServer) ─────── test.describe('Tracker — Health', () => { test('GET /api/health returns ok with required env set', async ({ request }) => { const res = await request.get('/api/health'); expect(res.ok()).toBeTruthy(); const body = await res.json(); expect(body.status).toBe('ok'); expect(body.service).toBe('tracker-dashboard'); expect(Array.isArray(body.checks)).toBe(true); expect(body.checks.every((c: { status: string }) => c.status === 'pass')).toBe(true); }); });