import * as http from 'http'; import * as fs from 'fs'; import * as path from 'path'; export interface IFixtureServer { start(port?: number): Promise<{ url: string; port: number }>; stop(): Promise; getFixtureUrl(stepNumber: number): string; isRunning(): boolean; } /** * Step number to fixture file mapping. * Steps 2-17 map to the corresponding HTML fixture files. */ const STEP_TO_FIXTURE: Record = { 2: 'step-02-hosted-racing.html', 3: 'step-03-create-race.html', 4: 'step-04-race-information.html', 5: 'step-05-server-details.html', 6: 'step-06-set-admins.html', 7: 'step-07-time-limits.html', // Time Limits wizard step 8: 'step-08-set-cars.html', // Set Cars wizard step 9: 'step-09-add-car-modal.html', // Add Car modal 10: 'step-10-set-car-classes.html', // Set Car Classes 11: 'step-11-set-track.html', // Set Track wizard step (CORRECTED) 12: 'step-12-add-track-modal.html', // Add Track modal 13: 'step-13-track-options.html', 14: 'step-14-time-of-day.html', 15: 'step-15-weather.html', 16: 'step-16-race-options.html', 17: 'step-17-track-conditions.html', }; export class FixtureServer implements IFixtureServer { private server: http.Server | null = null; private port: number = 3456; private fixturesPath: string; constructor(fixturesPath?: string) { this.fixturesPath = fixturesPath ?? path.resolve(process.cwd(), 'resources/mock-fixtures'); } async start(port: number = 3456): Promise<{ url: string; port: number }> { if (this.server) { return { url: `http://localhost:${this.port}`, port: this.port }; } this.port = port; return new Promise((resolve, reject) => { this.server = http.createServer((req, res) => { this.handleRequest(req, res); }); this.server.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { // Try next port this.server = null; this.start(port + 1).then(resolve).catch(reject); } else { reject(err); } }); this.server.listen(this.port, () => { resolve({ url: `http://localhost:${this.port}`, port: this.port }); }); }); } async stop(): Promise { if (!this.server) { return; } return new Promise((resolve, reject) => { this.server!.close((err) => { if (err) { reject(err); } else { this.server = null; resolve(); } }); }); } getFixtureUrl(stepNumber: number): string { const fixture = STEP_TO_FIXTURE[stepNumber]; if (!fixture) { return `http://localhost:${this.port}/`; } return `http://localhost:${this.port}/${fixture}`; } isRunning(): boolean { return this.server !== null; } private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { const urlPath = req.url || '/'; const fileName = urlPath === '/' ? 'step-02-hosted-racing.html' : urlPath.replace(/^\//, ''); const filePath = path.join(this.fixturesPath, fileName); // Security check - prevent directory traversal if (!filePath.startsWith(this.fixturesPath)) { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('Forbidden'); return; } fs.readFile(filePath, (err, data) => { if (err) { if (err.code === 'ENOENT') { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); } else { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Internal Server Error'); } return; } const ext = path.extname(filePath).toLowerCase(); const contentTypes: Record = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', }; const contentType = contentTypes[ext] || 'application/octet-stream'; res.writeHead(200, { 'Content-Type': contentType }); res.end(data); }); } } /** * Get the fixture filename for a given step number. */ export function getFixtureForStep(stepNumber: number): string | undefined { return STEP_TO_FIXTURE[stepNumber]; } /** * Get all step-to-fixture mappings. */ export function getAllStepFixtureMappings(): Record { return { ...STEP_TO_FIXTURE }; }