/** * 新命令测试:prune / verify / doctor / status --list * 用 USERPROFILE 重定向 HOME + 本地裸仓,不碰真实环境。 * 跑法: node --test tests/ */ 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 { execFileSync, spawnSync } from "node:child_process"; import { decryptSecrets } from "../scripts/sync-core.mjs"; const SCRIPTS = path.resolve(import.meta.dirname, "..", "scripts"); const PASS = "team-passphrase-for-tests"; const GIT_ENV = { GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0" }; function sandboxRoot() { return fs.mkdtempSync(path.join(os.tmpdir(), "zes-tools-")); } function makeMachine(root, name) { const home = path.join(root, name); for (const d of [".zcode/cli/plugins/data", ".zcode/cli/plugins/marketplaces/zcode-plugins-official", ".zcode/plugins", ".dsh/secrets", ".ssh"]) { fs.mkdirSync(path.join(home, d), { recursive: true }); } fs.writeFileSync(path.join(home, ".zcode/cli/plugins/marketplaces/zcode-plugins-official/marketplace.json"), JSON.stringify({ plugins: [{ name: "browser-use", version: "0.4.2" }] })); fs.mkdirSync(path.join(home, ".zcode/cli/plugins/data/browser-use@zcode-plugins-official"), { recursive: true }); const pdir = path.join(home, ".zcode/plugins/zcode-tps"); fs.mkdirSync(path.join(pdir, ".zcode-plugin"), { recursive: true }); fs.writeFileSync(path.join(pdir, ".zcode-plugin/plugin.json"), JSON.stringify({ name: "zcode-tps", version: "0.4.1" })); fs.writeFileSync(path.join(pdir, "run.mjs"), "export const v=1;\n"); fs.writeFileSync(path.join(home, ".zcode/cli/config.json"), JSON.stringify({ mcp: { servers: { s1: { type: "stdio", command: "node" } } }, plugins: { dirs: [] } }, null, 2)); fs.writeFileSync(path.join(home, ".dsh/secrets/gitea-token.txt"), `tok-${name}-${"x".repeat(28)}\n`); fs.writeFileSync(path.join(home, ".ssh/ssh-profiles.json"), JSON.stringify({ profiles: {} })); return home; } function envFor(home, extra = {}) { const env = { ...process.env, ...GIT_ENV, USERPROFILE: home, HOME: home }; delete env.HOMEDRIVE; delete env.HOMEPATH; delete env.ZCODE_SYNC_ALLOW_SECRETS; return { ...env, ...extra }; } function run(script, { home, args = [], env = {} }) { const r = spawnSync(process.execPath, [path.join(SCRIPTS, script), ...args], { env: envFor(home, env), encoding: "utf8", timeout: 120000 }); return { code: r.status, out: r.stdout ?? "", err: r.stderr ?? "" }; } function git(args, opts = {}) { return execFileSync("git", args, { encoding: "utf8", ...GIT_ENV, ...opts }); } /** 造 N 个快照:每个改一处插件内容,保证 hash 不同、是可区分的历史 */ function seedSnapshots(home, bare, work, n) { const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; const names = []; for (let i = 0; i < n; i++) { fs.writeFileSync(path.join(home, ".zcode/plugins/zcode-tps/run.mjs"), `export const v=${i};\n`); const r = run("export.mjs", { home, env }); assert.equal(r.code, 0, `第 ${i} 次导出差错:\n${r.err}`); const latest = JSON.parse(fs.readFileSync(path.join(work, "repo/latest.json"), "utf8")); const name = path.basename(latest.snapshot); names.push(name); // 同一秒内连跑会撞名 → 撞了就等一秒 if (i < n - 1) execFileSync(process.execPath, ["-e", "setTimeout(()=>{},1100)"]); } return names; } /* ---------------- prune ---------------- */ test("prune:默认预演不删,保留 keep 个 + latest 钉住", { timeout: 180000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m1"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); const names = seedSnapshots(home, bare, work, 4); const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; // 预演:keep=2 → 保留最新 2 个,不删任何东西 const dry = run("prune.mjs", { home, args: ["--keep", "2"], env }); assert.equal(dry.code, 0, `预演应成功\n${dry.err}`); assert.match(dry.out, /预演,未删除/); assert.match(dry.out, /现有 4 个,保留 2 个,将删除 2 个/); const snapRoot = path.join(work, "repo/snapshots"); assert.equal(fs.readdirSync(snapRoot).length, 4, "预演不得真删"); // 实删 const app = run("prune.mjs", { home, args: ["--keep", "2", "--apply"], env }); assert.equal(app.code, 0, `实删应成功\n${app.err}`); assert.match(app.out, /实际删除 2 个/); assert.match(app.out, /已推送/); const left = fs.readdirSync(snapRoot); assert.equal(left.length, 2, `应剩 2 个,实际 ${left.length}`); // 保留的必须是最新的两个 for (const keep of names.slice(-2)) assert.ok(left.includes(keep), `应保留 ${keep}`); // latest.json 指向的必须还在(否则后续 status/import 会找不到) const latest = JSON.parse(fs.readFileSync(path.join(work, "repo/latest.json"), "utf8")); assert.ok(left.includes(path.basename(latest.snapshot)), "latest 指向的快照不得被删"); // 远端也少了 git(["--git-dir", bare, "log", "--oneline", "main"]); fs.rmSync(root, { recursive: true, force: true }); }); test("prune:latest 指向旧快照时,删完要重指到一个还活着的", { timeout: 180000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m2"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); const names = seedSnapshots(home, bare, work, 4); const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; // 手工把 latest.json 指回最老那个,再 keep=1 → 最老那个会被钉住不删 const snapRoot = path.join(work, "repo/snapshots"); const oldest = names[0]; fs.writeFileSync(path.join(work, "repo/latest.json"), JSON.stringify({ snapshot: `snapshots/${oldest}`, host: "x", createdAt: "2020-01-01T00:00:00Z" })); const r = run("prune.mjs", { home, args: ["--keep", "1", "--apply"], env }); assert.equal(r.code, 0, `应成功\n${r.err}`); const left = fs.readdirSync(snapRoot); assert.ok(left.includes(oldest), "被 latest 钉住的快照不得被删"); const latest = JSON.parse(fs.readFileSync(path.join(work, "repo/latest.json"), "utf8")); assert.ok(left.includes(path.basename(latest.snapshot)), "latest 必须指向还存在的快照"); fs.rmSync(root, { recursive: true, force: true }); }); test("prune:--exclude 钉住指定快照;参数非法要报错", { timeout: 180000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m3"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); const names = seedSnapshots(home, bare, work, 3); const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; const r = run("prune.mjs", { home, args: ["--keep", "1", "--exclude", names[0], "--apply"], env }); assert.equal(r.code, 0, `应成功\n${r.err}`); assert.ok(fs.readdirSync(path.join(work, "repo/snapshots")).includes(names[0]), "--exclude 钉住的应保留"); // 非法参数 for (const bad of [["--keep", "0"], ["--keep", "abc"], ["--nope"]]) { const b = run("prune.mjs", { home, args: bad, env }); assert.notEqual(b.code, 0, `应拒绝:${bad.join(" ")}`); } const h = run("prune.mjs", { home, args: ["--help"], env }); assert.equal(h.code, 0, "--help 应成功"); assert.match(h.out, /用法/); fs.rmSync(root, { recursive: true, force: true }); }); test("prune:快照为空时优雅退出,不报错", { timeout: 120000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m4"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); const r = run("prune.mjs", { home, env: { ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work } }); assert.equal(r.code, 0, `应优雅退出\n${r.err}`); assert.match(r.out, /远端尚无快照/); fs.rmSync(root, { recursive: true, force: true }); }); /* ---------------- verify ---------------- */ test("verify:完好的快照通过校验", { timeout: 180000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m5"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); seedSnapshots(home, bare, work, 1); const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; const r = run("verify.mjs", { home, env }); assert.equal(r.code, 0, `校验应通过\n${r.out}\n${r.err}`); assert.match(r.out, /# 快照校验:/); assert.match(r.out, /密钥束可解密/); assert.match(r.out, /自研插件 zcode-tps@0.4.1 hash 一致/); assert.match(r.out, /结论:快照完好|结论:快照可用/); assert.doesNotMatch(r.out, /✗/, "不应有失败项"); fs.rmSync(root, { recursive: true, force: true }); }); test("verify:内容被篡改 → 报 hash 不符并退出 1", { timeout: 180000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m6"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); seedSnapshots(home, bare, work, 1); const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; const snapRoot = path.join(work, "repo/snapshots"); const snap = path.join(snapRoot, fs.readdirSync(snapRoot)[0]); // 篡改插件内容,manifest 里的 hash 就不对了 fs.writeFileSync(path.join(snap, "custom-plugins/zcode-tps/run.mjs"), "export const v=999; // tampered\n"); const r = run("verify.mjs", { home, env }); assert.equal(r.code, 1, "篡改必须导致退出码 1"); assert.match(r.out, /内容 hash 不符|hash 不符/); assert.match(r.out, /结论:快照不可用/); fs.rmSync(root, { recursive: true, force: true }); }); test("verify:口令不对 → 明确报错,且不打印密钥值", { timeout: 180000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m7"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); seedSnapshots(home, bare, work, 1); const env = { ZCODE_SYNC_PASSPHRASE: "totally-wrong-passphrase", ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; const r = run("verify.mjs", { home, env }); assert.equal(r.code, 1, "解不开必须退出 1"); assert.match(r.out, /密钥束解不开/); assert.match(r.out, /口令不对/); // 绝不能把密文或密钥值打出来 assert.doesNotMatch(r.out, /tok-m7/, "不得打印密钥明文"); const enc = fs.readFileSync(path.join(work, "repo/snapshots", fs.readdirSync(path.join(work, "repo/snapshots"))[0], "secrets.enc"), "utf8"); assert.ok(!r.out.includes(enc.slice(20, 60)), "不得把密文片段打进输出"); fs.rmSync(root, { recursive: true, force: true }); }); test("verify --all:区分'可解密'与'口令不符',并给出可用版本", { timeout: 240000 }, () => { const root = sandboxRoot(); const homeA = makeMachine(root, "m8a"), homeB = makeMachine(root, "m8b"); const bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); // A 机用口令 1 导一个 const P1 = "passphrase-one-1111", P2 = "passphrase-two-2222"; const envA = { ZCODE_SYNC_PASSPHRASE: P1, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; assert.equal(run("export.mjs", { home: homeA, env: envA }).code, 0); execFileSync(process.execPath, ["-e", "setTimeout(()=>{},1100)"]); // A 机改用口令 2 再导一个(模拟换口令) const envA2 = { ZCODE_SYNC_PASSPHRASE: P2, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; assert.equal(run("export.mjs", { home: homeA, env: envA2 }).code, 0); // 用口令 1 扫全部:应有一个可解、一个不可解 const r = run("verify.mjs", { home: homeA, args: ["--all"], env: envA }); assert.equal(r.code, 0, `扫描应成功\n${r.err}`); assert.match(r.out, /全部快照扫描\(2 个\)/); assert.match(r.out, /可解密/, "应有一个可解密"); assert.match(r.out, /口令不符\/损坏/, "应有一个解不开"); assert.match(r.out, /当前口令可用:1 个/); assert.match(r.out, /latest.json 指向的 .* 用当前口令不可用/, "应提示 latest 不可用"); assert.match(r.out, /改用可用的那版:\/sync-import /, "应给出可用版本与命令"); fs.rmSync(root, { recursive: true, force: true }); }); /* ---------------- 推送隔离 ---------------- */ test("export:只推本次快照,不把别的会话未推送的快照连带推上去", { timeout: 180000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m15"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); seedSnapshots(home, bare, work, 1); const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; const repo = path.join(work, "repo"); // 冒充"另一个会话留下的、未推送的、用别的口令导出的快照" const stray = path.join(repo, "snapshots", "stray-20200101-000000"); fs.mkdirSync(stray, { recursive: true }); fs.writeFileSync(path.join(stray, "manifest.json"), JSON.stringify({ version: 1, host: "other", createdAt: "2020-01-01T00:00:00Z" })); fs.writeFileSync(path.join(stray, "secrets.enc"), JSON.stringify({ alg: "aes-256-gcm", kdf: "scrypt", salt: "x", iv: "y", tag: "z", data: "q" })); // 再导一次 const r = run("export.mjs", { home, env }); assert.equal(r.code, 0, `导出应成功\n${r.err}`); // 远端不该有这个 stray 快照 const remoteFiles = git(["--git-dir", bare, "ls-tree", "-r", "--name-only", "main"]); assert.doesNotMatch(remoteFiles, /stray-20200101-000000/, "别的会话的未推送快照不得被连带推上去"); assert.match(remoteFiles, /latest\.json/, "本次 latest.json 应推上去"); // 本地应留下它,并在输出里说明"落下了什么" assert.ok(fs.existsSync(stray), "本地那个快照不该被删(不是我们的东西)"); assert.match(r.out, /落下 \d+ 项非本次内容/, "应告知用户有内容被刻意落下"); assert.match(r.out, /stray-20200101-000000/, "应点名落下的是哪个"); fs.rmSync(root, { recursive: true, force: true }); }); test("export:远端 latest 指向的快照,其密钥束必须能用当前口令解开", { timeout: 180000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m16"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); seedSnapshots(home, bare, work, 1); const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; const repo = path.join(work, "repo"); // 从远端克隆一份,模拟"队友拿到最新状态" const clone = path.join(root, "teammate"); git(["clone", "-q", bare, clone]); const latest = JSON.parse(fs.readFileSync(path.join(clone, "latest.json"), "utf8")); const snap = path.join(clone, latest.snapshot); assert.ok(fs.existsSync(path.join(snap, "manifest.json")), "latest 指向的快照必须存在"); assert.ok(fs.existsSync(path.join(snap, "secrets.enc")), "latest 指向的快照必须有密钥束"); // 用团队口令应能解开 assert.doesNotThrow(() => decryptSecrets(fs.readFileSync(path.join(snap, "secrets.enc"), "utf8"), PASS), "队友用团队口令必须能解开 latest 的密钥束"); fs.rmSync(root, { recursive: true, force: true }); }); test("prune:删除也限定在 snapshots/ 范围内", { timeout: 180000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m17"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); seedSnapshots(home, bare, work, 3); const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; const repo = path.join(work, "repo"); // 放一个仓库外的杂项文件,不该被 prune 的提交带上去 fs.writeFileSync(path.join(repo, "scratch-note.txt"), "someone's temp file\n"); const r = run("prune.mjs", { home, args: ["--keep", "1", "--apply"], env }); assert.equal(r.code, 0, `清理应成功\n${r.err}`); const remoteFiles = git(["--git-dir", bare, "ls-tree", "-r", "--name-only", "main"]); assert.doesNotMatch(remoteFiles, /scratch-note\.txt/, "无关文件不得被连带提交"); assert.ok(fs.existsSync(path.join(repo, "scratch-note.txt")), "无关文件不该被删"); fs.rmSync(root, { recursive: true, force: true }); }); /* ---------------- doctor ---------------- */ test("doctor:环境就绪时全绿", { timeout: 180000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m9"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); seedSnapshots(home, bare, work, 1); const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; const r = run("doctor.mjs", { home, env }); assert.equal(r.code, 0, `应全绿\n${r.out}\n${r.err}`); assert.match(r.out, /# zcode-env-sync 自诊断/); assert.match(r.out, /✓ Node 版本/); assert.match(r.out, /✓ git 可用/); assert.match(r.out, /✓ 同步口令/); assert.match(r.out, /✓ 换行符锁定/); assert.match(r.out, /✓ core\.autocrlf:false/); assert.match(r.out, /✓ latest 可解密/); assert.match(r.out, /结论:一切就绪|结论:可正常工作/); assert.doesNotMatch(r.out, /✗/, "不应有 fail"); fs.rmSync(root, { recursive: true, force: true }); }); test("doctor:缺口令 → fail 并给修复建议,退出码 1", { timeout: 120000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m10"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); const r = run("doctor.mjs", { home, env: { ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work } }); assert.equal(r.code, 1, "缺口令应退出 1"); assert.match(r.out, /✗ 同步口令:未设置/); assert.match(r.out, /ZCODE_SYNC_PASSPHRASE/, "应给出修复建议"); assert.match(r.out, /结论:有 1 项|结论:有 \d+ 项必须先修/); fs.rmSync(root, { recursive: true, force: true }); }); test("doctor:口令解不开 latest → fail 并指向 verify --all", { timeout: 180000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m11"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); seedSnapshots(home, bare, work, 1); const r = run("doctor.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: "some-other-passphrase", ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work } }); assert.equal(r.code, 1, "解不开应退出 1"); assert.match(r.out, /✗ latest 可解密/); assert.match(r.out, /verify\.mjs --all/, "应指向 verify --all 去找可用版本"); fs.rmSync(root, { recursive: true, force: true }); }); test("doctor:origin 内嵌凭据 → 警告", { timeout: 180000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m12"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); seedSnapshots(home, bare, work, 1); // 手工把 token 塞进 origin,模拟历史遗留的不卫生配置 git(["-C", path.join(work, "repo"), "remote", "set-url", "origin", "https://sometoken@gitea.example.invalid/x.git"]); const r = run("doctor.mjs", { home, env: { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work } }); assert.match(r.out, /⚠ origin 地址/); assert.match(r.out, /内嵌了凭据/); fs.rmSync(root, { recursive: true, force: true }); }); /* ---------------- status --list ---------------- */ test("status --list:列出全部快照并标出 latest", { timeout: 180000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m13"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); const names = seedSnapshots(home, bare, work, 3); const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; const r = run("status.mjs", { home, args: ["--list"], env }); assert.equal(r.code, 0, `应成功\n${r.err}`); assert.match(r.out, /# 远端快照\(3 个/); for (const n of names) assert.ok(r.out.includes(n), `应列出 ${n}`); assert.match(r.out, /\* .*latest|标 \* 的是 latest/, "应标出 latest"); assert.match(r.out, /导入指定版本:\/sync-import/, "应告诉用户怎么导入指定版本"); // 无快照时优雅提示:换一个空裸仓(同一个仓 clone 下来仍有快照) const emptyBare = path.join(root, "empty.git"); git(["init", "--bare", "-b", "main", emptyBare]); const r2 = run("status.mjs", { home, args: ["--list"], env: { ...env, ZCODE_SYNC_GIT_REMOTE: emptyBare, ZCODE_SYNC_WORK: path.join(root, "w2") } }); assert.equal(r2.code, 0, `应优雅退出\n${r2.err}`); assert.match(r2.out, /远端尚无快照/); fs.rmSync(root, { recursive: true, force: true }); }); /* ---------------- manifest 体积与 enabledDetailed ---------------- */ test("export:manifest 记录体积与 inline 插件版本", { timeout: 180000 }, () => { const root = sandboxRoot(); const home = makeMachine(root, "m14"), bare = path.join(root, "bare.git"), work = path.join(root, "w"); git(["init", "--bare", "-b", "main", bare]); seedSnapshots(home, bare, work, 1); const env = { ZCODE_SYNC_PASSPHRASE: PASS, ZCODE_SYNC_GIT_REMOTE: bare, ZCODE_SYNC_WORK: work }; const latest = JSON.parse(fs.readFileSync(path.join(work, "repo/latest.json"), "utf8")); assert.ok(latest.bytes > 0, "latest.json 应记录体积"); const m = JSON.parse(fs.readFileSync(path.join(work, "repo", latest.snapshot, "manifest.json"), "utf8")); assert.ok(m.bytes > 0, "manifest 应记录体积"); // 体积应对得上(容一个小误差,manifest 自身写入会变大小) assert.ok(Math.abs(m.bytes - latest.bytes) < 4096, "manifest 与 latest 的体积应一致"); assert.ok(Array.isArray(m.enabledDetailed), "应记录 enabledDetailed"); assert.ok(m.enabledDetailed.length > 0, "enabledDetailed 不应为空"); const bu = m.enabledDetailed.find((e) => e.name === "browser-use"); assert.ok(bu, "应含 browser-use"); assert.equal(bu.version, "0.4.2", "市场插件版本应从清单查到"); assert.equal(bu.source, "zcode-plugins-official"); fs.rmSync(root, { recursive: true, force: true }); });