website refactor
This commit is contained in:
@@ -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 = [];
|
||||
}
|
||||
|
||||
52
tests/shared/website/FeatureFlagHelpers.ts
Normal file
52
tests/shared/website/FeatureFlagHelpers.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
57
tests/shared/website/HttpDiagnostics.ts
Normal file
57
tests/shared/website/HttpDiagnostics.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
94
tests/shared/website/RouteContractSpec.ts
Normal file
94
tests/shared/website/RouteContractSpec.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user