Files
nodeagent/tests/unit/console-contract.test.mjs
T
jkooe dac8a0a2a7 feat(console): 入库 Tauri 2 桌面控制台(主控/被控一体)
apps/console —— 跨端(macOS/Windows)桌面控制台,复用 ops 侧的 Node agent 作本地被控端,
不做第二套 agent。技术栈:Tauri 2(Rust 壳 + 系统 WebView,包体小)+
Vue 3 + TS + Vite + Pinia + Naive UI(暗色)+ Vue Router(hash)。

- command 层(src-tauri/src/lib.rs):connect / disconnect / invoke_capability /
  get_capabilities / get_state;client 事件经 Emitter 桥接为 na-log / na-state / na-event 推前端。
  握手后**先 await 成功再落 state**(不持锁 await)。
- 7 页面:设备管理 / 状态总览 / 终端 / 进程与服务 / 软件 / 文件 / 审计。
- 实测:macOS .app 6.75 MiB(方案指标 <30MB;Electron 同级约 100MB+),
  直接执行二进制进程存活、无崩溃 —— `codesign --verify --deep --strict` 那条
  "code has no resources but signature indicates they must be present" 是 adhoc
  链接签名的固有噪声,实测无害(勿被误导去「修签名」)。

⚠️ 本轮用 examples/probe.rs 对真实被控端核对字段契约,抓出 9 处**静默空列**
(不报错、类型系统也抓不到,界面只显示 "-"),全部按契约修掉:
- 进程表读 r.cpu / r.memory → 真源 cpu_pct / memory_bytes(CPU、内存两列恒为 "-")
- 服务表读 r.status → 真源 state
- 磁盘读 d.mount||d.name / d.used → 真源只有 drive + total/free,
  **没有 used**(须自算 total-free)
- 网卡读 {name,rx_bytes,tx_bytes} → 真源 {adapter,ip,up}
- 软件表读 a.id → app.list 结果**没有 id**,应为 publisher
- 审计 broken_at 当数字渲染 → 真源是 {file,line,reason} 对象
另清掉幻影字段 SystemInfo.node(契约里只有 node_path)。

配套两点设计:
- **平台门控**:连接后探测 system.info.os → isWindows/platformReady;app.* 与
  system.service.list 仅 Windows 被控端可用,故软件页与服务 Tab **事前禁用并说明原因**,
  而不是发出注定失败的请求再解释错误。
- **结构化错误**:协议真源 InvokeResult.error 只有 {name,message,data?}(**没有 code**),
  故分支一律按 name;前端 CapabilityFailure 暴露 errorName,并提供
  isUnsupportedPlatform / isAclDenied / isRateLimited。未改协议(改动会扩散 4 端)。

tests/unit/console-contract.test.mjs(20 例):把「前端声明 ⊆ 契约」与「契约 ⊆ 前端声明」
双向钉死,另校验平台门控清单与 agent 侧 requireWindows 集合一致、AuditType 覆盖
apps/agent/src/audit.ts 全部取值。**已注入漂移实测会红**(把 cpu_pct 改回 cpu →
正/反向各挂 1 项),非空转守卫。
2026-10-07 10:14:12 +08:00

227 lines
9.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.
/**
* 控制台契约守卫:`apps/console/src/types/index.ts` 里声明的字段名,
* **必须**能在协议 manifest 的 `returns_schema` 里找到。
*
* ⚠️ 背景(2026-10-07 实测才发现):M3 七个页面写完后,用 `examples/probe.rs`
* 对真实被控端跑了一遍字段契约探针,抓到 4 处**界面恒空**的漂移:
* - 进程表读 `r.cpu` / `r.memory`,真源是 `cpu_pct` / `memory_bytes` → CPU、内存两列恒为 "-"
* - 磁盘读 `d.mount || d.name` / `d.used`,真源是 `drive`,且**没有 used**(只有 total/free)→ 盘名与已用量恒空
* - 服务表读 `r.status`,真源是 `state` → 状态列恒空
* - 软件表读 `a.id`,真源是 `publisher`(`app.list` 结果**没有 id**)→ 整列恒空
* 这些都不是运行时异常,是**静默空列**——类型系统抓不到(TS 只是声明,没人跟契约比对),
* 页面上也只是显示 "-"。所以必须由本用例把「声明 ⊆ 契约」这条铁律钉死。
*
* 反向也查(契约 ⊆ 声明):只对 `returns_schema` **完整枚举**了属性的能力生效,
* 避免"契约加了字段、前端没跟上"这种漏检。
*
* 数据源一律取 `packages/protocol/dist`(已构建),与 capabilities.test.mjs 保持一致。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { findCapability } from '../../packages/protocol/dist/index.js';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
const CONSOLE_TYPES = join(repoRoot, 'apps', 'console', 'src', 'types', 'index.ts');
const CONN_STORE = join(repoRoot, 'apps', 'console', 'src', 'stores', 'conn.ts');
const source = readFileSync(CONSOLE_TYPES, 'utf8');
/**
* 极简 TS interface 解析:只取**顶层**字段名(大括号深度 = 1),
* 忽略注释、字符串与嵌套对象字面量。够用于本项目这些扁平接口。
*/
function fieldsOf(interfaceName) {
const start = source.indexOf(`export interface ${interfaceName} {`);
if (start < 0) throw new Error(`未找到 interface ${interfaceName}`);
const bodyStart = source.indexOf('{', start);
let depth = 0;
let body = '';
for (let i = bodyStart; i < source.length; i += 1) {
const ch = source[i];
if (ch === '{') {
depth += 1;
if (depth === 1) continue;
}
if (ch === '}') {
depth -= 1;
if (depth === 0) break;
}
body += ch;
}
const names = new Set();
let nest = 0;
for (const raw of body.split('\n')) {
const line = raw.replace(/\/\/.*$/, '').trim();
if (!line) continue;
const opens = (line.match(/\{/g) ?? []).length;
const closes = (line.match(/\}/g) ?? []).length;
// 只在最外层(nest === 0)收集 `key?: type` / `key: type`
if (nest === 0) {
const m = /^([A-Za-z_$][\w$]*)\s*\??\s*:/.exec(line);
if (m) names.add(m[1]);
// 索引签名 `[k: string]: unknown` 跳过
}
nest += opens - closes;
}
return names;
}
/** 取某能力 `returns_schema.properties` 的键集合。 */
function contractTop(capName) {
const cap = findCapability(capName);
assert.ok(cap, `能力 ${capName} 不存在于 manifest`);
return new Set(Object.keys(cap.returns_schema?.properties ?? {}));
}
/** 取某能力某个数组字段 `items.properties` 的键集合。 */
function contractItems(capName, arrayField) {
const cap = findCapability(capName);
const arr = cap?.returns_schema?.properties?.[arrayField];
assert.ok(arr, `${capName}.returns_schema.${arrayField} 不存在`);
return new Set(Object.keys(arr.items?.properties ?? {}));
}
/** 取某能力某对象字段(如 system.status.disks)的 items/自属性键集合。 */
function contractNested(capName, objField) {
const cap = findCapability(capName);
const node = cap?.returns_schema?.properties?.[objField];
assert.ok(node, `${capName}.returns_schema.${objField} 不存在`);
return new Set(Object.keys(node.items?.properties ?? node.properties ?? {}));
}
// ---------- 声明 ⊆ 契约(抓「声明了不存在的字段」) ----------
/** [ 控制台 interface, 能力名, 契约字段来源 ] */
const FORWARD_CASES = [
['SystemInfo', 'system.info', () => contractTop('system.info')],
['SystemStatus', 'system.status', () => contractTop('system.status')],
['DiskInfo', 'system.status', () => contractNested('system.status', 'disks')],
['NetInfo', 'system.status', () => contractNested('system.status', 'net')],
['ProcessEntry', 'system.process.list', () => contractItems('system.process.list', 'processes')],
['ServiceEntry', 'system.service.list', () => contractItems('system.service.list', 'services')],
['FsEntry', 'fs.list', () => contractItems('fs.list', 'entries')],
['FsListResult', 'fs.list', () => contractTop('fs.list')],
['FsReadResult', 'fs.read', () => contractTop('fs.read')],
['AppEntry', 'app.list', () => contractItems('app.list', 'apps')],
['AuditVerifyResult', 'system.audit.verify', () => contractTop('system.audit.verify')],
];
for (const [iface, capName, contract] of FORWARD_CASES) {
test(`契约:${iface} 的字段都存在于 ${capName} 的 returns_schema`, () => {
const declared = fieldsOf(iface);
const allowed = contract();
const missing = [...declared].filter((f) => !allowed.has(f));
assert.deepEqual(
missing,
[],
`${iface} 声明了 ${capName} 契约中不存在的字段:${missing.join(', ')}\n` +
`(契约字段:${[...allowed].join(', ')})\n` +
`👉 这类漂移不会报错,只会让界面出现**静默空列**。`,
);
});
}
// ---------- 契约 ⊆ 声明(抓「契约加了字段、前端没跟上」) ----------
/**
* 只对 returns_schema **完整枚举**属性的能力做反向校验,
* 否则 `type: 'object'` 的空壳(如 system.audit.list 的 entries item)会误报。
*/
const REVERSE_CASES = [
['SystemInfo', 'system.info', () => contractTop('system.info')],
['SystemStatus', 'system.status', () => contractTop('system.status')],
['ProcessEntry', 'system.process.list', () => contractItems('system.process.list', 'processes')],
['ServiceEntry', 'system.service.list', () => contractItems('system.service.list', 'services')],
['FsEntry', 'fs.list', () => contractItems('fs.list', 'entries')],
['FsReadResult', 'fs.read', () => contractTop('fs.read')],
['AppEntry', 'app.list', () => contractItems('app.list', 'apps')],
];
for (const [iface, capName, contract] of REVERSE_CASES) {
test(`契约:${capName} 的字段都被 ${iface} 覆盖`, () => {
const declared = fieldsOf(iface);
const required = contract();
const missed = [...required].filter((f) => !declared.has(f));
assert.deepEqual(
missed,
[],
`${capName} 契约里有 ${missed.join(', ')},但 ${iface} 未声明(前端拿不到这些字段)。`,
);
});
}
// ---------- 平台门控清单与 agent 实现一致 ----------
test('平台门控:控制台的 WINDOWS_ONLY_CAPS 与被控端抛 UNSUPPORTED_PLATFORM 的能力一致', () => {
const storeSrc = readFileSync(CONN_STORE, 'utf8');
const m = /WINDOWS_ONLY_CAPS\s*=\s*\[([^\]]*)\]/.exec(storeSrc);
assert.ok(m, '未在 conn.ts 找到 WINDOWS_ONLY_CAPS');
const declared = new Set(
m[1]
.split(',')
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
.filter(Boolean),
);
// 从被控端实现里找平台门控点:`UNSUPPORTED_PLATFORM` 出现处附近的能力名。
const gated = new Set();
for (const rel of [
['apps', 'agent', 'src', 'capabilities', 'app.ts'],
['apps', 'agent', 'src', 'capabilities', 'system.ts'],
]) {
const src = readFileSync(join(repoRoot, ...rel), 'utf8');
// requireWindows('app.xxx') 与 `${capability} 仅在被控端为 Windows 时可用` 两种写法
for (const mm of src.matchAll(/requireWindows\(\s*['"]([\w.]+)['"]/g)) gated.add(mm[1]);
for (const mm of src.matchAll(/['"]([\w.]+) 仅在被控端为 Windows 时可用['"]/g)) {
// 该文件里 system.service.list 是以字面量写的;app.ts 是模板串(已由上面捕获)
if (mm[1].includes('.')) gated.add(mm[1]);
}
}
const declaredArr = [...declared].sort();
const gatedArr = [...gated].sort();
assert.deepEqual(
declaredArr,
gatedArr,
'控制台的平台门控清单与被控端实际门控的能力不一致:\n' +
` 控制台声明:${declaredArr.join(', ')}\n` +
` 被控端实际:${gatedArr.join(', ')}`,
);
});
// ---------- 审计事件类别与真源一致 ----------
test('审计:控制台的 AuditType 覆盖 audit.ts 的全部 AuditType 取值', () => {
const auditSrc = readFileSync(
join(repoRoot, 'apps', 'agent', 'src', 'audit.ts'),
'utf8',
);
const union = /export type AuditType\s*=([\s\S]*?);/.exec(auditSrc);
assert.ok(union, '未在 audit.ts 找到 AuditType 联合类型');
const real = new Set(
[...union[1].matchAll(/'([\w.]+)'/g)].map((mm) => mm[1]),
);
assert.ok(real.size >= 8, `AuditType 解析异常,仅取到 ${real.size} 个`);
const declared = fieldsOf('AuditEntry');
assert.ok(declared.size > 0, 'AuditEntry 解析失败');
// union 类型不是 interface —— 直接正则取类型字面量
const m = /export type AuditType\s*=([\s\S]*?);/.exec(
readFileSync(CONSOLE_TYPES, 'utf8'),
);
assert.ok(m, '未在控制台 types/index.ts 找到 AuditType');
const consoleTypes = new Set([...m[1].matchAll(/"([\w.]+)"/g)].map((mm) => mm[1]));
const missed = [...real].filter((t) => !consoleTypes.has(t));
assert.deepEqual(
missed,
[],
`控制台 AuditType 缺少:${missed.join(', ')}(真源:${[...real].join(', ')})`,
);
});