/** * Website SSR Smoke Tests * * Run with: npx vitest run tests/smoke/website-ssr.test.ts --config vitest.smoke.config.ts */ import { describe, test, expect, beforeAll, afterAll } from 'vitest'; import { getWebsiteRouteContracts, RouteContract } from '../shared/website/RouteContractSpec'; import { WebsiteServerHarness } from '../integration/harness/WebsiteServerHarness'; import { ApiServerHarness } from '../integration/harness/ApiServerHarness'; const WEBSITE_BASE_URL = process.env.WEBSITE_BASE_URL || 'http://localhost:3000'; const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3001'; describe('Website SSR Contract Suite', () => { const contracts = getWebsiteRouteContracts(); let websiteHarness: WebsiteServerHarness | null = null; let apiHarness: ApiServerHarness | null = null; let errorCount500 = 0; beforeAll(async () => { // 1. Ensure API is running if (API_BASE_URL.includes('localhost')) { try { await fetch(`${API_BASE_URL}/health`); console.log(`API already running at ${API_BASE_URL}`); } catch (e) { console.log(`Starting API server harness on ${API_BASE_URL}...`); apiHarness = new ApiServerHarness({ port: parseInt(new URL(API_BASE_URL).port) || 3001, }); await apiHarness.start(); } } // 2. Ensure Website is running if (WEBSITE_BASE_URL.includes('localhost')) { try { await fetch(WEBSITE_BASE_URL, { method: 'HEAD' }); console.log(`Website server already running at ${WEBSITE_BASE_URL}`); } catch (e) { console.log(`Starting website server harness on ${WEBSITE_BASE_URL}...`); websiteHarness = new WebsiteServerHarness({ port: parseInt(new URL(WEBSITE_BASE_URL).port) || 3000, }); await websiteHarness.start(); } } }, 120000); afterAll(async () => { if (websiteHarness) { await websiteHarness.stop(); } if (apiHarness) { await apiHarness.stop(); } // Fail suite on bursts of 500s (e.g. > 3) if (errorCount500 > 3) { throw new Error(`Suite failed due to high error rate: ${errorCount500} routes returned 500`); } // Fail on uncaught exceptions in logs if (websiteHarness?.hasErrorPatterns()) { console.error('Server logs contained error patterns:\n' + websiteHarness.getLogTail(50)); throw new Error('Suite failed due to error patterns in server logs'); } }); test.each(contracts)('Contract: $path', async (contract: RouteContract) => { const url = `${WEBSITE_BASE_URL}${contract.path}`; let response: Response; try { response = await fetch(url, { redirect: 'manual' }); } catch (e) { const logTail = websiteHarness ? `\nServer Log Tail:\n${websiteHarness.getLogTail(20)}` : ''; throw new Error(`Failed to fetch ${url}: ${e.message}${logTail}`); } const status = response.status; const text = await response.text(); const location = response.headers.get('location'); if (status === 500 && contract.expectedStatus !== 'errorRoute') { errorCount500++; } const failureContext = ` Route: ${contract.path} Status: ${status} Location: ${location || 'N/A'} HTML (clipped): ${text.slice(0, 500)}... ${websiteHarness ? `\nServer Log Tail:\n${websiteHarness.getLogTail(10)}` : ''} `.trim(); // 1. Status class matches expectedStatus if (contract.expectedStatus === 'ok') { expect(status, failureContext).toBe(200); } else if (contract.expectedStatus === 'redirect') { expect(status, failureContext).toBeGreaterThanOrEqual(300); expect(status, failureContext).toBeLessThan(400); } else if (contract.expectedStatus === 'notFoundAllowed') { expect([200, 404], failureContext).toContain(status); } else if (contract.expectedStatus === 'errorRoute') { expect([200, 404, 500], failureContext).toContain(status); } // 2. Redirect location semantics if (contract.expectedStatus === 'redirect' && contract.expectedRedirectTo) { expect(location, failureContext).toContain(contract.expectedRedirectTo); if (contract.accessLevel !== 'public' && contract.expectedRedirectTo.includes('/auth/login')) { expect(location, failureContext).toContain('returnTo='); } } // 3. HTML sanity checks if (status === 200 || (status === 404 && contract.expectedStatus === 'notFoundAllowed')) { if (contract.ssrMustContain) { for (const pattern of contract.ssrMustContain) { expect(text, failureContext).toMatch(pattern); } } if (contract.ssrMustNotContain) { for (const pattern of contract.ssrMustNotContain) { expect(text, failureContext).not.toMatch(pattern); } } if (contract.minTextLength) { expect(text.length, failureContext).toBeGreaterThanOrEqual(contract.minTextLength); } } }); });