Files
rays b7aa4f92d7 fix(ui): load chat rendering assets from the extension's own folder
The markdown and code-highlighting libraries were requested from the
absolute path /extensions/ComfyUI-Doctor/lib/. ComfyUI serves an
extension under its install folder name, so any other folder name got
404s and silently loaded unpinned copies from cdn.jsdelivr.net into the
ComfyUI origin; offline, assistant replies rendered as blank bubbles and
the failed load surfaced as an unhandled promise rejection.

Asset URLs are now resolved relative to the rendering module, and the
CDN fallback is removed. A missing asset logs one warning and the load
never rejects. The chat island shows a reply as plain text while marked
is unavailable and re-renders it as markdown once the assets settle.

The E2E test server also serves the extension under a lowercase install
folder, and the harness can load Doctor through a host-shaped
/extensions/<folder>/ entry. Host-asset tests cover both folder names,
missing assets and late asset loading, with CDN requests blocked.
2026-10-13 14:17:43 +08:00

416 lines
11 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;
}
// ComfyUI serves an extension's WEB_DIRECTORY at /extensions/<custom-node folder>/. Mirror that
// host shape for the canonical folder and a non-canonical install folder, so tests can prove that
// asset URLs follow the folder the extension is actually served from.
const HOST_EXTENSION_PREFIXES = ['ComfyUI-Doctor', 'comfyui-doctor'].map((folder) => `/extensions/${folder}/`);
const webDir = path.join(rootDir, 'web');
function confinedPath(baseDir, relativePath) {
const absolutePath = path.normalize(path.join(baseDir, relativePath));
const basePrefix = baseDir.endsWith(path.sep) ? baseDir : `${baseDir}${path.sep}`;
if (absolutePath !== baseDir && !absolutePath.startsWith(basePrefix)) {
return null;
}
return absolutePath;
}
function resolveFilePath(pathname) {
const requestedPath = pathname === '/' ? '/tests/e2e/test-harness.html' : pathname;
let decodedPath;
try {
decodedPath = decodeURIComponent(requestedPath);
} catch {
return null;
}
// IMPORTANT: the host-shaped alias must stay confined to web/ (traversal guard per base dir);
// widening it to the repository root would expose tests, scripts and planning files.
const hostPrefix = HOST_EXTENSION_PREFIXES.find((prefix) => decodedPath.startsWith(prefix));
if (hostPrefix) {
return confinedPath(webDir, decodedPath.slice(hostPrefix.length));
}
return confinedPath(rootDir, decodedPath);
}
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}`);
});