website refactor

This commit is contained in:
2026-01-17 22:55:03 +01:00
parent 64d9e7fd16
commit 69d4cce7f1
64 changed files with 1146 additions and 1014 deletions

View File

@@ -0,0 +1,100 @@
import { spawn, ChildProcess } from 'child_process';
import { join } from 'path';
export interface ApiServerHarnessOptions {
port?: number;
env?: Record<string, string>;
}
export class ApiServerHarness {
private process: ChildProcess | null = null;
private logs: string[] = [];
private port: number;
constructor(options: ApiServerHarnessOptions = {}) {
this.port = options.port || 3001;
}
async start(): Promise<void> {
return new Promise((resolve, reject) => {
const cwd = join(process.cwd(), 'apps/api');
this.process = spawn('npm', ['run', 'start:dev'], {
cwd,
env: {
...process.env,
PORT: this.port.toString(),
GRIDPILOT_API_PERSISTENCE: 'inmemory',
ENABLE_BOOTSTRAP: 'true',
},
shell: true,
detached: true,
});
let resolved = false;
const checkReadiness = async () => {
if (resolved) return;
try {
const res = await fetch(`http://localhost:${this.port}/health`);
if (res.ok) {
resolved = true;
resolve();
}
} catch (e) {
// Not ready yet
}
};
this.process.stdout?.on('data', (data) => {
const str = data.toString();
this.logs.push(str);
if (str.includes('Nest application successfully started') || str.includes('started')) {
checkReadiness();
}
});
this.process.stderr?.on('data', (data) => {
const str = data.toString();
this.logs.push(str);
});
this.process.on('error', (err) => {
if (!resolved) {
resolved = true;
reject(err);
}
});
this.process.on('exit', (code) => {
if (!resolved && code !== 0 && code !== null) {
resolved = true;
reject(new Error(`API server exited with code ${code}`));
}
});
// Timeout after 60 seconds
setTimeout(() => {
if (!resolved) {
resolved = true;
reject(new Error(`API server failed to start within 60s. Logs:\n${this.getLogTail(20)}`));
}
}, 60000);
});
}
async stop(): Promise<void> {
if (this.process && this.process.pid) {
try {
process.kill(-this.process.pid);
} catch (e) {
this.process.kill();
}
this.process = null;
}
}
getLogTail(lines: number = 60): string {
return this.logs.slice(-lines).join('');
}
}

View File

@@ -11,8 +11,10 @@ export class WebsiteServerHarness {
private process: ChildProcess | null = null;
private logs: string[] = [];
private port: number;
private options: WebsiteServerHarnessOptions;
constructor(options: WebsiteServerHarnessOptions = {}) {
this.options = options;
this.port = options.port || 3000;
}
@@ -28,46 +30,75 @@ export class WebsiteServerHarness {
cwd,
env: {
...process.env,
...this.options.env,
PORT: this.port.toString(),
...((this.process as unknown as { env: Record<string, string> })?.env || {}),
},
shell: true,
detached: true, // Start in a new process group
});
let resolved = false;
const checkReadiness = async () => {
if (resolved) return;
try {
const res = await fetch(`http://localhost:${this.port}`, { method: 'HEAD' });
if (res.ok || res.status === 307 || res.status === 200) {
resolved = true;
resolve();
}
} catch (e) {
// Not ready yet
}
};
this.process.stdout?.on('data', (data) => {
const str = data.toString();
this.logs.push(str);
if (str.includes('ready') || str.includes('started') || str.includes('Local:')) {
resolve();
checkReadiness();
}
});
this.process.stderr?.on('data', (data) => {
const str = data.toString();
this.logs.push(str);
console.error(`[Website Server Error] ${str}`);
// Don't console.error here as it might be noisy, but keep in logs
});
this.process.on('error', (err) => {
reject(err);
});
this.process.on('exit', (code) => {
if (code !== 0 && code !== null) {
console.error(`Website server exited with code ${code}`);
if (!resolved) {
resolved = true;
reject(err);
}
});
// Timeout after 30 seconds
this.process.on('exit', (code) => {
if (!resolved && code !== 0 && code !== null) {
resolved = true;
reject(new Error(`Website server exited with code ${code}`));
}
});
// Timeout after 60 seconds (Next.js dev can be slow)
setTimeout(() => {
reject(new Error('Website server failed to start within 30s'));
}, 30000);
if (!resolved) {
resolved = true;
reject(new Error(`Website server failed to start within 60s. Logs:\n${this.getLogTail(20)}`));
}
}, 60000);
});
}
async stop(): Promise<void> {
if (this.process) {
this.process.kill();
if (this.process && this.process.pid) {
try {
// Kill the process group since we used detached: true
process.kill(-this.process.pid);
} catch (e) {
// Fallback to normal kill
this.process.kill();
}
this.process = null;
}
}
@@ -84,8 +115,10 @@ export class WebsiteServerHarness {
const errorPatterns = [
'uncaughtException',
'unhandledRejection',
'Error: ',
// 'Error: ', // Too broad, catches expected API errors
];
// Only fail on actual process-level errors or unexpected server crashes
return this.logs.some(log => errorPatterns.some(pattern => log.includes(pattern)));
}
}

View File

@@ -1,10 +1,11 @@
import { describe, test, beforeAll, afterAll } from 'vitest';
import { routes } from '../../../apps/website/lib/routing/RouteConfig';
import { WebsiteServerHarness } from '../harness/WebsiteServerHarness';
import { ApiServerHarness } from '../harness/ApiServerHarness';
import { HttpDiagnostics } from '../../shared/website/HttpDiagnostics';
const WEBSITE_BASE_URL = process.env.WEBSITE_BASE_URL || 'http://localhost:3000';
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3101';
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3001';
type AuthRole = 'unauth' | 'auth' | 'admin' | 'sponsor';
@@ -18,6 +19,7 @@ async function loginViaApi(role: AuthRole): Promise<string | null> {
}[role];
try {
console.log(`[RouteProtection] Attempting login for role ${role} at ${API_BASE_URL}/auth/login`);
const res = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -25,38 +27,71 @@ async function loginViaApi(role: AuthRole): Promise<string | null> {
});
if (!res.ok) {
console.warn(`Login failed for role ${role}: ${res.status} ${res.statusText}`);
console.warn(`[RouteProtection] Login failed for role ${role}: ${res.status} ${res.statusText}`);
const body = await res.text();
console.warn(`[RouteProtection] Login failure body: ${body}`);
return null;
}
const setCookie = res.headers.get('set-cookie') ?? '';
console.log(`[RouteProtection] Login success. set-cookie: ${setCookie}`);
const cookiePart = setCookie.split(';')[0] ?? '';
return cookiePart.startsWith('gp_session=') ? cookiePart : null;
} catch (e) {
console.warn(`Could not connect to API at ${API_BASE_URL} for role ${role} login.`);
console.warn(`[RouteProtection] Could not connect to API at ${API_BASE_URL} for role ${role} login: ${e.message}`);
return null;
}
}
describe('Route Protection Matrix', () => {
let harness: WebsiteServerHarness | null = null;
let websiteHarness: WebsiteServerHarness | null = null;
let apiHarness: ApiServerHarness | null = null;
beforeAll(async () => {
if (WEBSITE_BASE_URL.includes('localhost')) {
console.log(`[RouteProtection] beforeAll starting. WEBSITE_BASE_URL=${WEBSITE_BASE_URL}, API_BASE_URL=${API_BASE_URL}`);
// 1. Ensure API is running
if (API_BASE_URL.includes('localhost')) {
try {
await fetch(WEBSITE_BASE_URL, { method: 'HEAD' });
await fetch(`${API_BASE_URL}/health`);
console.log(`[RouteProtection] API already running at ${API_BASE_URL}`);
} catch (e) {
harness = new WebsiteServerHarness({
port: parseInt(new URL(WEBSITE_BASE_URL).port) || 3000,
console.log(`[RouteProtection] Starting API server harness on ${API_BASE_URL}...`);
apiHarness = new ApiServerHarness({
port: parseInt(new URL(API_BASE_URL).port) || 3001,
});
await harness.start();
await apiHarness.start();
console.log(`[RouteProtection] API Harness started.`);
}
}
});
// 2. Ensure Website is running
if (WEBSITE_BASE_URL.includes('localhost')) {
try {
console.log(`[RouteProtection] Checking if website is already running at ${WEBSITE_BASE_URL}`);
await fetch(WEBSITE_BASE_URL, { method: 'HEAD' });
console.log(`[RouteProtection] Website already running.`);
} catch (e) {
console.log(`[RouteProtection] Website not running, starting harness...`);
websiteHarness = new WebsiteServerHarness({
port: parseInt(new URL(WEBSITE_BASE_URL).port) || 3000,
env: {
API_BASE_URL: API_BASE_URL,
NEXT_PUBLIC_API_BASE_URL: API_BASE_URL,
},
});
await websiteHarness.start();
console.log(`[RouteProtection] Website Harness started.`);
}
}
}, 120000);
afterAll(async () => {
if (harness) {
await harness.stop();
if (websiteHarness) {
await websiteHarness.stop();
}
if (apiHarness) {
await apiHarness.stop();
}
});
@@ -73,19 +108,19 @@ describe('Route Protection Matrix', () => {
{ role: 'unauth', path: routes.sponsor.dashboard, expectedStatus: [302, 307], expectedRedirect: routes.auth.login },
// Authenticated (Driver)
{ role: 'auth', path: routes.public.home, expectedStatus: 200 },
{ role: 'auth', path: routes.public.home, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
{ role: 'auth', path: routes.protected.dashboard, expectedStatus: 200 },
{ role: 'auth', path: routes.admin.root, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
{ role: 'auth', path: routes.sponsor.dashboard, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
// Admin
{ role: 'admin', path: routes.public.home, expectedStatus: 200 },
{ role: 'admin', path: routes.public.home, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
{ role: 'admin', path: routes.protected.dashboard, expectedStatus: 200 },
{ role: 'admin', path: routes.admin.root, expectedStatus: 200 },
{ role: 'admin', path: routes.sponsor.dashboard, expectedStatus: [302, 307], expectedRedirect: routes.admin.root },
// Sponsor
{ role: 'sponsor', path: routes.public.home, expectedStatus: 200 },
{ role: 'sponsor', path: routes.public.home, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard },
{ role: 'sponsor', path: routes.protected.dashboard, expectedStatus: 200 },
{ role: 'sponsor', path: routes.admin.root, expectedStatus: [302, 307], expectedRedirect: routes.sponsor.dashboard },
{ role: 'sponsor', path: routes.sponsor.dashboard, expectedStatus: 200 },
@@ -123,7 +158,7 @@ describe('Route Protection Matrix', () => {
status,
location,
html,
serverLogs: harness?.getLogTail(60),
serverLogs: websiteHarness?.getLogTail(60),
};
const formatFailure = (extra: string) => HttpDiagnostics.formatHttpFailure({ ...failureContext, extra });