diff --git a/dashboards/tracker-web/e2e/tracker.spec.ts b/dashboards/tracker-web/e2e/tracker.spec.ts index 711fe05b..ba30bc48 100644 --- a/dashboards/tracker-web/e2e/tracker.spec.ts +++ b/dashboards/tracker-web/e2e/tracker.spec.ts @@ -1,16 +1,167 @@ -import { test, expect } from '@playwright/test'; +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. * - * Tests cover login page, authentication redirects, public roadmap, - * and dashboard structure. + * 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/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.getByText('Tracker')).toBeVisible(); + 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(); @@ -23,20 +174,28 @@ test.describe('Tracker Login Page', () => { }); 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(/failed|error|invalid/i)).toBeVisible({ - timeout: 10000, - }); + await expect(page.getByText('Invalid email or password')).toBeVisible({ timeout: 10000 }); }); test('shows loading state on submit', async ({ page }) => { - // Block API to keep loading state visible + // Delay the login response so the loading state stays visible. await page.route( - '**/api/auth/**', - route => new Promise(resolve => setTimeout(() => resolve(route.abort()), 3000)) + '**/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'); @@ -44,8 +203,17 @@ test.describe('Tracker Login Page', () => { 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('/'); @@ -58,57 +226,133 @@ test.describe('Tracker — Protected Routes', () => { }); }); -test.describe('Tracker — Public Roadmap', () => { - test('roadmap page renders board layout', async ({ page }) => { - await page.goto('/roadmap'); - // The roadmap page is public — no auth required - await expect(page.getByText(/roadmap/i).first()).toBeVisible({ - 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: {} }); }); - }); - test('roadmap page has search and filter controls', async ({ page }) => { - await page.goto('/roadmap'); - // Should have a search input - await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({ timeout: 10000 }); - }); + 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(); - test('roadmap page has submit suggestion button', async ({ page }) => { - await page.goto('/roadmap'); - await expect(page.getByRole('button', { name: /suggest|submit|new/i }).first()).toBeVisible({ - timeout: 10000, - }); - }); - - test('roadmap page shows status columns in board view', async ({ page }) => { - await page.goto('/roadmap'); - // Board view shows Planned, In Progress, Complete columns - await expect(page.getByText('Planned').first()).toBeVisible({ - timeout: 10000, - }); - await expect(page.getByText('In Progress').first()).toBeVisible(); - await expect(page.getByText('Complete').first()).toBeVisible(); - }); - - test('roadmap page can toggle between board and list view', async ({ page }) => { - await page.goto('/roadmap'); - // Look for view toggle buttons - const listBtn = page.getByRole('button', { name: /list/i }); - if (await listBtn.isVisible()) { - await listBtn.click(); - // Should now show list view - await expect(page.locator("table, [role='list']").first()).toBeVisible({ - timeout: 5000, - }); - } + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + await expect(page.getByText('42')).toBeVisible(); + await expect(page.getByText('admin@example.com')).toBeVisible(); }); }); +// ── 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(); + await page.getByRole('button', { 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('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', async ({ request }) => { + 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); }); }); diff --git a/dashboards/tracker-web/eslint.config.mjs b/dashboards/tracker-web/eslint.config.mjs index c3cc933d..36715651 100644 --- a/dashboards/tracker-web/eslint.config.mjs +++ b/dashboards/tracker-web/eslint.config.mjs @@ -15,6 +15,9 @@ const eslintConfig = defineConfig([ ".next/**", "out/**", "build/**", + "coverage/**", + "test-results/**", + "playwright-report/**", "next-env.d.ts", ]), ]); diff --git a/dashboards/tracker-web/playwright.config.ts b/dashboards/tracker-web/playwright.config.ts index 46de4b53..eae09dcb 100644 --- a/dashboards/tracker-web/playwright.config.ts +++ b/dashboards/tracker-web/playwright.config.ts @@ -22,5 +22,12 @@ export default defineConfig({ url: 'http://localhost:3003', reuseExistingServer: !process.env.CI, timeout: 30_000, + // Provide the env vars /api/health requires so the health gate is deterministic. + // These are non-secret placeholders only used to exercise the health handler. + env: { + PLATFORM_API_URL: 'http://localhost:4003', + JWT_SECRET: 'e2e-placeholder-not-a-real-secret', + DEFAULT_PRODUCT_ID: 'tracker-e2e', + }, }, }); diff --git a/dashboards/tracker-web/src/app/roadmap/page.tsx b/dashboards/tracker-web/src/app/roadmap/page.tsx index 746f3017..042a53a2 100644 --- a/dashboards/tracker-web/src/app/roadmap/page.tsx +++ b/dashboards/tracker-web/src/app/roadmap/page.tsx @@ -256,6 +256,7 @@ export default function RoadmapPage() { setSubmitForm({ ...submitForm, type: e.target.value })} + aria-label="Request type" className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm" >