Files
evomap__evolver/test/webuiServer.test.js
evolver-publish 65269ec7a0 Release v1.80.8
2026-05-16 05:36:11 +00:00

207 lines
8.9 KiB
JavaScript

'use strict';
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const http = require('http');
const { WebUiServer } = require('../src/webui');
function request(url) {
return new Promise((resolve, reject) => {
http.get(url, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString();
resolve({ status: res.statusCode, headers: res.headers, body: raw });
});
}).on('error', reject);
});
}
describe('WebUiServer', () => {
let server;
let baseUrl;
before(async () => {
server = new WebUiServer({
port: 39921,
logger: { log: () => {}, error: () => {}, warn: () => {} },
});
const info = await server.start();
baseUrl = info.url;
});
after(async () => {
await server.stop();
});
it('serves the dashboard shell', async () => {
const res = await request(`${baseUrl}/`);
assert.equal(res.status, 200);
assert.match(res.headers['content-type'], /text\/html/);
assert.match(res.body, /Evolver Web UI/);
// Dashboard must load echarts from the vendored local route, not from a
// third-party CDN. Privacy + offline-availability requirement.
assert.match(res.body, /<script src="\/vendor\/echarts\.min\.js"><\/script>/);
assert.doesNotMatch(res.body, /cdn\.jsdelivr\.net|unpkg\.com/);
});
it('serves the vendored echarts bundle with the Apache-2.0 header', async () => {
const res = await request(`${baseUrl}/vendor/echarts.min.js`);
assert.equal(res.status, 200);
assert.match(res.headers['content-type'], /application\/javascript/);
// First-line ASF license banner is preserved by the upstream minifier;
// its presence is a cheap integrity hint that we shipped the real file
// (and a license-attribution check).
assert.match(res.body.slice(0, 200), /Apache Software Foundation/);
assert.ok(res.body.length > 500000, 'vendored echarts looks truncated');
});
it('serves read-only status API with structured JSON', async () => {
const res = await request(`${baseUrl}/webui/status`);
const body = JSON.parse(res.body);
assert.equal(res.status, 200);
assert.ok(body.safety);
assert.ok(body.filesPresent);
});
it('uses structured API errors', async () => {
const res = await request(`${baseUrl}/webui/runs/missing-run-id`);
const body = JSON.parse(res.body);
assert.equal(res.status, 404);
assert.equal(body.error.code, 'RUN_NOT_FOUND');
});
it('does not ship the kv-then-partial-replace HTML escape antipattern', async () => {
// Cursor Bugbot Medium-severity finding on PR #532: passing a raw
// <span> through kv() / esc() and then trying to undo the escape
// with .replace(/&lt;span/g, ...).replace(/&lt;\\/span&gt;/g, ...)
// leaves &quot; (from the class attribute) and &gt; (from the
// opening tag terminator) intact, so the status indicator never
// renders. The fix is to build the <dl> manually with esc()'d text
// and inject the indicator span as real HTML. This guard catches
// any future regression that brings the kv()+partial-replace dance
// back -- the bundled client JS must not contain it.
const res = await request(`${baseUrl}/app.js`);
assert.equal(res.status, 200);
assert.doesNotMatch(
res.body,
/\.replace\(\s*\/&lt;span/,
'kv-then-partial-replace antipattern reintroduced; rebuild the dl manually instead',
);
});
it('survives malformed percent escapes in path segments with a clean 404', async () => {
// Cursor agentic security review (HIGH): matchPath used to call
// decodeURIComponent without a try/catch, so a request like
// /webui/runs/%E0%A4%A would throw URIError outside _handle's
// try/catch and trip Node's unhandled-rejection policy, taking the
// local WebUI down. The fix is to treat malformed segments as
// "no route matches" -> clean 404.
const res = await request(`${baseUrl}/webui/runs/%E0%A4%A`);
assert.equal(res.status, 404, 'malformed escape must 404, not crash');
const body = JSON.parse(res.body);
assert.equal(body.error.code, 'NOT_FOUND');
});
it('does not ship echarts.init() outside ensureChart() in pipelines.js', async () => {
// Cursor Bugbot Medium-severity findings on this PR: renderScoreTrend
// and renderRunGraph called echarts.init() directly, leaking instances
// that disposeAllCharts() (theme/locale toggle) and the window-resize
// handler can't see. Every chart must go through ensureChart() so the
// instance is tracked in state.charts. This guard pins the rule on
// the bundled client JS for the pipelines tab.
const res = await request(`${baseUrl}/app.js`);
assert.equal(res.status, 200);
// pipelines.js is concatenated into the bundle; ensureChart('runGraph')
// and ensureChart('scoreTrendChart') must both appear, and no raw
// echarts.init( call for either chart container must remain.
assert.match(res.body, /ensureChart\(['"]runGraph['"]\)/);
assert.match(res.body, /ensureChart\(['"]scoreTrendChart['"]\)/);
// Allow the ensureChart definition itself (which legitimately uses
// echarts.init), but reject any other call site that hits init().
const initSites = res.body.match(/echarts\.init\(/g) || [];
assert.equal(
initSites.length, 1,
`expected exactly one echarts.init( site (the ensureChart() definition), found ${initSites.length}`,
);
});
it('isDarkMode trusts the .dark class as the only source of truth', async () => {
// Cursor Bugbot Medium-severity finding on this PR: isDarkMode() used
// to fall back to matchMedia('prefers-color-scheme: dark') when .dark
// was absent. On a dark-OS system, toggling to light removed .dark but
// matchMedia still returned true, so chartTextColor() rendered light
// text on a light background. The single-source-of-truth fix is to
// return only the class check; THEME_INIT_SCRIPT pre-resolves the
// light/dark/system tri-state into the class before any JS runs.
const res = await request(`${baseUrl}/app.js`);
assert.equal(res.status, 200);
assert.doesNotMatch(
res.body,
/matchMedia\(['"]\(prefers-color-scheme:\s*dark\)['"]\)\.matches/,
'isDarkMode() must not fall back to matchMedia; the class is canonical',
);
});
it('Hub Activity table escapes every dynamic cell (no asymmetric trust)', async () => {
// Cursor Bugbot Low-severity finding (2026-05-10 round): the Hub
// Activity table row in interactions.js esc()'d every column EXCEPT
// e.statusCode and e.latencyMs, on the assumption they were always
// numeric. The proxy daemon has historically leaked string shapes
// there ('timeout', 'n/a', ...) so the asymmetric trust was an
// injection seam. Every dynamic cell in this table must now flow
// through esc(). Guard the bundled /app.js fingerprint so any
// future refactor that drops the esc() wrap immediately fails the
// suite.
const res = await request(`${baseUrl}/app.js`);
assert.equal(res.status, 200);
assert.match(
res.body,
/esc\(e\.statusCode/,
'e.statusCode must be wrapped in esc() inside the Hub Activity table renderer',
);
assert.match(
res.body,
/esc\(e\.latencyMs/,
'e.latencyMs must be wrapped in esc() inside the Hub Activity table renderer',
);
});
it('Pipelines view ships a #scoreTrendChart container so renderScoreTrend is reachable', async () => {
// Cursor Bugbot Medium-severity finding (2026-05-10 round):
// renderScoreTrend in pipelines.js looks up
// document.getElementById('scoreTrendChart'), but the pipelines
// <section> in indexHtml.js never shipped the element. The function
// always short-circuited at `if (!el) return`, so the score trend
// chart feature was silently dead code. Pin both sides of the
// contract: the dashboard shell must ship the container, and the
// bundled client JS must call ensureChart('scoreTrendChart') (the
// latter is already covered by the ensureChart pin above, but we
// also assert the i18n key for the card title in both locales so
// the AGENTS.md "every new i18n key lives in en + zh" invariant
// cannot drift back).
const shell = await request(`${baseUrl}/`);
assert.equal(shell.status, 200);
assert.match(
shell.body,
/id="scoreTrendChart"/,
'pipelines view must ship the <div id="scoreTrendChart"> container',
);
assert.match(
shell.body,
/data-i18n="pipelines\.card\.scoreTrend"/,
'Score Trend card heading must carry the i18n marker',
);
const js = await request(`${baseUrl}/app.js`);
assert.equal(js.status, 200);
assert.match(
js.body,
/'pipelines\.card\.scoreTrend'\s*:\s*\{\s*en:\s*'Score Trend',\s*zh:\s*'评分趋势'\s*\}/,
'pipelines.card.scoreTrend must be present in both en and zh dicts',
);
});
});