Files

395 lines
10 KiB
JavaScript

#!/usr/bin/env node
import { createServer } from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const port = Number(process.env.PW_WEB_SERVER_PORT) || 3000;
const host = '127.0.0.1';
const rootDir = process.cwd();
const telemetryState = {
enabled: false,
events: [],
};
const allowedTelemetryEvents = {
feature: {
tab_switch: ['chat', 'stats', 'settings'],
},
analysis: {
pattern_matched: null,
llm_called: ['openai', 'deepseek', 'anthropic', 'ollama', 'lmstudio', 'gemini', 'groq', 'openrouter', 'xai', 'custom'],
},
resolution: {
marked: ['resolved', 'unresolved', 'ignored'],
},
session: {
start: null,
end: null,
},
};
const mimeTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
'.webm': 'video/webm',
};
function sendJson(res, status, payload) {
const body = JSON.stringify(payload);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store',
});
res.end(body);
}
function sendNoContent(res) {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
});
res.end();
}
function isDoctorPath(pathname) {
return pathname.startsWith('/doctor/') || pathname.startsWith('/debugger/');
}
function readRequestBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.setEncoding('utf8');
req.on('data', chunk => {
body += chunk;
if (body.length > 1024 * 1024) {
reject(new Error('Request body too large'));
req.destroy();
}
});
req.on('end', () => resolve(body));
req.on('error', reject);
});
}
async function readJsonPayload(req) {
const body = await readRequestBody(req);
if (!body.trim()) {
return {};
}
return JSON.parse(body);
}
function telemetryStats() {
return {
count: telemetryState.events.length,
oldest: telemetryState.events[0]?.timestamp || null,
newest: telemetryState.events[telemetryState.events.length - 1]?.timestamp || null,
};
}
function validateTelemetryEvent(payload) {
const category = payload?.category;
const action = payload?.action;
if (!category || !action) {
return 'Missing required field: category or action';
}
if (!Object.prototype.hasOwnProperty.call(allowedTelemetryEvents, category)) {
return `Invalid category: ${category}`;
}
const actionMap = allowedTelemetryEvents[category];
if (!Object.prototype.hasOwnProperty.call(actionMap, action)) {
return `Invalid action: ${action} for category ${category}`;
}
const allowedLabels = actionMap[action];
if (Array.isArray(allowedLabels) && payload.label !== undefined && !allowedLabels.includes(payload.label)) {
return `Invalid label: ${payload.label}`;
}
return '';
}
function telemetryEventFromPayload(payload) {
const event = {
schema_version: '1.0',
event_id: `mock-${Date.now()}-${telemetryState.events.length + 1}`,
timestamp: new Date().toISOString(),
category: payload.category,
action: payload.action,
};
if (payload.label !== undefined) {
event.label = payload.label;
}
if (payload.value !== undefined) {
event.value = payload.value;
}
return event;
}
async function handleTelemetryEndpoint(req, res, url) {
const { pathname } = url;
const method = req.method || 'GET';
if (pathname === '/doctor/telemetry/status' && method === 'GET') {
sendJson(res, 200, {
success: true,
enabled: telemetryState.enabled,
stats: telemetryStats(),
upload_destination: null,
});
return true;
}
if (pathname === '/doctor/telemetry/buffer' && method === 'GET') {
sendJson(res, 200, {
success: true,
events: telemetryState.events,
count: telemetryState.events.length,
});
return true;
}
if (pathname === '/doctor/telemetry/toggle' && method === 'POST') {
const payload = await readJsonPayload(req);
telemetryState.enabled = Boolean(payload.enabled);
sendJson(res, 200, {
success: true,
enabled: telemetryState.enabled,
message: telemetryState.enabled ? 'Telemetry enabled' : 'Telemetry disabled',
});
return true;
}
if (pathname === '/doctor/telemetry/clear' && method === 'POST') {
await readJsonPayload(req).catch(() => ({}));
telemetryState.events = [];
sendJson(res, 200, {
success: true,
message: 'Buffer cleared',
});
return true;
}
if (pathname === '/doctor/telemetry/track' && method === 'POST') {
const origin = req.headers.origin || '';
const hostHeader = req.headers.host || '';
if (origin) {
const originHost = new URL(origin).host;
if (originHost && hostHeader && originHost !== hostHeader) {
sendJson(res, 403, {
success: false,
error: 'cross_origin_rejected',
message: 'Cross-origin request rejected',
});
return true;
}
}
const contentLength = Number(req.headers['content-length'] || 0);
if (contentLength > 1024) {
sendJson(res, 413, {
success: false,
error: 'payload_too_large',
message: 'Payload too large',
});
return true;
}
const payload = await readJsonPayload(req);
if (!telemetryState.enabled) {
sendJson(res, 200, {
success: false,
message: 'Telemetry disabled',
});
return true;
}
const validationError = validateTelemetryEvent(payload);
if (validationError) {
sendJson(res, 200, {
success: false,
message: validationError,
});
return true;
}
telemetryState.events.push(telemetryEventFromPayload(payload));
sendJson(res, 200, {
success: true,
message: 'Event recorded',
});
return true;
}
if (pathname === '/doctor/telemetry/export' && method === 'GET') {
const body = JSON.stringify(telemetryState.events);
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store',
'Content-Disposition': 'attachment; filename=telemetry_export.json',
});
res.end(body);
return true;
}
return false;
}
async function handleMockEndpoint(req, res, url) {
const { pathname, searchParams } = url;
const method = req.method || 'GET';
// Root cause note: static http.server generated noisy 404/501 for
// /doctor/* and /debugger/*, obscuring real regressions in E2E output.
if (method === 'OPTIONS' && isDoctorPath(pathname)) {
sendNoContent(res);
return true;
}
if (await handleTelemetryEndpoint(req, res, url)) {
return true;
}
if (pathname === '/doctor/health_report' && method === 'GET') {
sendJson(res, 200, {
success: true,
errors: [],
summary: { total: 0 },
});
return true;
}
if (pathname === '/doctor/provider_defaults' && method === 'GET') {
sendJson(res, 200, {
openai: 'https://api.openai.com/v1',
deepseek: 'https://api.deepseek.com/v1',
});
return true;
}
if (pathname === '/doctor/ui_text' && method === 'GET') {
const lang = searchParams.get('lang') || 'en';
sendJson(res, 200, { language: lang, text: {} });
return true;
}
if (pathname === '/doctor/list_models' && method === 'POST') {
sendJson(res, 200, { success: true, models: [] });
return true;
}
if (pathname === '/doctor/statistics' && method === 'GET') {
sendJson(res, 200, {
success: true,
statistics: {
total_errors: 0,
pattern_frequency: {},
category_breakdown: {},
top_patterns: [],
resolution_rate: { resolved: 0, unresolved: 0, ignored: 0 },
trend: { last_24h: 0, last_7d: 0, last_30d: 0 },
},
});
return true;
}
if (pathname === '/debugger/last_analysis' && method === 'GET') {
sendJson(res, 200, {});
return true;
}
if (pathname === '/debugger/set_language' && method === 'POST') {
sendJson(res, 200, { success: true });
return true;
}
if (isDoctorPath(pathname)) {
sendJson(res, 200, { success: true, mocked: true, path: pathname });
return true;
}
return false;
}
function resolveFilePath(pathname) {
const requestedPath = pathname === '/' ? '/tests/e2e/test-harness.html' : pathname;
const decodedPath = decodeURIComponent(requestedPath);
const absolutePath = path.normalize(path.join(rootDir, decodedPath));
const rootPrefix = rootDir.endsWith(path.sep) ? rootDir : `${rootDir}${path.sep}`;
if (absolutePath !== rootDir && !absolutePath.startsWith(rootPrefix)) {
return null;
}
return absolutePath;
}
function serveStatic(req, res, pathname) {
const filePath = resolveFilePath(pathname);
if (!filePath) {
sendJson(res, 403, { success: false, message: 'Forbidden path' });
return;
}
fs.stat(filePath, (statError, stats) => {
if (statError || !stats.isFile()) {
sendJson(res, 404, { success: false, message: 'File not found' });
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = mimeTypes[ext] || 'application/octet-stream';
res.writeHead(200, {
'Content-Type': contentType,
'Content-Length': stats.size,
'Cache-Control': 'no-cache',
});
if (req.method === 'HEAD') {
res.end();
return;
}
const stream = fs.createReadStream(filePath);
stream.on('error', () => {
if (!res.headersSent) {
sendJson(res, 500, { success: false, message: 'Failed to read file' });
} else {
res.destroy();
}
});
stream.pipe(res);
});
}
const server = createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://${host}:${port}`);
try {
if (await handleMockEndpoint(req, res, url)) {
return;
}
if ((req.method || 'GET') !== 'GET' && (req.method || 'GET') !== 'HEAD') {
sendJson(res, 405, { success: false, message: 'Method Not Allowed' });
return;
}
serveStatic(req, res, url.pathname);
} catch (error) {
sendJson(res, 500, {
success: false,
message: error instanceof Error ? error.message : 'Mock server error',
});
}
});
server.listen(port, host, () => {
console.error(`[e2e-server] serving ${rootDir} at http://${host}:${port}`);
});