Files
aurora-admin/tools/verify-dev-server.mjs
T
aurora-admin f1fbfc2ddb
Regression / regression (push) Canceled after 0s
feat(品牌标识): 几何 K 图标(favicon/顶栏标记/theme-color) + 并行会话成果入库
## 品牌标识(本次会话)

起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」,
那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」,
移动端站已是「K」;移动端文档站则完全没有 favicon)。

- 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边;
  描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」
- 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark);
  顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28))
- 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg)
- 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留)
- 移动端 7 个模板补 favicon(此前计数 0)

验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/
theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致;
两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。

## 并行会话成果(本次一并入库)

- 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具
  + IconPreview 预览页 + ICON-SPEC.md 冻结规格
- 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁
- PC 组件:103 个大后台组件 / 组件11 批次
- uni-app:PC 端试点 + 移动端端实现 + 真实编译验证

## 工程

- .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库)
- CHANGELOG 补品牌标识条目
- ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
2026-09-21 10:05:48 +08:00

171 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* verify-dev-server.mjs — 静态服务的健壮性验证(零依赖,只用 node 内置模块)。
*
* 背景(实测缺陷 P1):
* 修复前 `site/dev-server.js` 的请求回调直接调用 fs.readFile(filePath, cb),
* 而 decodeURIComponent('%00') 会解出 NUL 字符;fs.readFile 对含 NUL 的路径**同步**
* 抛 TypeError [ERR_INVALID_ARG_VALUE],异常逃逸出请求回调 → 整个进程退出。
* 即:单个畸形请求 = 一次 DoS。
*
* 反例断言(修复前必然失败,用来区分修复前后):
* [COUNTEREXAMPLE] MALFORMED_BURST_SERVER_ALIVE
* —— 发完 %00 / 穿越 / 双重编码 NUL 请求后,子进程仍存活(exitCode === null),
* 且再发一次正常请求仍为 200;stderr 不含 ERR_INVALID_ARG_VALUE。
* 修复前的真实表现(本机实测,修复前工作树副本):
* GET /site/%00 -> 客户端 ECONNRESET
* child.exitCode -> 1
* 其后 GET /site/ -> ECONNREFUSED
* stderr: TypeError [ERR_INVALID_ARG_VALUE]: The argument 'path' must be a string,
* Uint8Array, or URL without null bytes.
* at Object.readFile (node:fs:385:16)
* at Server.<anonymous> (.../site/dev-server.js:106:6)
*
* 端口:默认用 13311,避免与正在运行的 3311 实例抢端口;可用 KOLE_PORT 覆盖。
* 验证脚本把 KOLE_PORT 传给子进程(这也是 dev-server.js 新增的唯一开关)。
*
* 运行:node tools/verify-dev-server.mjs
* 退出码:0 = 全部通过;1 = 有断言失败 / 服务起不来。
*/
import { spawn } from 'node:child_process';
import { request } from 'node:http';
import { join } from 'node:path';
const ROOT = join(import.meta.dirname, '..');
const HOST = '127.0.0.1';
const PORT = Number(process.env.KOLE_PORT) || 13311;
const BASELINE_PORT = 13311;
let failed = 0;
let passed = 0;
const results = [];
function check(name, ok, detail = '') {
results.push({ name, ok, detail });
if (ok) passed++;
else failed++;
}
function get(path) {
return new Promise((resolve) => {
const req = request({ host: HOST, port: PORT, path, method: 'GET' }, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body, error: null }));
});
req.on('error', (e) => resolve({ status: 0, headers: {}, body: '', error: e.code || e.message }));
req.setTimeout(5000, () => { req.destroy(); resolve({ status: 0, headers: {}, body: '', error: 'TIMEOUT' }); });
req.end();
});
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
console.log(`[dev-server] 启动子进程 node site/dev-server.js (KOLE_PORT=${PORT}, HOST=${HOST})`);
const child = spawn(process.execPath, [join('site', 'dev-server.js')], {
cwd: ROOT,
env: { ...process.env, KOLE_PORT: String(PORT) },
stdio: ['ignore', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
const watchdog = setTimeout(() => {
console.error('[dev-server] 看门狗超时(20s),强制收尾');
try { child.kill(); } catch { /* 已退出 */ }
process.exit(1);
}, 20000);
/* ---------- 1. 等待就绪 ---------- */
let ready = null;
for (let i = 0; i < 40; i++) {
if (child.exitCode !== null) break;
const r = await get('/site/');
if (r.status === 200) { ready = r; break; }
await sleep(250);
}
check('server starts and answers GET /site/ with 200', ready !== null,
ready ? `status=${ready.status}` : `子进程未就绪 exitCode=${child.exitCode} stderr=${JSON.stringify(stderr.slice(0, 200))}`);
check('KOLE_PORT honored in startup banner', stdout.includes(`:${PORT}/site/`),
`banner=${JSON.stringify(stdout.trim().split('\n')[0] || '')}`);
if (ready) {
check('GET /site/ serves the real site document', /<!DOCTYPE html>/i.test(ready.body) && ready.body.length > 200,
`bytes=${ready.body.length}`);
check('security headers kept (CSP + nosniff)', /default-src 'self'/.test(ready.headers['content-security-policy'] || '') &&
ready.headers['x-content-type-options'] === 'nosniff');
}
/* ---------- 2. 既有行为回归(不得被本次修复改动) ---------- */
const rootRedirect = await get('/');
check('GET / -> 302 to /site/', rootRedirect.status === 302 && rootRedirect.headers.location === '/site/',
`status=${rootRedirect.status} location=${rootRedirect.headers.location}`);
const indexHtml = await get('/site/index.html');
check('GET /site/index.html -> 200 text/html', indexHtml.status === 200 &&
/text\/html/.test(indexHtml.headers['content-type'] || ''), `status=${indexHtml.status}`);
const appJs = await get('/site/app.js');
check('GET /site/app.js -> 200 text/javascript', appJs.status === 200 &&
/text\/javascript/.test(appJs.headers['content-type'] || ''), `status=${appJs.status}`);
const missing = await get('/site/definitely-missing-xyz.html');
check('missing file -> 404 with unchanged wording', missing.status === 404 &&
missing.body === '404 Not Found: /site/definitely-missing-xyz.html', `status=${missing.status}`);
/* ---------- 3. 畸形请求矩阵(全部必须 4xx,且不得打挂进程) ---------- */
const malformed = [
['/site/%00', 'NUL 编码'],
['/site/%2500', '双重编码 NUL(%2500)'],
['/site/%00.html', 'NUL 编码 + 后缀'],
['/site/sub/%2500', '子目录下的双重编码 NUL'],
['/site/../package.json', '相对穿越'],
['/..%2fpackage.json', '编码穿越 %2f'],
['/site/%2e%2e/package.json', '编码穿越 %2e'],
['/site/.git/config', '敏感段 .git']
];
const malformedSeen = [];
for (const [path, label] of malformed) {
const r = await get(path);
malformedSeen.push(`${path}(${label}) -> ${r.status || r.error}`);
check(`malformed ${path} (${label}) -> 4xx`,
r.status >= 400 && r.status < 500, `status=${r.status || r.error}`);
}
/* ---------- 4. 反例断言:修复前必然失败 ---------- */
await sleep(300);
const aliveExitCode = child.exitCode;
const afterBurst = await get('/site/');
check('[COUNTEREXAMPLE] MALFORMED_BURST_SERVER_ALIVE — 畸形请求突发后进程仍存活',
aliveExitCode === null, `child.exitCode=${aliveExitCode}(修复前为 1)`);
check('[COUNTEREXAMPLE] MALFORMED_BURST_STILL_SERVING — 突发后正常请求仍 200',
afterBurst.status === 200, `status=${afterBurst.status || afterBurst.error}(修复前为 ECONNREFUSED)`);
check('[COUNTEREXAMPLE] NO_FATAL_STDERR — stderr 不含 ERR_INVALID_ARG_VALUE 崩溃栈',
!/ERR_INVALID_ARG_VALUE/.test(stderr), `stderr=${JSON.stringify(stderr.slice(0, 160))}`);
check('repeated NUL requests stay handled (10x)', await (async () => {
for (let i = 0; i < 10; i++) {
const r = await get('/site/%00');
if (!(r.status >= 400 && r.status < 500)) return false;
}
const last = await get('/site/');
return last.status === 200 && child.exitCode === null;
})(), '10 次 %00 后仍存活且仍 4xx');
/* ---------- 收尾 ---------- */
try { child.kill(); } catch { /* 已退出 */ }
clearTimeout(watchdog);
console.log('');
console.log(`[dev-server] 端口 ${PORT}${PORT === BASELINE_PORT ? '(默认验证端口,未占用 3311)' : '(KOLE_PORT 覆盖)'}`);
console.log(`[dev-server] 畸形请求实测:${malformedSeen.join(' | ')}`);
for (const r of results) {
console.log(`[dev-server] ${r.ok ? 'OK' : 'FAIL'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`);
}
if (failed) {
console.error(`\n[dev-server] ${failed} check(s) failed / ${passed} passed`);
process.exit(1);
}
console.log(`\n[dev-server] OK — ${passed} checks passed(含 3 条修复前必失败的反例断言)`);
process.exit(0);