website refactor

This commit is contained in:
2026-01-17 18:28:10 +01:00
parent 6d57f8b1ce
commit 64d9e7fd16
44 changed files with 1729 additions and 415 deletions

View File

@@ -10,11 +10,22 @@ export interface CapturedError {
export class ConsoleErrorCapture {
private errors: CapturedError[] = [];
private allowlist: (string | RegExp)[] = [];
constructor(private page: Page) {
this.setupCapture();
}
public setAllowlist(patterns: (string | RegExp)[]): void {
this.allowlist = patterns;
}
private isAllowed(message: string): boolean {
return this.allowlist.some(pattern =>
typeof pattern === 'string' ? message.includes(pattern) : pattern.test(message)
);
}
private setupCapture(): void {
this.page.on('console', (msg) => {
if (msg.type() === 'error') {
@@ -40,10 +51,44 @@ export class ConsoleErrorCapture {
return this.errors;
}
public getUnexpectedErrors(): CapturedError[] {
return this.errors.filter(e => !this.isAllowed(e.message));
}
public format(): string {
if (this.errors.length === 0) return 'No console errors captured.';
const unexpected = this.getUnexpectedErrors();
const allowed = this.errors.filter(e => this.isAllowed(e.message));
let output = '--- Console Error Capture ---\n';
if (unexpected.length > 0) {
output += `UNEXPECTED ERRORS (${unexpected.length}):\n`;
unexpected.forEach((e, i) => {
output += `[${i + 1}] ${e.type.toUpperCase()}: ${e.message}\n`;
if (e.stack) output += `Stack: ${e.stack}\n`;
});
}
if (allowed.length > 0) {
output += `\nALLOWED ERRORS (${allowed.length}):\n`;
allowed.forEach((e, i) => {
output += `[${i + 1}] ${e.type.toUpperCase()}: ${e.message}\n`;
});
}
return output;
}
public hasErrors(): boolean {
return this.errors.length > 0;
}
public hasUnexpectedErrors(): boolean {
return this.getUnexpectedErrors().length > 0;
}
public clear(): void {
this.errors = [];
}

View File

@@ -0,0 +1,52 @@
/**
* Feature flag helper functions for testing
*/
export interface FeatureFlagData {
features: Record<string, string>;
timestamp: string;
}
/**
* Helper to compute enabled flags from feature config
*/
export function getEnabledFlags(featureData: FeatureFlagData): string[] {
if (!featureData.features || typeof featureData.features !== 'object') {
return [];
}
return Object.entries(featureData.features)
.filter(([, value]) => value === 'enabled')
.map(([flag]) => flag);
}
/**
* Helper to check if a specific flag is enabled
*/
export function isFeatureEnabled(featureData: FeatureFlagData, flag: string): boolean {
return featureData.features?.[flag] === 'enabled';
}
/**
* Helper to fetch feature flags from the API
* Note: This is a pure function that takes the fetcher as an argument to avoid network side effects in unit tests
*/
export async function fetchFeatureFlags(
fetcher: (url: string) => Promise<{ ok: boolean; json: () => Promise<unknown>; status: number }>,
apiBaseUrl: string
): Promise<FeatureFlagData> {
const featuresUrl = `${apiBaseUrl}/features`;
try {
const response = await fetcher(featuresUrl);
if (!response.ok) {
throw new Error(`Failed to fetch feature flags: ${response.status}`);
}
const data = await response.json() as FeatureFlagData;
return data;
} catch (error) {
console.error(`[FEATURE FLAGS] Failed to fetch from ${featuresUrl}:`, error);
throw error;
}
}

View File

@@ -0,0 +1,57 @@
export interface HttpFailureContext {
role?: string;
url: string;
status: number;
location?: string | null;
html?: string;
extra?: string;
serverLogs?: string;
}
export class HttpDiagnostics {
static clipString(str: string, max = 1200): string {
if (str.length <= max) return str;
return str.substring(0, max) + `... [clipped ${str.length - max} chars]`;
}
static formatHttpFailure({ role, url, status, location, html, extra, serverLogs }: HttpFailureContext): string {
const lines = [
`HTTP Failure: ${status} for ${url}`,
role ? `Role: ${role}` : null,
location ? `Location: ${location}` : null,
extra ? `Extra: ${extra}` : null,
html ? `HTML Body (clipped):\n${this.clipString(html)}` : 'No HTML body provided',
serverLogs ? `\n--- Server Log Tail ---\n${serverLogs}` : null,
].filter(Boolean);
return lines.join('\n');
}
static assertHtmlContains(html: string, mustContain: string | string[], context: HttpFailureContext): void {
const targets = Array.isArray(mustContain) ? mustContain : [mustContain];
for (const target of targets) {
if (!html.includes(target)) {
const message = this.formatHttpFailure({
...context,
extra: `Expected HTML to contain: "${target}"`,
html,
});
throw new Error(message);
}
}
}
static assertHtmlNotContains(html: string, mustNotContain: string | string[], context: HttpFailureContext): void {
const targets = Array.isArray(mustNotContain) ? mustNotContain : [mustNotContain];
for (const target of targets) {
if (html.includes(target)) {
const message = this.formatHttpFailure({
...context,
extra: `Expected HTML NOT to contain: "${target}"`,
html,
});
throw new Error(message);
}
}
}
}

View File

@@ -0,0 +1,94 @@
import { WebsiteRouteManager, RouteAccess } from './WebsiteRouteManager';
import { routes } from '../../../apps/website/lib/routing/RouteConfig';
/**
* Expected HTTP status or behavior for a route.
* - 'ok': 200 OK
* - 'redirect': 3xx redirect (usually to login)
* - 'notFoundAllowed': 404 is an acceptable/expected outcome (e.g. for edge cases)
* - 'errorRoute': The dedicated error pages themselves
*/
export type ExpectedStatus = 'ok' | 'redirect' | 'notFoundAllowed' | 'errorRoute';
/**
* RouteContract defines the "Single Source of Truth" for how a website route
* should behave during SSR and E2E testing.
*/
export interface RouteContract {
/** The fully resolved path (e.g. /leagues/123 instead of /leagues/[id]) */
path: string;
/** The required access level for this route */
accessLevel: RouteAccess;
/** What we expect when hitting this route unauthenticated */
expectedStatus: ExpectedStatus;
/** If expectedStatus is 'redirect', where should it go? (pathname only) */
expectedRedirectTo?: string;
/** Strings or Regex that MUST be present in the SSR HTML */
ssrMustContain?: Array<string | RegExp>;
/** Strings or Regex that MUST NOT be present in the SSR HTML (e.g. error markers) */
ssrMustNotContain?: Array<string | RegExp>;
/** Minimum expected length of the HTML response body */
minTextLength?: number;
}
const DEFAULT_SSR_MUST_CONTAIN = ['<!DOCTYPE html>', '<body'];
const DEFAULT_SSR_MUST_NOT_CONTAIN = [
'__NEXT_ERROR__',
'Application error: a client-side exception has occurred',
];
/**
* Generates the full list of route contracts by augmenting the base inventory
* with expected behaviors and sanity checks.
*/
export function getWebsiteRouteContracts(): RouteContract[] {
const manager = new WebsiteRouteManager();
const inventory = manager.getWebsiteRouteInventory();
// Per-route overrides for special cases where the group-based logic isn't enough
const overrides: Record<string, Partial<RouteContract>> = {
[routes.error.notFound]: {
expectedStatus: 'notFoundAllowed',
},
[routes.error.serverError]: {
expectedStatus: 'errorRoute',
},
};
return inventory.map((def) => {
const path = manager.resolvePathTemplate(def.pathTemplate, def.params);
// Default augmentation based on access level
let expectedStatus: ExpectedStatus = 'ok';
let expectedRedirectTo: string | undefined = undefined;
if (def.access !== 'public') {
expectedStatus = 'redirect';
// Most protected routes redirect to login when unauthenticated
expectedRedirectTo = routes.auth.login;
}
// If the inventory explicitly allows 404 (e.g. for non-existent IDs in edge cases)
if (def.allowNotFound) {
expectedStatus = 'notFoundAllowed';
}
const contract: RouteContract = {
path,
accessLevel: def.access,
expectedStatus,
expectedRedirectTo,
ssrMustContain: [...DEFAULT_SSR_MUST_CONTAIN],
ssrMustNotContain: [...DEFAULT_SSR_MUST_NOT_CONTAIN],
minTextLength: 1000, // Reasonable minimum for a Next.js page
};
// Apply per-route overrides (matching by template or resolved path)
const override = overrides[def.pathTemplate] || overrides[path];
if (override) {
Object.assign(contract, override);
}
return contract;
});
}

View File

@@ -25,7 +25,6 @@ export class WebsiteAuthManager {
const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:3101';
const role = (typeof requestOrRole === 'string' ? requestOrRole : maybeRole) as AuthRole;
const request = typeof requestOrRole === 'string' ? null : requestOrRole;
// If using API login, create context with cookies pre-set
if (typeof requestOrRole !== 'string') {

View File

@@ -94,9 +94,13 @@ export class WebsiteRouteManager {
public getAccessLevel(pathTemplate: string): RouteAccess {
// NOTE: `routeMatchers.isInGroup(path, 'public')` is prefix-based and will treat everything
// as public because the home route is `/`. Use `isPublic()` for correct classification.
// Check public first to ensure public routes nested under protected prefixes (e.g. /sponsor/signup)
// are correctly classified as public.
if (routeMatchers.isPublic(pathTemplate)) return 'public';
if (routeMatchers.isInGroup(pathTemplate, 'admin')) return 'admin';
if (routeMatchers.isInGroup(pathTemplate, 'sponsor')) return 'sponsor';
if (routeMatchers.isPublic(pathTemplate)) return 'public';
if (routeMatchers.requiresAuth(pathTemplate)) return 'auth';
return 'public';
}