395 lines
10 KiB
JavaScript
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}`);
|
|
});
|