tests cleanup
This commit is contained in:
@@ -1,391 +0,0 @@
|
||||
import { test, expect, type Page, type BrowserContext } from '@playwright/test';
|
||||
import {
|
||||
authContextForAccess,
|
||||
attachConsoleErrorCapture,
|
||||
setWebsiteAuthContext,
|
||||
type WebsiteAuthContext,
|
||||
type WebsiteFaultMode,
|
||||
type WebsiteSessionDriftMode,
|
||||
} from './websiteAuth';
|
||||
import {
|
||||
getWebsiteAuthDriftRoutes,
|
||||
getWebsiteFaultInjectionRoutes,
|
||||
getWebsiteParamEdgeCases,
|
||||
getWebsiteRouteInventory,
|
||||
resolvePathTemplate,
|
||||
type WebsiteRouteDefinition,
|
||||
} from './websiteRouteInventory';
|
||||
|
||||
type SmokeScenario = {
|
||||
scenarioName: string;
|
||||
auth: WebsiteAuthContext;
|
||||
expectAuthRedirect: boolean;
|
||||
};
|
||||
|
||||
type AuthOptions = {
|
||||
sessionDrift?: WebsiteSessionDriftMode;
|
||||
faultMode?: WebsiteFaultMode;
|
||||
};
|
||||
|
||||
function toRegexEscaped(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function urlToKey(rawUrl: string): string {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
return `${parsed.origin}${parsed.pathname}${parsed.search}`;
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
async function runWebsiteSmokeScenario(args: {
|
||||
page: Page;
|
||||
context: BrowserContext;
|
||||
route: WebsiteRouteDefinition;
|
||||
scenario: SmokeScenario;
|
||||
resolvedPath: string;
|
||||
expectedPath: string;
|
||||
authOptions?: AuthOptions;
|
||||
}): Promise<void> {
|
||||
const { page, context, route, scenario, resolvedPath, expectedPath, authOptions = {} } = args;
|
||||
|
||||
await setWebsiteAuthContext(context, scenario.auth, authOptions);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const anyEvent = event;
|
||||
const reason = anyEvent && typeof anyEvent === 'object' && 'reason' in anyEvent ? anyEvent.reason : undefined;
|
||||
// Forward to console so smoke harness can treat as a runtime failure.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[unhandledrejection] ${String(reason)}`);
|
||||
});
|
||||
});
|
||||
|
||||
const capture = attachConsoleErrorCapture(page);
|
||||
|
||||
const navigationHistory: string[] = [];
|
||||
let redirectLoopError: string | null = null;
|
||||
|
||||
const recordNavigation = (rawUrl: string) => {
|
||||
if (redirectLoopError) return;
|
||||
|
||||
navigationHistory.push(urlToKey(rawUrl));
|
||||
|
||||
const tail = navigationHistory.slice(-8);
|
||||
if (tail.length < 8) return;
|
||||
|
||||
const isAlternating = (items: string[]) => {
|
||||
if (items.length < 6) return false;
|
||||
const a = items[0];
|
||||
const b = items[1];
|
||||
if (!a || !b || a === b) return false;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i] !== (i % 2 === 0 ? a : b)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if (isAlternating(tail) || isAlternating(tail.slice(1))) {
|
||||
const unique = Array.from(new Set(tail));
|
||||
if (unique.length >= 2) {
|
||||
redirectLoopError = `Redirect loop detected while loading ${resolvedPath} (auth=${scenario.auth}). Navigation tail:\n${tail
|
||||
.map((u) => `- ${u}`)
|
||||
.join('\n')}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (navigationHistory.length > 12) {
|
||||
redirectLoopError = `Excessive navigation count while loading ${resolvedPath} (auth=${scenario.auth}). Count=${navigationHistory.length}\nRecent navigations:\n${navigationHistory
|
||||
.slice(-12)
|
||||
.map((u) => `- ${u}`)
|
||||
.join('\n')}`;
|
||||
}
|
||||
};
|
||||
|
||||
page.on('framenavigated', (frame) => {
|
||||
if (frame.parentFrame()) return;
|
||||
recordNavigation(frame.url());
|
||||
});
|
||||
|
||||
const requestFailures: Array<{
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
errorText: string;
|
||||
}> = [];
|
||||
const responseFailures: Array<{ url: string; status: number }> = [];
|
||||
const jsonParseFailures: Array<{ url: string; status: number; error: string }> = [];
|
||||
const responseChecks: Array<Promise<void>> = [];
|
||||
|
||||
page.on('requestfailed', (req) => {
|
||||
const failure = req.failure();
|
||||
const errorText = failure?.errorText ?? 'unknown';
|
||||
|
||||
// Ignore expected aborts during navigation/redirects (Next.js will abort in-flight requests).
|
||||
if (errorText.includes('net::ERR_ABORTED') || errorText.includes('NS_BINDING_ABORTED')) {
|
||||
const resourceType = req.resourceType();
|
||||
const url = req.url();
|
||||
|
||||
if (resourceType === 'document' || resourceType === 'media') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Next.js RSC/data fetches are frequently aborted during redirects.
|
||||
if (resourceType === 'fetch' && url.includes('_rsc=')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore fetch requests to the expected redirect target during page redirects
|
||||
// This handles cases like /sponsor -> /sponsor/dashboard where the redirect
|
||||
// causes an aborted fetch request to the target URL
|
||||
if (resourceType === 'fetch' && route.expectedPathTemplate) {
|
||||
const expectedPath = resolvePathTemplate(route.expectedPathTemplate, route.params);
|
||||
const urlObj = new URL(url);
|
||||
if (urlObj.pathname === expectedPath) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestFailures.push({
|
||||
url: req.url(),
|
||||
method: req.method(),
|
||||
resourceType: req.resourceType(),
|
||||
errorText,
|
||||
});
|
||||
});
|
||||
|
||||
page.on('response', (resp) => {
|
||||
const status = resp.status();
|
||||
const url = resp.url();
|
||||
const resourceType = resp.request().resourceType();
|
||||
|
||||
const isApiUrl = (() => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.pathname.startsWith('/api/')) return true;
|
||||
if (parsed.hostname === 'localhost' && (parsed.port === '3101' || parsed.port === '3000')) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
// Guardrail: for successful JSON API responses, ensure the body is valid JSON.
|
||||
// Keep this generic: only api-ish URLs, only fetch/xhr, only 2xx, only application/json.
|
||||
if (isApiUrl && status >= 200 && status < 300 && (resourceType === 'fetch' || resourceType === 'xhr')) {
|
||||
const headers = resp.headers();
|
||||
const contentType = headers['content-type'] ?? '';
|
||||
const contentLength = headers['content-length'];
|
||||
|
||||
if (contentType.includes('application/json') && status !== 204 && contentLength !== '0') {
|
||||
responseChecks.push(
|
||||
resp
|
||||
.json()
|
||||
.then(() => undefined)
|
||||
.catch((err) => {
|
||||
jsonParseFailures.push({ url, status, error: String(err) });
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (status < 400) return;
|
||||
|
||||
// Param edge-cases are allowed to return 404 as the primary document.
|
||||
if (route.allowNotFound && resourceType === 'document' && status === 404) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Intentional error routes: allow the main document to be 404/500.
|
||||
if (resourceType === 'document' && resolvedPath === '/404' && status === 404 && /\/404\/?$/.test(url)) {
|
||||
return;
|
||||
}
|
||||
if (resourceType === 'document' && resolvedPath === '/500' && status === 500 && /\/500\/?$/.test(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
responseFailures.push({ url, status });
|
||||
});
|
||||
|
||||
const navResponse = await page.goto(resolvedPath, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
await expect(page).toHaveTitle(/GridPilot/i);
|
||||
|
||||
const currentUrl = new URL(page.url());
|
||||
const finalPathname = currentUrl.pathname;
|
||||
|
||||
if (scenario.expectAuthRedirect) {
|
||||
// Some routes enforce client-side auth redirects; others may render a safe "public" state in alpha/demo mode.
|
||||
// Keep this minimal: either we land on an auth entry route, OR the navigation succeeded with a 200.
|
||||
if (/^\/auth\/(login|iracing)\/?$/.test(finalPathname)) {
|
||||
// ok
|
||||
} else {
|
||||
expect(
|
||||
navResponse?.status(),
|
||||
`Expected protected route ${resolvedPath} to redirect to auth or return 200 when public; ended at ${finalPathname}`,
|
||||
).toBe(200);
|
||||
}
|
||||
} else if (route.allowNotFound) {
|
||||
if (finalPathname === '/404') {
|
||||
// ok
|
||||
} else {
|
||||
await expect(page).toHaveURL(new RegExp(`${toRegexEscaped(expectedPath)}(\\?.*)?$`));
|
||||
}
|
||||
} else {
|
||||
await expect(page).toHaveURL(new RegExp(`${toRegexEscaped(expectedPath)}(\\?.*)?$`));
|
||||
}
|
||||
|
||||
// Give the app a moment to surface any late runtime errors after initial render.
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
await Promise.all(responseChecks);
|
||||
|
||||
if (redirectLoopError) {
|
||||
throw new Error(redirectLoopError);
|
||||
}
|
||||
|
||||
expect(
|
||||
jsonParseFailures.length,
|
||||
`Invalid JSON responses on route ${resolvedPath} (auth=${scenario.auth}):\n${jsonParseFailures
|
||||
.map((r) => `- ${r.status} ${r.url}: ${r.error}`)
|
||||
.join('\n')}`,
|
||||
).toBe(0);
|
||||
|
||||
expect(
|
||||
requestFailures.length,
|
||||
`Request failures on route ${resolvedPath} (auth=${scenario.auth}):\n${requestFailures
|
||||
.map((r) => `- ${r.method} ${r.resourceType} ${r.url} (${r.errorText})`)
|
||||
.join('\n')}`,
|
||||
).toBe(0);
|
||||
|
||||
expect(
|
||||
responseFailures.length,
|
||||
`HTTP failures on route ${resolvedPath} (auth=${scenario.auth}):\n${responseFailures.map((r) => `- ${r.status} ${r.url}`).join('\n')}`,
|
||||
).toBe(0);
|
||||
|
||||
expect(
|
||||
capture.pageErrors.length,
|
||||
`Page errors on route ${resolvedPath} (auth=${scenario.auth}):\n${capture.pageErrors.join('\n')}`,
|
||||
).toBe(0);
|
||||
|
||||
const treatAsErrorRoute =
|
||||
resolvedPath === '/404' || resolvedPath === '/500' || (route.allowNotFound && finalPathname === '/404') || navResponse?.status() === 404;
|
||||
|
||||
const consoleErrors = treatAsErrorRoute
|
||||
? capture.consoleErrors.filter((msg) => {
|
||||
if (msg.includes('Failed to load resource: the server responded with a status of 404 (Not Found)')) return false;
|
||||
if (msg.includes('Failed to load resource: the server responded with a status of 500 (Internal Server Error)')) return false;
|
||||
if (msg.includes('the server responded with a status of 500')) return false;
|
||||
return true;
|
||||
})
|
||||
: capture.consoleErrors;
|
||||
|
||||
expect(
|
||||
consoleErrors.length,
|
||||
`Console errors on route ${resolvedPath} (auth=${scenario.auth}):\n${consoleErrors.join('\n')}`,
|
||||
).toBe(0);
|
||||
|
||||
// Verify images with /media/* paths are shown correctly
|
||||
const mediaImages = await page.locator('img[src*="/media/"]').all();
|
||||
|
||||
for (const img of mediaImages) {
|
||||
const src = await img.getAttribute('src');
|
||||
const alt = await img.getAttribute('alt');
|
||||
const isVisible = await img.isVisible();
|
||||
|
||||
// Check that src starts with /media/
|
||||
expect(src, `Image src should start with /media/ on route ${resolvedPath}`).toMatch(/^\/media\//);
|
||||
|
||||
// Check that alt text exists (for accessibility)
|
||||
expect(alt, `Image should have alt text on route ${resolvedPath}`).toBeTruthy();
|
||||
|
||||
// Check that image is visible
|
||||
expect(isVisible, `Image with src="${src}" should be visible on route ${resolvedPath}`).toBe(true);
|
||||
|
||||
// Note: Skipping naturalWidth/naturalHeight check for now due to Next.js Image component issues in test environment
|
||||
// The image URLs are correct and the proxy is working, but Next.js Image optimization may be interfering
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Website smoke - all pages render', () => {
|
||||
const routes = getWebsiteRouteInventory();
|
||||
|
||||
for (const route of routes) {
|
||||
const resolvedPath = resolvePathTemplate(route.pathTemplate, route.params);
|
||||
const expectedPath = resolvePathTemplate(route.expectedPathTemplate ?? route.pathTemplate, route.params);
|
||||
const intendedAuth = authContextForAccess(route.access);
|
||||
|
||||
const scenarios: SmokeScenario[] = [{ scenarioName: 'intended', auth: intendedAuth, expectAuthRedirect: false }];
|
||||
|
||||
if (route.access !== 'public') {
|
||||
scenarios.push({ scenarioName: 'public-redirect', auth: 'public', expectAuthRedirect: true });
|
||||
}
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(`${scenario.auth} (${scenario.scenarioName}) renders ${route.pathTemplate} -> ${resolvedPath}`, async ({ page, context }) => {
|
||||
await runWebsiteSmokeScenario({ page, context, route, scenario, resolvedPath, expectedPath });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Website smoke - param edge cases', () => {
|
||||
const edgeRoutes = getWebsiteParamEdgeCases();
|
||||
|
||||
for (const route of edgeRoutes) {
|
||||
const resolvedPath = resolvePathTemplate(route.pathTemplate, route.params);
|
||||
const expectedPath = resolvePathTemplate(route.expectedPathTemplate ?? route.pathTemplate, route.params);
|
||||
|
||||
const scenario: SmokeScenario = { scenarioName: 'invalid-param', auth: 'public', expectAuthRedirect: false };
|
||||
|
||||
test(`${scenario.auth} (${scenario.scenarioName}) renders ${route.pathTemplate} -> ${resolvedPath}`, async ({ page, context }) => {
|
||||
await runWebsiteSmokeScenario({ page, context, route, scenario, resolvedPath, expectedPath });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Website smoke - auth state drift', () => {
|
||||
const driftRoutes = getWebsiteAuthDriftRoutes();
|
||||
|
||||
const driftModes: WebsiteSessionDriftMode[] = ['invalid-cookie', 'expired', 'missing-sponsor-id'];
|
||||
|
||||
for (const route of driftRoutes) {
|
||||
const resolvedPath = resolvePathTemplate(route.pathTemplate, route.params);
|
||||
const expectedPath = resolvePathTemplate(route.expectedPathTemplate ?? route.pathTemplate, route.params);
|
||||
|
||||
for (const sessionDrift of driftModes) {
|
||||
const scenario: SmokeScenario = { scenarioName: `drift:${sessionDrift}`, auth: 'sponsor', expectAuthRedirect: true };
|
||||
|
||||
test(`${scenario.auth} (${scenario.scenarioName}) renders ${route.pathTemplate} -> ${resolvedPath}`, async ({ page, context }) => {
|
||||
await runWebsiteSmokeScenario({ page, context, route, scenario, resolvedPath, expectedPath, authOptions: { sessionDrift } });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Website smoke - mock fault injection (curated subset)', () => {
|
||||
const faultRoutes = getWebsiteFaultInjectionRoutes();
|
||||
|
||||
const faultModes: WebsiteFaultMode[] = ['null-array', 'missing-field', 'invalid-date'];
|
||||
|
||||
for (const route of faultRoutes) {
|
||||
const resolvedPath = resolvePathTemplate(route.pathTemplate, route.params);
|
||||
const expectedPath = resolvePathTemplate(route.expectedPathTemplate ?? route.pathTemplate, route.params);
|
||||
|
||||
for (const faultMode of faultModes) {
|
||||
const scenario: SmokeScenario = {
|
||||
scenarioName: `fault:${faultMode}`,
|
||||
auth: authContextForAccess(route.access),
|
||||
expectAuthRedirect: false,
|
||||
};
|
||||
|
||||
test(`${scenario.auth} (${scenario.scenarioName}) renders ${route.pathTemplate} -> ${resolvedPath}`, async ({ page, context }) => {
|
||||
await runWebsiteSmokeScenario({ page, context, route, scenario, resolvedPath, expectedPath, authOptions: { faultMode } });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import type { Page, BrowserContext } from '@playwright/test';
|
||||
import type { RouteAccess } from './websiteRouteInventory';
|
||||
|
||||
export type WebsiteAuthContext = 'public' | 'auth' | 'admin' | 'sponsor';
|
||||
|
||||
export type WebsiteSessionDriftMode = 'invalid-cookie' | 'expired' | 'missing-sponsor-id';
|
||||
export type WebsiteFaultMode = 'null-array' | 'missing-field' | 'invalid-date';
|
||||
|
||||
export function authContextForAccess(access: RouteAccess): WebsiteAuthContext {
|
||||
if (access === 'public') return 'public';
|
||||
if (access === 'auth') return 'auth';
|
||||
if (access === 'admin') return 'admin';
|
||||
return 'sponsor';
|
||||
}
|
||||
|
||||
export async function setWebsiteAuthContext(
|
||||
context: BrowserContext,
|
||||
auth: WebsiteAuthContext,
|
||||
options: { sessionDrift?: WebsiteSessionDriftMode; faultMode?: WebsiteFaultMode } = {},
|
||||
): Promise<void> {
|
||||
const domain = 'localhost';
|
||||
const base = { domain, path: '/' };
|
||||
|
||||
// The website uses `gp_session` cookie for authentication
|
||||
// For smoke tests, we use normal login API with seeded demo user credentials
|
||||
// to get real session cookies
|
||||
|
||||
if (auth === 'public') {
|
||||
// No authentication needed
|
||||
await context.clearCookies();
|
||||
return;
|
||||
}
|
||||
|
||||
// For authenticated contexts, we need to perform a normal login
|
||||
// This ensures we get real session cookies with proper structure
|
||||
// Note: All auth contexts use the same seeded demo driver user for simplicity
|
||||
// Role-based access control is tested separately in integration tests
|
||||
|
||||
// Call the normal login API with seeded demo user credentials
|
||||
// Use demo.driver@example.com for all auth contexts (driver role)
|
||||
const response = await fetch('http://localhost:3101/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: 'demo.driver@example.com',
|
||||
password: 'Demo1234!',
|
||||
}),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Normal login failed: ${response.status}`);
|
||||
}
|
||||
|
||||
// Extract cookies from the response
|
||||
const setCookieHeader = response.headers.get('set-cookie');
|
||||
if (!setCookieHeader) {
|
||||
throw new Error('No cookies set by normal login');
|
||||
}
|
||||
|
||||
// Parse the Set-Cookie headers
|
||||
const cookies = setCookieHeader.split(',').map(cookieStr => {
|
||||
const parts = cookieStr.split(';').map(p => p.trim());
|
||||
const [nameValue, ...attributes] = parts;
|
||||
const [name, value] = nameValue.split('=');
|
||||
|
||||
const cookie: any = {
|
||||
name,
|
||||
value: decodeURIComponent(value),
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
expires: Math.floor(Date.now() / 1000) + 3600, // 1 hour
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: 'Lax' as const
|
||||
};
|
||||
|
||||
for (const attr of attributes) {
|
||||
const [attrName, attrValue] = attr.split('=');
|
||||
const lowerName = attrName.toLowerCase();
|
||||
|
||||
if (lowerName === 'path') cookie.path = attrValue;
|
||||
else if (lowerName === 'httponly') cookie.httpOnly = true;
|
||||
else if (lowerName === 'secure') cookie.secure = true;
|
||||
else if (lowerName === 'samesite') cookie.sameSite = attrValue as any;
|
||||
else if (lowerName === 'domain') {
|
||||
// Skip domain from API - we'll use localhost
|
||||
}
|
||||
else if (lowerName === 'max-age') cookie.expires = Math.floor(Date.now() / 1000) + parseInt(attrValue);
|
||||
}
|
||||
|
||||
// For Docker/local testing, ensure cookies work with localhost
|
||||
// Playwright's context.addCookies requires specific settings for localhost
|
||||
if (cookie.domain === 'localhost') {
|
||||
cookie.secure = false; // Localhost doesn't need HTTPS
|
||||
// Keep sameSite as provided by API, but ensure it's compatible
|
||||
if (cookie.sameSite === 'None') {
|
||||
// For SameSite=None, we need Secure=true, but localhost doesn't support it
|
||||
// So we fall back to Lax for local testing
|
||||
cookie.sameSite = 'Lax';
|
||||
}
|
||||
}
|
||||
|
||||
return cookie;
|
||||
});
|
||||
|
||||
// Apply session drift or fault modes if specified
|
||||
if (options.sessionDrift || options.faultMode) {
|
||||
const sessionCookie = cookies.find(c => c.name === 'gp_session');
|
||||
|
||||
if (sessionCookie) {
|
||||
if (options.sessionDrift) {
|
||||
sessionCookie.value = `drift-${options.sessionDrift}-${sessionCookie.value}`;
|
||||
}
|
||||
|
||||
if (options.faultMode) {
|
||||
cookies.push({
|
||||
name: 'gridpilot_fault_mode',
|
||||
value: options.faultMode,
|
||||
domain,
|
||||
path: '/',
|
||||
expires: Math.floor(Date.now() / 1000) + 3600,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: 'Lax' as const
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear existing cookies and add the new ones
|
||||
await context.clearCookies();
|
||||
await context.addCookies(cookies);
|
||||
}
|
||||
|
||||
export type ConsoleCapture = {
|
||||
consoleErrors: string[];
|
||||
pageErrors: string[];
|
||||
};
|
||||
|
||||
export function attachConsoleErrorCapture(page: Page): ConsoleCapture {
|
||||
const consoleErrors: string[] = [];
|
||||
const pageErrors: string[] = [];
|
||||
|
||||
page.on('pageerror', (err) => {
|
||||
pageErrors.push(String(err));
|
||||
});
|
||||
|
||||
page.on('console', (msg) => {
|
||||
const type = msg.type();
|
||||
if (type !== 'error') return;
|
||||
|
||||
const text = msg.text();
|
||||
|
||||
// Filter known benign warnings (keep small + generic).
|
||||
if (text.includes('Download the React DevTools')) return;
|
||||
|
||||
// Next/Image accessibility warning (not a runtime failure for smoke coverage).
|
||||
if (text.includes('Image is missing required "alt" property')) return;
|
||||
|
||||
// React controlled <select> warning (still renders fine; treat as non-fatal for route coverage).
|
||||
if (text.includes('Use the `defaultValue` or `value` props on <select> instead of setting `selected` on <option>.')) return;
|
||||
|
||||
consoleErrors.push(`[${type}] ${text}`);
|
||||
});
|
||||
|
||||
return { consoleErrors, pageErrors };
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export type RouteAccess = 'public' | 'auth' | 'admin' | 'sponsor';
|
||||
|
||||
export type RouteParams = Record<string, string>;
|
||||
|
||||
export type WebsiteRouteDefinition = {
|
||||
pathTemplate: string;
|
||||
params?: RouteParams;
|
||||
access: RouteAccess;
|
||||
expectedPathTemplate?: string;
|
||||
allowNotFound?: boolean;
|
||||
};
|
||||
|
||||
function walkDir(rootDir: string): string[] {
|
||||
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
||||
const results: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...walkDir(fullPath));
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(fullPath);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function toPathTemplate(appDir: string, pageFilePath: string): string {
|
||||
const rel = path.relative(appDir, pageFilePath);
|
||||
const segments = rel.split(path.sep);
|
||||
|
||||
// drop trailing "page.tsx"
|
||||
segments.pop();
|
||||
|
||||
// root page.tsx
|
||||
if (segments.length === 0) return '/';
|
||||
|
||||
return `/${segments.join('/')}`;
|
||||
}
|
||||
|
||||
export function listNextAppPageTemplates(appDir?: string): string[] {
|
||||
const resolvedAppDir = appDir ?? path.join(process.cwd(), 'apps', 'website', 'app');
|
||||
|
||||
const files = walkDir(resolvedAppDir);
|
||||
const pages = files.filter((f) => path.basename(f) === 'page.tsx');
|
||||
|
||||
return pages.map((pagePath) => toPathTemplate(resolvedAppDir, pagePath));
|
||||
}
|
||||
|
||||
export function resolvePathTemplate(pathTemplate: string, params: RouteParams = {}): string {
|
||||
return pathTemplate.replace(/\[([^\]]+)\]/g, (_match, key: string) => {
|
||||
const replacement = params[key];
|
||||
if (!replacement) {
|
||||
throw new Error(`Missing route param "${key}" for template "${pathTemplate}"`);
|
||||
}
|
||||
return replacement;
|
||||
});
|
||||
}
|
||||
|
||||
// Default IDs used to resolve dynamic routes in smoke tests.
|
||||
// These values must be supported by the docker mock API in docker-compose.test.yml.
|
||||
const LEAGUE_ID = 'league-1';
|
||||
const DRIVER_ID = 'driver-1';
|
||||
const TEAM_ID = 'team-1';
|
||||
const RACE_ID = 'race-1';
|
||||
const PROTEST_ID = 'protest-1';
|
||||
|
||||
const ROUTE_META: Record<string, Omit<WebsiteRouteDefinition, 'pathTemplate'>> = {
|
||||
'/': { access: 'public' },
|
||||
|
||||
'/404': { access: 'public' },
|
||||
'/500': { access: 'public' },
|
||||
|
||||
'/admin': { access: 'admin' },
|
||||
'/admin/users': { access: 'admin' },
|
||||
|
||||
'/auth/forgot-password': { access: 'public' },
|
||||
'/auth/login': { access: 'public' },
|
||||
'/auth/reset-password': { access: 'public' },
|
||||
'/auth/signup': { access: 'public' },
|
||||
|
||||
'/dashboard': { access: 'auth' },
|
||||
|
||||
'/drivers': { access: 'public' },
|
||||
'/drivers/[id]': { access: 'public', params: { id: DRIVER_ID } },
|
||||
|
||||
'/leaderboards': { access: 'public' },
|
||||
'/leaderboards/drivers': { access: 'public' },
|
||||
|
||||
'/leagues': { access: 'public' },
|
||||
'/leagues/create': { access: 'auth' },
|
||||
'/leagues/[id]': { access: 'public', params: { id: LEAGUE_ID } },
|
||||
'/leagues/[id]/roster/admin': { access: 'admin', params: { id: LEAGUE_ID } },
|
||||
'/leagues/[id]/rulebook': { access: 'public', params: { id: LEAGUE_ID } },
|
||||
'/leagues/[id]/schedule': { access: 'public', params: { id: LEAGUE_ID } },
|
||||
'/leagues/[id]/schedule/admin': { access: 'admin', params: { id: LEAGUE_ID } },
|
||||
'/leagues/[id]/settings': { access: 'admin', params: { id: LEAGUE_ID } },
|
||||
'/leagues/[id]/sponsorships': { access: 'admin', params: { id: LEAGUE_ID } },
|
||||
'/leagues/[id]/standings': { access: 'public', params: { id: LEAGUE_ID } },
|
||||
'/leagues/[id]/stewarding': { access: 'admin', params: { id: LEAGUE_ID } },
|
||||
'/leagues/[id]/stewarding/protests/[protestId]': {
|
||||
access: 'admin',
|
||||
params: { id: LEAGUE_ID, protestId: PROTEST_ID },
|
||||
},
|
||||
'/leagues/[id]/wallet': { access: 'admin', params: { id: LEAGUE_ID } },
|
||||
|
||||
'/onboarding': { access: 'auth' },
|
||||
|
||||
'/profile': { access: 'auth' },
|
||||
'/profile/leagues': { access: 'auth' },
|
||||
'/profile/liveries': { access: 'auth' },
|
||||
'/profile/liveries/upload': { access: 'auth' },
|
||||
'/profile/settings': { access: 'auth' },
|
||||
'/profile/sponsorship-requests': { access: 'auth' },
|
||||
|
||||
'/races': { access: 'public' },
|
||||
'/races/all': { access: 'public' },
|
||||
'/races/[id]': { access: 'public', params: { id: RACE_ID } },
|
||||
'/races/[id]/results': { access: 'public', params: { id: RACE_ID } },
|
||||
'/races/[id]/stewarding': { access: 'admin', params: { id: RACE_ID } },
|
||||
|
||||
'/sponsor': { access: 'sponsor', expectedPathTemplate: '/sponsor/dashboard' },
|
||||
'/sponsor/billing': { access: 'sponsor' },
|
||||
'/sponsor/campaigns': { access: 'sponsor' },
|
||||
'/sponsor/dashboard': { access: 'sponsor' },
|
||||
'/sponsor/leagues': { access: 'sponsor' },
|
||||
'/sponsor/leagues/[id]': { access: 'sponsor', params: { id: LEAGUE_ID } },
|
||||
'/sponsor/settings': { access: 'sponsor' },
|
||||
'/sponsor/signup': { access: 'public' },
|
||||
|
||||
'/teams': { access: 'public' },
|
||||
'/teams/leaderboard': { access: 'public' },
|
||||
'/teams/[id]': { access: 'public', params: { id: TEAM_ID } },
|
||||
};
|
||||
|
||||
export function getWebsiteRouteInventory(): WebsiteRouteDefinition[] {
|
||||
const discovered = listNextAppPageTemplates();
|
||||
|
||||
const missingMeta = discovered.filter((template) => !ROUTE_META[template]);
|
||||
if (missingMeta.length > 0) {
|
||||
throw new Error(
|
||||
`Missing ROUTE_META entries for discovered pages:\n${missingMeta
|
||||
.slice()
|
||||
.sort()
|
||||
.map((t) => `- ${t}`)
|
||||
.join('\n')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const extraMeta = Object.keys(ROUTE_META).filter((template) => !discovered.includes(template));
|
||||
if (extraMeta.length > 0) {
|
||||
throw new Error(
|
||||
`ROUTE_META contains templates that are not present as page.tsx routes:\n${extraMeta
|
||||
.slice()
|
||||
.sort()
|
||||
.map((t) => `- ${t}`)
|
||||
.join('\n')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return discovered
|
||||
.slice()
|
||||
.sort()
|
||||
.map((pathTemplate) => ({ pathTemplate, ...ROUTE_META[pathTemplate] }));
|
||||
}
|
||||
|
||||
export function getWebsiteParamEdgeCases(): WebsiteRouteDefinition[] {
|
||||
return [
|
||||
{ pathTemplate: '/races/[id]', params: { id: 'does-not-exist' }, access: 'public', allowNotFound: true },
|
||||
{ pathTemplate: '/leagues/[id]', params: { id: 'does-not-exist' }, access: 'public', allowNotFound: true },
|
||||
];
|
||||
}
|
||||
|
||||
export function getWebsiteFaultInjectionRoutes(): WebsiteRouteDefinition[] {
|
||||
return [
|
||||
{ pathTemplate: '/leagues/[id]', params: { id: LEAGUE_ID }, access: 'public' },
|
||||
{ pathTemplate: '/leagues/[id]/schedule/admin', params: { id: LEAGUE_ID }, access: 'admin' },
|
||||
{ pathTemplate: '/sponsor/dashboard', access: 'sponsor' },
|
||||
{ pathTemplate: '/races/[id]', params: { id: RACE_ID }, access: 'public' },
|
||||
];
|
||||
}
|
||||
|
||||
export function getWebsiteAuthDriftRoutes(): WebsiteRouteDefinition[] {
|
||||
return [{ pathTemplate: '/sponsor/dashboard', access: 'sponsor' }];
|
||||
}
|
||||
Reference in New Issue
Block a user