/** * 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.*←.*源码已删/, "本机文件不该被报成残留"); });