Files
gridpilot.gg/packages/infrastructure/adapters/automation/FixtureServer.ts

157 lines
4.3 KiB
TypeScript

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<void>;
getFixtureUrl(stepNumber: number): string;
isRunning(): boolean;
}
/**
* Step number to fixture file mapping.
* Steps 2-18 map to the corresponding HTML fixture files.
*/
const STEP_TO_FIXTURE: Record<number, string> = {
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-add-admin.html',
8: 'step-08-time-limits.html',
9: 'step-09-set-cars.html',
10: 'step-10-add-car.html',
11: 'step-11-set-car-classes.html',
12: 'step-12-set-track.html',
13: 'step-13-add-track.html',
14: 'step-14-track-options.html',
15: 'step-15-time-of-day.html',
16: 'step-16-weather.html',
17: 'step-17-race-options.html',
18: 'step-18-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<void> {
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<string, string> = {
'.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<number, string> {
return { ...STEP_TO_FIXTURE };
}