/** * Route Health Check Suite * * Visits every publicly accessible URL in the application and verifies: * - HTTP status is not 4xx / 5xx (or the expected status for auth-guarded pages) * - No Laravel error page is rendered ("Whoops", "Server Error", stack traces) * - No uncaught JavaScript exceptions (window.onerror / unhandledrejection) * - No browser console errors * - Page has a non-empty and a visible <body> * * Auth-guarded routes are tested to confirm they redirect cleanly to /login * rather than throwing an error. * * Run: * npx playwright test tests/e2e/routes.spec.ts * npx playwright test tests/e2e/routes.spec.ts --reporter=html */ import { test, expect, type Page } from '@playwright/test'; // ───────────────────────────────────────────────────────────────────────────── // Route registry // ───────────────────────────────────────────────────────────────────────────── interface RouteFixture { /** URL path (relative to baseURL) */ url: string; /** Human-readable label shown in test output */ label: string; /** * When set, assert page.url() contains this string after navigation. * When absent, no URL assertion is made (redirects are tolerated by default). */ expectUrlContains?: string; /** When true, expect the browser to land on the login page */ requiresAuth?: boolean; /** Skip entirely — use for routes that need real DB fixtures not guaranteed in CI */ skip?: boolean; /** Additional text that MUST be present somewhere on the page */ bodyContains?: string; } // Public routes — must return 200 with no errors const PUBLIC_ROUTES: RouteFixture[] = [ // ── Core ────────────────────────────────────────────────────────────────── { url: '/', label: 'Home page' }, { url: '/home', label: 'Home (alias)' }, { url: '/blank', label: 'Blank template' }, // ── Browse / gallery ────────────────────────────────────────────────────── { url: '/browse', label: 'Browse' }, { url: '/browse-categories', label: 'Browse Categories' }, { url: '/categories', label: 'Categories' }, { url: '/sections', label: 'Sections' }, { url: '/featured', label: 'Featured artworks' }, { url: '/featured-artworks', label: 'Featured artworks (alias)' }, // ── Uploads ────────────────────────────────────────────────────────────── { url: '/uploads/latest', label: 'Latest uploads (new)' }, { url: '/uploads/daily', label: 'Daily uploads (new)' }, { url: '/daily-uploads', label: 'Daily uploads (legacy)' }, { url: '/latest', label: 'Latest (legacy)' }, // ── Community ──────────────────────────────────────────────────────────── { url: '/members/photos', label: 'Member photos (new)' }, { url: '/authors/top', label: 'Top authors (new)' }, { url: '/comments/latest', label: 'Latest comments (new)' }, { url: '/comments/monthly', label: 'Monthly commentators (new)' }, { url: '/downloads/today', label: 'Today downloads (new)' }, { url: '/top-authors', label: 'Top authors (legacy)' }, { url: '/top-favourites', label: 'Top favourites (legacy)' }, { url: '/today-downloads', label: 'Today downloads (legacy)' }, { url: '/today-in-history', label: 'Today in history' }, { url: '/monthly-commentators',label: 'Monthly commentators (legacy)' }, { url: '/latest-comments', label: 'Latest comments (legacy)' }, { url: '/interviews', label: 'Interviews' }, { url: '/chat', label: 'Chat' }, // ── Forum ──────────────────────────────────────────────────────────────── { url: '/forum', label: 'Forum index' }, // ── Content type roots ─────────────────────────────────────────────────── { url: '/photography', label: 'Photography root' }, { url: '/wallpapers', label: 'Wallpapers root' }, { url: '/skins', label: 'Skins root' }, // ── Auth pages (guest-only, publicly accessible) ───────────────────────── { url: '/login', label: 'Login page' }, { url: '/register', label: 'Register page' }, { url: '/forgot-password', label: 'Forgot password' }, ]; // Auth-guarded routes — unauthenticated visitors must land on /login const AUTH_ROUTES: RouteFixture[] = [ { url: '/dashboard', label: 'Dashboard', requiresAuth: true }, { url: '/dashboard/profile', label: 'Dashboard profile', requiresAuth: true }, { url: '/dashboard/artworks', label: 'Dashboard artworks', requiresAuth: true }, { url: '/dashboard/gallery', label: 'Dashboard gallery', requiresAuth: true }, { url: '/dashboard/favorites', label: 'Dashboard favorites', requiresAuth: true }, { url: '/upload', label: 'Upload page', requiresAuth: true }, { url: '/statistics', label: 'Statistics', requiresAuth: true }, { url: '/recieved-comments', label: 'Received comments', requiresAuth: true }, { url: '/mybuddies', label: 'My buddies', requiresAuth: true }, { url: '/buddies', label: 'Buddies', requiresAuth: true }, { url: '/manage', label: 'Manage', requiresAuth: true }, ]; // Routes that should 404 (to ensure 404 handling is clean and doesn't 500) const NOT_FOUND_ROUTES: RouteFixture[] = [ { url: '/this-page-does-not-exist-xyz-9999', label: '404 — unknown path' }, { url: '/art/999999999/no-such-artwork', label: '404 — unknown artwork' }, ]; // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── /** Console message origins we choose to tolerate (CSP reports, hot-reload, etc.) */ const IGNORED_CONSOLE_PATTERNS: RegExp[] = [ /\[vite\]/i, /\[HMR\]/i, /favicon\.ico/i, /Failed to load resource.*hot/i, /content security policy/i, /sourcemappingurl/i, // Missing image/asset files in dev environment (thumbnails not present locally) /Failed to load resource: the server responded with a status of 404/i, ]; /** * Text fragments whose presence in page HTML indicates Laravel rendered * an error page (debug mode or a production error view). */ const ERROR_PAGE_SIGNALS: string[] = [ 'Whoops!', 'Server Error', 'Symfony\\Component\\', 'ErrorException', 'QueryException', 'ParseError', ]; interface PageProbe { jsErrors: string[]; consoleErrors: string[]; } /** Wire up collectors for JS errors before navigating. */ function attachProbes(page: Page): PageProbe { const probe: PageProbe = { jsErrors: [], consoleErrors: [] }; page.on('pageerror', (err) => { probe.jsErrors.push(err.message); }); page.on('console', (msg) => { if (msg.type() !== 'error') return; const text = msg.text(); if (IGNORED_CONSOLE_PATTERNS.some((re) => re.test(text))) return; probe.consoleErrors.push(text); }); return probe; } /** Assert the probe found no problems. */ function expectCleanProbe(probe: PageProbe) { expect( probe.jsErrors, `Uncaught JS exceptions: ${probe.jsErrors.join(' | ')}` ).toHaveLength(0); expect( probe.consoleErrors, `Browser console errors: ${probe.consoleErrors.join(' | ')}` ).toHaveLength(0); } /** Check the rendered HTML for Laravel / server-side error signals. */ async function expectNoErrorPage(page: Page) { const html = await page.content(); for (const signal of ERROR_PAGE_SIGNALS) { expect( html, `Error page signal found in HTML: "${signal}"` ).not.toContain(signal); } } /** Check the page has a <title> and visible <body>. */ async function expectMeaningfulPage(page: Page) { const title = await page.title(); expect(title.trim(), 'Page <title> must not be empty').not.toBe(''); await expect(page.locator('body'), '<body> must be visible').toBeVisible(); } // ───────────────────────────────────────────────────────────────────────────── // Test suites // ───────────────────────────────────────────────────────────────────────────── // ── 1. Public routes ────────────────────────────────────────────────────────── test.describe('Public routes — 200, no errors', () => { for (const route of PUBLIC_ROUTES) { const testFn = route.skip ? test.skip : test; testFn(route.label, async ({ page }) => { const probe = attachProbes(page); const response = await page.goto(route.url, { waitUntil: 'domcontentloaded' }); // ── Status check ────────────────────────────────────────────────────── const status = response?.status() ?? 0; expect( status, `${route.url} returned HTTP ${status} — expected 200` ).toBe(200); // ── Optional URL assertion ───────────────────────────────────────────── if (route.expectUrlContains) { expect(page.url()).toContain(route.expectUrlContains); } // ── Page content checks ─────────────────────────────────────────────── await expectNoErrorPage(page); await expectMeaningfulPage(page); if (route.bodyContains) { await expect(page.locator('body')).toContainText(route.bodyContains); } // ── JS probe results ────────────────────────────────────────────────── expectCleanProbe(probe); }); } }); // ── 2. Auth-guarded routes ──────────────────────────────────────────────────── test.describe('Auth-guarded routes — redirect to /login cleanly', () => { for (const route of AUTH_ROUTES) { test(route.label, async ({ page }) => { const probe = attachProbes(page); // Follow redirects; we expect to land on the login page const response = await page.goto(route.url, { waitUntil: 'domcontentloaded' }); // Final page must not be an error page const status = response?.status() ?? 0; expect( status, `${route.url} auth redirect resulted in HTTP ${status} — expected 200 (login page)` ).toBe(200); // Must have redirected to login expect( page.url(), `${route.url} did not redirect to /login` ).toContain('/login'); await expectNoErrorPage(page); await expectMeaningfulPage(page); expectCleanProbe(probe); }); } }); // ── 3. 404 handling ─────────────────────────────────────────────────────────── test.describe('404 routes — clean error page, not a 500', () => { for (const route of NOT_FOUND_ROUTES) { test(route.label, async ({ page }) => { const probe = attachProbes(page); const response = await page.goto(route.url, { waitUntil: 'domcontentloaded' }); const status = response?.status() ?? 0; // Must be 404 — never 500 expect( status, `${route.url} returned HTTP ${status} — expected 404, not 500` ).toBe(404); // 404 pages are fine, but they must not be a 500 crash const html = await page.content(); const crashSignals = [ 'Whoops!', 'Symfony\\Component\\', 'ErrorException', 'QueryException', ]; for (const signal of crashSignals) { expect( html, `500-level signal "${signal}" found on a ${status} page` ).not.toContain(signal); } expectCleanProbe(probe); }); } }); // ── 4. Spot-check: critical pages render expected landmarks ────────────────── test.describe('Landmark spot-checks', () => { test('Home page — has gallery section', async ({ page }) => { const probe = attachProbes(page); await page.goto('/', { waitUntil: 'domcontentloaded' }); await expect(page.locator('[data-nova-gallery], .gallery-grid, .container_photo')).toBeVisible(); expectCleanProbe(probe); }); test('Sections page — renders content-type section headings', async ({ page }) => { const probe = attachProbes(page); await page.goto('/sections', { waitUntil: 'domcontentloaded' }); // Should have at least one section anchor const sections = page.locator('[id^="section-"]'); await expect(sections.first()).toBeVisible(); expectCleanProbe(probe); }); test('Browse-categories page — renders category links', async ({ page }) => { const probe = attachProbes(page); await page.goto('/browse-categories', { waitUntil: 'domcontentloaded' }); await expect(page.locator('a[href]').first()).toBeVisible(); expectCleanProbe(probe); }); test('Forum — renders forum index', async ({ page }) => { const probe = attachProbes(page); await page.goto('/forum', { waitUntil: 'domcontentloaded' }); // Forum has either category rows or a "no threads" message await expect(page.locator('body')).toBeVisible(); await expectNoErrorPage(page); expectCleanProbe(probe); }); test('Authors top — renders leaderboard', async ({ page }) => { const probe = attachProbes(page); await page.goto('/authors/top', { waitUntil: 'domcontentloaded' }); await expectNoErrorPage(page); await expectMeaningfulPage(page); expectCleanProbe(probe); }); test('Daily uploads — date strip renders', async ({ page }) => { const probe = attachProbes(page); await page.goto('/uploads/daily', { waitUntil: 'domcontentloaded' }); await expectNoErrorPage(page); // Date strip should have multiple buttons const strip = page.locator('#dateStrip button'); await expect(strip.first()).toBeVisible(); const count = await strip.count(); expect(count, 'Daily uploads date strip should have 15 tabs').toBe(15); expectCleanProbe(probe); }); test('Daily uploads — AJAX endpoint returns HTML fragment', async ({ page }) => { const probe = attachProbes(page); const today = new Date().toISOString().split('T')[0]; const response = await page.goto(`/uploads/daily?ajax=1&datum=${today}`, { waitUntil: 'domcontentloaded', }); expect( response?.status(), 'Daily uploads AJAX endpoint should return 200' ).toBe(200); // Response should not be an error page const html = await page.content(); expect(html).not.toContain('Whoops!'); expect(html).not.toContain('Server Error'); expectCleanProbe(probe); }); test('Login page loads and has form', async ({ page }) => { const probe = attachProbes(page); await page.goto('/login', { waitUntil: 'domcontentloaded' }); await expect(page.locator('form[method="POST"]')).toBeVisible(); await expect(page.locator('input[type="email"], input[name="email"]')).toBeVisible(); await expect(page.locator('input[type="password"]')).toBeVisible(); expectCleanProbe(probe); }); test('Register page loads and has form', async ({ page }) => { const probe = attachProbes(page); await page.goto('/register', { waitUntil: 'domcontentloaded' }); await expect(page.locator('form[method="POST"]')).toBeVisible(); expectCleanProbe(probe); }); test('Photography root loads', async ({ page }) => { const probe = attachProbes(page); await page.goto('/photography', { waitUntil: 'domcontentloaded' }); await expectNoErrorPage(page); await expectMeaningfulPage(page); expectCleanProbe(probe); }); test('Wallpapers root loads', async ({ page }) => { const probe = attachProbes(page); await page.goto('/wallpapers', { waitUntil: 'domcontentloaded' }); await expectNoErrorPage(page); await expectMeaningfulPage(page); expectCleanProbe(probe); }); }); // ── 5. Navigation performance — no route should hang ───────────────────────── test.describe('Response time — no page should take over 8 s', () => { const SLOW_THRESHOLD_MS = 8000; const PERF_ROUTES = [ '/', '/uploads/latest', '/comments/latest', '/authors/top', '/sections', '/forum', '/browse-categories', ]; for (const url of PERF_ROUTES) { test(`${url} responds within ${SLOW_THRESHOLD_MS}ms`, async ({ page }) => { const start = Date.now(); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: SLOW_THRESHOLD_MS + 2000 }); const elapsed = Date.now() - start; expect( elapsed, `${url} took ${elapsed}ms — over the ${SLOW_THRESHOLD_MS}ms threshold` ).toBeLessThan(SLOW_THRESHOLD_MS); }); } });