feat: zcode thinking-mode 0.3.0(state历史+rollback+分写+校验收紧+34用例全绿+文档)
This commit is contained in:
@@ -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.*←.*源码已删/, "本机文件不该被报成残留");
|
||||
});
|
||||
Reference in New Issue
Block a user