391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
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 } });
|
|
});
|
|
}
|
|
}
|
|
}); |