mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
207 lines
8.9 KiB
JavaScript
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(/<span/g, ...).replace(/<\\/span>/g, ...)
|
|
// leaves " (from the class attribute) and > (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*\/<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',
|
|
);
|
|
});
|
|
});
|