feat: zcode thinking-mode 0.3.0(state历史+rollback+分写+校验收紧+34用例全绿+文档)

This commit is contained in:
2026-09-21 09:47:20 +08:00
commit 6ed5dda847
41 changed files with 6213 additions and 0 deletions
+375
View File
@@ -0,0 +1,375 @@
/**
* audit-plugins.mjs / deploy.mjs 的测试。
*
* 两个工具都在操作"工作区 ↔ 安装副本",测试必须证明它们**真能发现漂移** ——
* 一个永远报"一致"的审计工具比没有更糟。
*
* 全程用 ZCODE_AUDIT_WORKSPACE / ZCODE_PLUGINS_DIR / ZCODE_DEPLOY_* 重定向到临时目录,
* 不碰真实工作区与真实插件。
*
* 跑法: node --test "tests/*.test.mjs"
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
const TOOLS = path.resolve(import.meta.dirname, "..");
const AUDIT = path.join(TOOLS, "audit-plugins.mjs");
const DEPLOY = path.join(TOOLS, "deploy.mjs");
/* ---------- 造台 ---------- */
function sandbox() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "zpa-"));
const ws = path.join(root, "workspace");
const plugins = path.join(root, "plugins");
const backups = path.join(root, "backups");
fs.mkdirSync(ws, { recursive: true });
fs.mkdirSync(plugins, { recursive: true });
fs.mkdirSync(backups, { recursive: true });
return { root, ws, plugins, backups };
}
/** 在工作区造一个插件(可指定版本与文件内容) */
function makePlugin(ws, name, { version = "1.0.0", body = "export const v=1;\n", desc = true } = {}) {
const dir = path.join(ws, name);
fs.mkdirSync(path.join(dir, ".zcode-plugin"), { recursive: true });
fs.mkdirSync(path.join(dir, "scripts"), { recursive: true });
fs.mkdirSync(path.join(dir, "commands"), { recursive: true });
fs.writeFileSync(path.join(dir, ".zcode-plugin", "plugin.json"),
JSON.stringify({ name, version, commands: "commands" }, null, 2));
fs.writeFileSync(path.join(dir, "scripts", "main.mjs"), body);
fs.writeFileSync(path.join(dir, "commands", `${name}.md`),
`---\n${desc ? `description: ${name} 的命令\n` : ""}---\n\n\`\`\`bash\nnode "\${P:-$(find ~/.zcode/plugins -maxdepth 3 -name main.mjs -path '*${name}*' 2>/dev/null | head -1)}"\n\`\`\`\n`);
fs.writeFileSync(path.join(dir, "README.md"), `# ${name}\n`);
return dir;
}
function env(sb, extra = {}) {
return {
...process.env,
ZCODE_AUDIT_WORKSPACE: sb.ws,
ZCODE_DEPLOY_WORKSPACE: sb.ws,
ZCODE_PLUGINS_DIR: sb.plugins,
ZCODE_DEPLOY_BACKUP_DIR: sb.backups,
...extra,
};
}
function run(script, sb, args = [], extra = {}) {
const r = spawnSync(process.execPath, [script, ...args],
{ env: env(sb, extra), encoding: "utf8", timeout: 300000 });
return { code: r.status, out: `${r.stdout ?? ""}${r.stderr ?? ""}` };
}
/* ---------- audit:一致 / 漂移 ---------- */
test("audit:源码与副本一致时全绿退出 0", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
const r = run(AUDIT, sb);
assert.equal(r.code, 0, r.out);
assert.match(r.out, /✓ 与安装副本逐字节一致/);
assert.match(r.out, /结论:源码与安装副本一致/);
});
test("audit:改了源码没重装 → 报漂移并给出重装命令", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
// 源码改了内容,副本还是旧的 —— 这正是最容易踩的坑
fs.writeFileSync(path.join(sb.ws, "plug-a", "scripts", "main.mjs"), "export const v=999;\n");
const r = run(AUDIT, sb);
assert.equal(r.code, 1, "有漂移必须退出 1");
assert.match(r.out, /✗ 副本与源码有差异/);
assert.match(r.out, /内容不同:scripts\/main\.mjs/);
assert.match(r.out, /→ 重装:cp -r/);
});
test("audit:源码新增文件但没部署 → 报未部署", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
fs.writeFileSync(path.join(sb.ws, "plug-a", "scripts", "extra.mjs"), "export const x=1;\n");
const r = run(AUDIT, sb);
assert.equal(r.code, 1);
assert.match(r.out, /未部署:scripts\/extra\.mjs/);
});
test("audit:版本号不一致单独报出来", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a", { version: "2.0.0" });
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
// 只改副本的版本(模拟装的是老版)
const mf = path.join(sb.plugins, "plug-a", ".zcode-plugin", "plugin.json");
const j = JSON.parse(fs.readFileSync(mf, "utf8"));
fs.writeFileSync(mf, JSON.stringify({ ...j, version: "1.0.0" }, null, 2));
const r = run(AUDIT, sb);
assert.equal(r.code, 1);
assert.match(r.out, /版本不一致:源码 v2\.0\.0 \/ 安装 v1\.0\.0/);
});
test("audit:工作区有、安装目录没有 → 报未安装", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
const r = run(AUDIT, sb);
assert.equal(r.code, 1);
assert.match(r.out, /✗ 未安装/);
});
test("audit:副本独有文件只警告,不判失败(可能是有意放的本地产物)", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
fs.writeFileSync(path.join(sb.plugins, "plug-a", "local-note.txt"), "本机专用\n");
const r = run(AUDIT, sb);
assert.equal(r.code, 0, `副本独有不该判失败\n${r.out}`);
assert.match(r.out, /⚠ 副本与源码有差异/);
assert.match(r.out, /本地产物\(有意保留,不影响\):local-note\.txt/);
});
test("audit:命令文档缺 description → 报错", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a", { desc: false });
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
const r = run(AUDIT, sb);
assert.equal(r.code, 1);
assert.match(r.out, /缺 description/);
});
test("audit:文档引用了不存在的脚本 → 报错", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.writeFileSync(path.join(sb.ws, "plug-a", "commands", "plug-a.md"),
'---\ndescription: x\n---\n\n```bash\nnode "$(find ~/.zcode/plugins -name ghost.mjs -path \'*plug-a*\' | head -1)"\n```\n');
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
const r = run(AUDIT, sb);
assert.equal(r.code, 1);
assert.match(r.out, /引用脚本缺失:ghost\.mjs/);
});
test("audit:脚本语法错误 → 报语法失败(而不是等到运行时才炸)", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a", { body: "export const v = ;;;\n" });
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
const r = run(AUDIT, sb);
assert.equal(r.code, 1);
assert.match(r.out, /✗ 语法 main\.mjs/);
});
test("audit:--drift 只报漂移,不跑文档与语法检查", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a", { desc: false }); // 文档缺 description,全量模式会报错
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
const full = run(AUDIT, sb);
assert.equal(full.code, 1, "全量模式应因缺 description 失败");
const drift = run(AUDIT, sb, ["--drift"]);
assert.equal(drift.code, 0, `副本一致时 --drift 不该报错\n${drift.out}`);
assert.doesNotMatch(drift.out, /缺 description/);
});
test("audit:工作区没有插件 → 退出 1 并说明需要什么", () => {
const sb = sandbox();
const r = run(AUDIT, sb);
assert.equal(r.code, 1);
assert.match(r.out, /未在工作区找到插件/);
assert.match(r.out, /\.zcode-plugin\/plugin\.json/);
});
/* ---------- deploy ---------- */
test("deploy --dry-run:只列将改的文件,一个字节都不落盘", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
fs.writeFileSync(path.join(sb.ws, "plug-a", "scripts", "new.mjs"), "export const n=1;\n");
const before = fs.readFileSync(path.join(sb.plugins, "plug-a", "scripts", "main.mjs"), "utf8");
const r = run(DEPLOY, sb, ["--dry-run"]);
assert.equal(r.code, 0, r.out);
assert.match(r.out, /预演/);
assert.match(r.out, /新增 1/);
assert.ok(!fs.existsSync(path.join(sb.plugins, "plug-a", "scripts", "new.mjs")), "预演不得落盘");
assert.equal(fs.readFileSync(path.join(sb.plugins, "plug-a", "scripts", "main.mjs"), "utf8"), before);
});
test("deploy:真同步把漂移消掉,并留备份", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
fs.writeFileSync(path.join(sb.ws, "plug-a", "scripts", "main.mjs"), "export const v=42;\n");
const r = run(DEPLOY, sb, ["--no-test"]);
assert.equal(r.code, 0, r.out);
assert.match(r.out, /## 备份/);
assert.match(r.out, /更新 1/);
assert.equal(fs.readFileSync(path.join(sb.plugins, "plug-a", "scripts", "main.mjs"), "utf8"), "export const v=42;\n");
// 审计应转为一致
const after = run(AUDIT, sb);
assert.equal(after.code, 0, after.out);
// 备份里应是改前的版本,可据此回滚
const bks = fs.readdirSync(sb.backups).filter((d) => d.startsWith(".plugin-backups-deploy-"));
assert.equal(bks.length, 1, "应产生一个备份批次");
const old = fs.readFileSync(path.join(sb.backups, bks[0], "plug-a", "scripts", "main.mjs"), "utf8");
assert.equal(old, "export const v=1;\n", "备份必须是改动前的原文");
});
test("deploy:只在最新时如实说无需改动,不谎报同步", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
const r = run(DEPLOY, sb, ["--no-test"]);
assert.equal(r.code, 0, r.out);
assert.match(r.out, /已是最新,4 个文件无需改动/);
});
test("deploy:不删副本里的本地产物(只增改)", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
const keep = path.join(sb.plugins, "plug-a", "local-state.json");
fs.writeFileSync(keep, '{"device":"this-box"}\n');
const r = run(DEPLOY, sb, ["--no-test"]);
assert.equal(r.code, 0, r.out);
assert.ok(fs.existsSync(keep), "副本里的本地产物不该被同步删掉");
assert.equal(fs.readFileSync(keep, "utf8"), '{"device":"this-box"}\n');
});
test("deploy:指定插件名只处理那一个", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
makePlugin(sb.ws, "plug-b");
for (const n of ["plug-a", "plug-b"]) fs.cpSync(path.join(sb.ws, n), path.join(sb.plugins, n), { recursive: true });
fs.writeFileSync(path.join(sb.ws, "plug-a", "scripts", "main.mjs"), "export const v='a2';\n");
fs.writeFileSync(path.join(sb.ws, "plug-b", "scripts", "main.mjs"), "export const v='b2';\n");
const r = run(DEPLOY, sb, ["plug-a", "--no-test"]);
assert.equal(r.code, 0, r.out);
assert.doesNotMatch(r.out, /plug-b.*更新/, "不该动 plug-b");
assert.equal(fs.readFileSync(path.join(sb.plugins, "plug-b", "scripts", "main.mjs"), "utf8"), "export const v=1;\n");
});
test("deploy:插件名不存在 → 明确报错", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
const r = run(DEPLOY, sb, ["没有这个插件"]);
assert.equal(r.code, 1);
assert.match(r.out, /没找到插件:没有这个插件/);
});
test("deploy:测试失败时退出 1 且不谎报成功", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
// 给副本加一个必然失败的测试(工作区没有,所以不会触发同步)
fs.mkdirSync(path.join(sb.plugins, "plug-a", "tests"), { recursive: true });
fs.writeFileSync(path.join(sb.plugins, "plug-a", "tests", "boom.test.mjs"),
'import {test} from "node:test";\nimport assert from "node:assert/strict";\ntest("boom", () => { assert.equal(1, 2); });\n');
fs.writeFileSync(path.join(sb.plugins, "plug-a", "package.json"),
JSON.stringify({ name: "plug-a", type: "module", scripts: { test: 'node --test "tests/*.test.mjs"' } }, null, 2));
const r = run(DEPLOY, sb, [], { ZCODE_DEPLOY_BACKUP_DIR: sb.backups });
assert.equal(r.code, 1, "测试失败必须退出 1");
assert.match(r.out, /plug-a:✗/);
assert.match(r.out, /失败 1 项|项失败/);
});
/* ---------- 残留文件(源码删了、副本还在) ---------- */
test("audit:源码删掉的脚本在副本里残留 → 报错退出 1", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
// 副本里多一个源码没有的脚本 —— 幽灵代码,仍会被加载
fs.writeFileSync(path.join(sb.plugins, "plug-a", "scripts", "ghost.mjs"), "export const g=1;\n");
const r = run(AUDIT, sb);
assert.equal(r.code, 1, "残留脚本必须报错");
assert.match(r.out, /源码已删除但副本还在\(残留代码仍会被加载\):scripts\/ghost\.mjs/);
assert.match(r.out, /→ 残留文件需手工删除:rm/);
});
test("audit:根目录的本机 state 文件不当作残留(只警告)", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
// 操作员放的本机状态:按扩展名一刀切会被误判成"残留代码建议删除"
fs.writeFileSync(path.join(sb.plugins, "plug-a", "local-state.json"), '{"device":"this-box"}\n');
const r = run(AUDIT, sb);
assert.equal(r.code, 0, `本机 state 不该判失败\n${r.out}`);
assert.match(r.out, /本地产物\(有意保留,不影响\):local-state\.json/);
assert.doesNotMatch(r.out, /残留文件需手工删除/);
});
test("audit:自定义子目录里的本机文件不算残留", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
fs.mkdirSync(path.join(sb.plugins, "plug-a", "data"), { recursive: true });
fs.writeFileSync(path.join(sb.plugins, "plug-a", "data", "cache.json"), "{}\n");
const r = run(AUDIT, sb);
assert.equal(r.code, 0, `子目录本机文件不该判失败\n${r.out}`);
assert.match(r.out, /本地产物/);
});
test("audit:文档被删也算残留(命令菜单里会留个死命令)", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
fs.rmSync(path.join(sb.ws, "plug-a", "commands", "plug-a.md"));
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true, force: true });
const r = run(AUDIT, sb);
assert.equal(r.code, 1);
assert.match(r.out, /源码已删除但副本还在.*commands\/plug-a\.md/);
});
test("deploy:报告残留并给出删除命令,但不自动删(避免误删本机文件)", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
const ghost = path.join(sb.plugins, "plug-a", "scripts", "ghost.mjs");
fs.writeFileSync(ghost, "export const g=1;\n");
const r = run(DEPLOY, sb, ["--no-test"]);
assert.match(r.out, /残留 1/);
assert.match(r.out, /## 残留文件/);
assert.match(r.out, /rm .*ghost\.mjs/);
assert.ok(fs.existsSync(ghost), "不自动删 —— 交给操作者确认");
});
test("deploy:同步不会碰根目录的本机文件", () => {
const sb = sandbox();
makePlugin(sb.ws, "plug-a");
fs.cpSync(path.join(sb.ws, "plug-a"), path.join(sb.plugins, "plug-a"), { recursive: true });
const local = path.join(sb.plugins, "plug-a", "local-state.json");
fs.writeFileSync(local, '{"device":"this-box"}\n');
fs.writeFileSync(path.join(sb.ws, "plug-a", "scripts", "main.mjs"), "export const v=99;\n");
const r = run(DEPLOY, sb, ["--no-test"]);
assert.equal(r.code, 0, r.out);
assert.ok(fs.existsSync(local));
assert.equal(fs.readFileSync(local, "utf8"), '{"device":"this-box"}\n');
assert.doesNotMatch(r.out, /local-state\.json.*←.*源码已删/, "本机文件不该被报成残留");
});