Files
nodeagent/tests/unit/console-commands.test.mjs
T
jkooe 456e452b30 test(console): 补两条「静默失效」守卫 —— CSS 变量闭合 + 首帧底色一致
主题改动引入了一类新的静默失效:**拼错的 CSS 变量名不报错**。
`var(--na-sunkenTypo)` 类型检查过、构建过、运行也不抛 —— 颜色只是落回继承值,
界面「看着对但就是不对」,与本项目历来吃亏的失效模式同源。故补守卫:

7. 扫 src/** 全部 var(--na-*) 使用点,与 cssVars() 的键集合比对
8. index.html 的首帧兜底色 ⇄ 调色板 bg

第 8 条是因为首帧防闪脚本是全应用**唯一允许的色值重复处**(其执行时点早于任何模块加载,
重复无法消除);既然允许重复,就必须有东西盯着它,否则改调色板忘了同步
→ 深色用户每次启动闪一帧白。

注入故障实测两条都会红:
  var(--na-sunken) → var(--na-sunkenTypo)   第 7 条红
  var(--na-sunken) → var(--na-sunkenx)      第 7 条红
  index.html #f4f4f5 → #ffffff              第 8 条红
复原后 8/8 绿,文件逐字节还原。

第 7 条顺带暴露过一次**守卫自身漏报**:正则原写 [a-z0-9-]+,遇到 `sunkenTypo`
这种含大写的拼错会在 `T` 处停下导致整个模式不匹配 —— 写错的变量名反而逃过检查。
放宽到 [^)\s,;]+ 后才真正生效。
教训:**注入故障不只验证守卫有没有,还要验证它覆盖了目标形态**。

单测 280 → 282。
2026-10-11 14:34:41 +08:00

233 lines
10 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.
/**
* 控制台「命令 / 事件 / 资源」三组契约守卫。
*
* ⚠️ 为什么必须钉死(本项目反复吃过「两份真相」的亏)
* -------------------------------------------------
* 前端调 `invoke("agent_start")`,若 Rust 侧忘了在 `generate_handler!` 里注册,
* **编译不报错、类型检查也不报错** —— 只在用户点下按钮时得到一个
* "command agent_start not found"。事件名同理:Rust `emit("na-agent-state")`
* 与前端 `listen("na-agent-state")` 拼错一个字母,界面就**永远不刷新**且毫无提示。
* 资源映射同理:`resolve_*` 在 Rust 里找 `agent.mjs`,而打包映射写成别的名字,
* 结果是「开发期全绿、装完包找不到文件」。
*
* 三类都属于**静默失效**,故用本用例把三份契约对齐:
* ① Rust `generate_handler![...]` ⇄ 前端 `invoke("...")`
* ② Rust `emit("na-...")` ⇄ 前端 `listen("na-...")`
* ③ `tauri.bundle.gen.json` 的 resource 目标名 ⇄ Rust `resolve_*` 查找的字面量
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
const CONSOLE = join(repoRoot, 'apps', 'console');
const LIB_RS = join(CONSOLE, 'src-tauri', 'src', 'lib.rs');
const AGENT_RS = join(CONSOLE, 'src-tauri', 'src', 'agent.rs');
const SIDECAR_RS = join(CONSOLE, 'src-tauri', 'src', 'sidecar.rs');
const API_TS = join(CONSOLE, 'src', 'api', 'index.ts');
const STORE_TS = join(CONSOLE, 'src', 'stores', 'agent.ts');
const GEN_JSON = join(CONSOLE, 'src-tauri', 'tauri.bundle.gen.json');
const libRs = readFileSync(LIB_RS, 'utf8');
const apiTs = readFileSync(API_TS, 'utf8');
/** 取 Rust `generate_handler![...]` 里注册的命令名。 */
function rustCommands() {
const m = /generate_handler!\s*\[([\s\S]*?)\]\s*\)/.exec(libRs);
assert.ok(m, '未在 lib.rs 找到 generate_handler! 列表');
return new Set(
m[1]
.split(',')
.map((s) => s.replace(/\/\/.*$/gm, '').trim())
.filter((s) => /^[a-z_][a-z0-9_]*$/.test(s)),
);
}
/** 取前端 `invoke<T>("name", ...)` 调用到的命令名。 */
function tsCommands() {
const out = new Set();
// 形如:invoke<AgentStatus>("agent_status") / invoke<void>("disconnect")
// / invoke<InvokeResult<T>>("invoke_capability") ← 泛型可嵌套(`InvokeResult<T>`),
// 故字符集要排除 `(` `)` `;`,否则会在第一个 `>` 处提前截断而漏掉这一条。
for (const m of apiTs.matchAll(/invoke\s*(?:<[^;()]{0,200}>)?\s*\(\s*"([a-z_][a-z0-9_]*)"/g)) {
out.add(m[1]);
}
return out;
}
test('前端调用的每个 Tauri 命令都已在 Rust 注册', () => {
const rust = rustCommands();
const ts = tsCommands();
assert.ok(rust.size >= 15, `Rust 命令解析异常,只取到 ${rust.size} 个`);
assert.ok(ts.size >= 15, `前端命令解析异常,只取到 ${ts.size} 个`);
const missing = [...ts].filter((c) => !rust.has(c)).sort();
assert.deepEqual(
missing,
[],
`前端调用了未注册的命令(运行时会报 "command not found"):${missing.join(', ')}`,
);
});
test('Rust 注册的每个命令都被前端使用(无死命令)', () => {
const rust = rustCommands();
const ts = tsCommands();
const unused = [...rust].filter((c) => !ts.has(c)).sort();
assert.deepEqual(
unused,
[],
`Rust 注册了但前端从未调用的命令(要么删掉、要么补上前端调用):${unused.join(', ')}`,
);
});
test('Rust 发出的事件名与前端监听的一致', () => {
const emitted = new Set([...libRs.matchAll(/emit\(\s*"(na-[a-z0-9-]+)"/g)].map((m) => m[1]));
const stores = readFileSync(STORE_TS, 'utf8')
+ readFileSync(join(CONSOLE, 'src', 'stores', 'conn.ts'), 'utf8');
const listened = new Set([...stores.matchAll(/listen<[^>]*>\(\s*"(na-[a-z0-9-]+)"/g)].map((m) => m[1]));
assert.ok(emitted.size >= 5, `Rust 事件名解析异常,只取到 ${emitted.size} 个:${[...emitted]}`);
assert.ok(listened.size >= 5, `前端事件名解析异常,只取到 ${listened.size} 个:${[...listened]}`);
const notListened = [...emitted].filter((e) => !listened.has(e)).sort();
assert.deepEqual(notListened, [], `Rust 发出但前端无监听的事件:${notListened.join(', ')}`);
const notEmitted = [...listened].filter((e) => !emitted.has(e)).sort();
assert.deepEqual(notEmitted, [], `前端监听了但 Rust 从不发出的事件:${notEmitted.join(', ')}`);
});
test('打包资源映射的目标名与 Rust 查找的字面量一致', () => {
// gen 片段是 prepare-console.mjs 的产物;测它的**目标名**(value)与 Rust 侧一致。
// 若未生成(例如只跑单测、没跑打包准备),则跳过而不是误报。
if (!existsSync(GEN_JSON)) return;
const gen = JSON.parse(readFileSync(GEN_JSON, 'utf8'));
const values = new Set(Object.values(gen.bundle.resources));
// Rust 侧实际查找的文件名(必须在 resource 目标名里出现,否则装完包找不到)
// node 在 Windows 上叫 node.exe —— 两者任一即可
const hasNode = values.has('node') || values.has('node.exe');
assert.ok(hasNode, `资源映射里没有 node 运行时:${[...values].join(', ')}`);
for (const name of ['sidecar.mjs', 'agent.mjs']) {
assert.ok(values.has(name), `资源映射里缺少 ${name}:${[...values].join(', ')}`);
}
// 反向:Rust 源码里确实在找这几个名字(防止「映射改了、Rust 没跟上」)
const agentRs = readFileSync(AGENT_RS, 'utf8');
const sidecarRs = readFileSync(SIDECAR_RS, 'utf8');
assert.match(agentRs, /"agent\.mjs"/, 'agent.rs 未在查找 agent.mjs');
assert.match(sidecarRs, /"sidecar\.mjs"/, 'sidecar.rs 未在查找 sidecar.mjs');
});
test('sidecar 与 Rust 的方法名对齐(check_update / download)', () => {
const sidecarSrc = readFileSync(join(CONSOLE, 'sidecar', 'main.mjs'), 'utf8');
for (const method of ['check_update', 'download', 'connect', 'disconnect', 'invoke', 'state']) {
assert.match(
sidecarSrc,
new RegExp(`\\b${method}\\s*\\(|\\b${method}\\s*:`),
`sidecar 未实现方法 ${method}`,
);
}
// Rust 侧调用的方法名必须都在 sidecar 里存在
const called = new Set(
[...libRs.matchAll(/\.request\(\s*"([a-z_]+)"/g)].map((m) => m[1]),
);
for (const m of called) {
assert.match(
sidecarSrc,
new RegExp(`\\b${m}\\s*\\(|\\b${m}\\s*:`),
`Rust 调用了 sidecar 的 "${m}",但 sidecar 未实现`,
);
}
});
test('新页面已在路由表中注册', () => {
const router = readFileSync(join(CONSOLE, 'src', 'router', 'index.ts'), 'utf8');
for (const name of ['local', 'logs']) {
assert.match(router, new RegExp(`name:\\s*"${name}"`), `路由缺少 ${name}`);
}
// 首页必须是「本机服务」—— 否则用户打开应用先看到一个要填地址的页面,
// 与「打开就能启动/停止/重启」的目标相悖
assert.match(router, /path:\s*"\/",\s*redirect:\s*"\/local"/, '默认路由应指向 /local');
// 每个 view 文件都必须真实存在(懒加载是字符串,写错文件名编译期不报错)
const views = readdirSync(join(CONSOLE, 'src', 'views'));
for (const m of router.matchAll(/import\(\s*"\.\.\/views\/([\w]+)\.vue"\s*\)/g)) {
assert.ok(views.includes(`${m[1]}.vue`), `路由引用了不存在的视图:${m[1]}.vue`);
}
});
test('自定义样式用到的 --na-* 变量都由调色板定义', () => {
const themeTsPath = join(CONSOLE, 'src', 'theme', 'index.ts');
const themeTs = readFileSync(themeTsPath, 'utf8');
// `cssVars()` 返回的键就是可用变量全集
const defined = new Set(
[...themeTs.matchAll(/"(--na-[^"]+)"\s*:/g)].map((m) => m[1]),
);
assert.ok(
defined.size >= 10,
`调色板变量只解析到 ${defined.size} 个 —— 守卫自身失效,先查正则`,
);
// 扫全 src 的使用点(定义文件自身豁免)
//
// 字符集刻意放宽到 `[^)\s,;]+` 而不是 `[a-z0-9-]+`:后者遇到
// `var(--na-sunkenTypo)` 这种含大写的拼错,会在 `T` 处停下导致整个模式**不匹配**,
// 于是「写错的变量名」反而逃过检查 —— 守卫静默漏报,比没有守卫更危险。
const used = new Map(); // 变量 → 首个出现位置
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules') continue;
const p = join(dir, e.name);
if (e.isDirectory()) {
walk(p);
} else if (/\.(vue|ts|css)$/.test(e.name) && p !== themeTsPath) {
const src = readFileSync(p, 'utf8');
for (const m of src.matchAll(/var\(\s*(--na-[^)\s,;]+)\s*\)/g)) {
if (!used.has(m[1])) used.set(m[1], p);
}
}
}
};
walk(join(CONSOLE, 'src'));
assert.ok(used.size > 0, '未扫到任何 --na-* 使用点 —— 守卫自身失效');
// 关键:`var(--na-typo)` 这种拼错的变量**不会报错**,颜色只是静默落回继承值,
// 类型检查、构建、运行都不吭声。只能靠这条断言。
for (const [v, where] of used) {
assert.ok(
defined.has(v),
`${v}(${where})被使用但 cssVars() 未定义 —— 颜色会静默落回继承值`,
);
}
});
test('index.html 的首帧兜底色与调色板一致', () => {
const themeTs = readFileSync(join(CONSOLE, 'src', 'theme', 'index.ts'), 'utf8');
const blockOf = (name) => {
const i = themeTs.indexOf(`const ${name}: Palette = {`);
assert.ok(i > 0, `未找到 ${name} 调色板`);
return themeTs.slice(i, themeTs.indexOf('\n};', i));
};
const bgOf = (name) => {
const m = /bg:\s*"(#[0-9a-fA-F]{6})"/.exec(blockOf(name));
assert.ok(m, `未从 ${name} 调色板解析出 bg`);
return m[1];
};
// index.html 在 Vue 接管前先刷底色,是**唯一**允许的色值重复处(见文件内注释)。
// 允许重复就必须有守卫:改调色板忘了改它 → 深色用户每次启动闪一帧白。
const html = readFileSync(join(CONSOLE, 'index.html'), 'utf8');
for (const name of ['WHITE', 'GRAY']) {
const bg = bgOf(name);
assert.ok(
html.includes(bg),
`index.html 的首帧兜底色缺少 ${name}.bg = ${bg} —— 换主题时会闪一帧错色`,
);
}
});