Files
nodeagent/tests/unit/version-write.test.mjs
T
jkooe bce11514a9 fix(release): 发版脚本漏改 Cargo 清单导致版本号分叉
release.mjs patch --dry-run 实测:只改 8 个 package.json + 1 个 tauri.conf.json,而
apps/console/src-tauri/{Cargo.toml,Cargo.lock} 里**同样存着 2.0.2** —— 发版后必然滞留,
与 tauri.conf.json 悄悄分叉,且**不报错**(Tauri 打包以 tauri.conf.json 为准)。

- 版本写入抽成 scripts/lib/version-write.mjs(纯函数、可测),release.mjs 调用它。
  抽出来的理由:埋在脚本里时单测只能断言「源码里出现过某个字符串」—— 那种守卫注入
  故障都不会变红(铁律:守卫要落在最终产物字节上)。
- Cargo.toml **只在 [package] 段**改(非 package 段里也可能有独占一行的 version = "…");
  Cargo.lock **按 [[package]] 块 + crate 名定位**(全文盲替换会改坏 thiserror 2.0.21
  这类依赖版本)。
- release.mjs 的「脏文件白名单」改为取自**实际改写结果**(changed),不再手写文件名列表 ——
  手写的那份一旦与写入逻辑漂移,要么拦下本该放行的(发版卡死),要么放过不该放行的
  (静默推走改动)。
- 新增 tests/unit/version-write.test.mjs(9 条):在临时目录铺**真实文件**并 git init/git add
  (versionTargets 走 git ls-files,不真进索引就会假绿),跑完读回文件断言;另含一条
  **仓库版本号一致性守卫**,发版漏项会直接变红。
- 注入 4 处故障实测:编排漏 Cargo / Cargo.toml 全文盲替换 / Cargo.lock 按位置盲改 /
  仓库版本分叉 —— 全部变红,还原后全绿。
2026-10-13 17:16:46 +08:00

226 lines
10 KiB
JavaScript
Raw Permalink 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.
/**
* 版本号写入(`scripts/lib/version-write.mjs`)的行为守卫 + 发版漂移守卫。
*
* ⚠️ 背景(2026-10-13):`scripts/release.mjs` 原先只改 `package.json` + `tauri.conf.json`,
* 于是 `apps/console/src-tauri/{Cargo.toml,Cargo.lock}` 里的版本号**发版后会滞留**。
* `release.mjs patch --dry-run` 实证:报「8 个 package.json + 1 个 tauri.conf.json」,
* Cargo 两处**完全不在视野里** —— 而它们同样写着 2.0.2,会与 tauri.conf.json 悄悄分叉。
* 分叉不报错(Tauri 打包以 tauri.conf.json 为准)→ 属最难发现的漂移。
*
* 为什么这样测(铁律「守卫要落在最终产物字节上」):
* 首版若断言「release.mjs 源码里出现过 Cargo.toml 字样」,注入故障都不会变红。
* 本文件改为:在临时目录里铺**真实文件**(含 git init + git add,让 `git ls-files` 真的工作),
* 跑一遍写入,再**读回文件内容**断言 —— 这是能被注入故障打红的守卫。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
readCargoPackageName,
setCargoLockVersion,
setCargoTomlVersion,
versionTargets,
writeVersionEverywhere,
} from '../../scripts/lib/version-write.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
// ---------------------------------------------------------------------------
// ① 纯函数:Cargo.toml
// ---------------------------------------------------------------------------
test('Cargo.toml:只改 [package] 段的 version', () => {
const src = [
'[package]',
'name = "nodeagent-console"',
'version = "2.0.2"',
'edition = "2021"',
'',
'[build-dependencies]',
'tauri-build = { version = "2", features = [] }',
'',
// 关键陷阱:非 [package] 段里**同样存在** `version = "2.0.2"` 这种独占一行的写法。
// 不做段落限定的话,全文盲替换会把它一起改坏(TOML 合法,改完不报错)。
'[package.metadata.deb]',
'version = "2.0.2"',
'',
].join('\n');
const out = setCargoTomlVersion(src, '9.9.9', 'Cargo.toml');
assert.match(out, /\[package\]\nname = "nodeagent-console"\nversion = "9\.9\.9"\n/);
assert.equal(out.match(/version = "2\.0\.2"/g)?.length, 1, '非 [package] 段的那处必须原样保留');
assert.equal(out.match(/version = "9\.9\.9"/g)?.length, 1, '被改的只能有一处');
});
test('Cargo.toml:缺 [package] version 时大声失败,不静默跳过', () => {
assert.throws(() => setCargoTomlVersion('[package]\nname = "x"\n', '9.9.9', 'Cargo.toml'), /找不到 version 行/);
});
test('Cargo.toml:读到 crate 名(用于去 Cargo.lock 里定位同名包)', () => {
assert.equal(readCargoPackageName('[package]\nname = "nodeagent-console"\nversion = "2.0.2"\n'), 'nodeagent-console');
assert.equal(readCargoPackageName('[dependencies]\nname = "not-this"\n'), null);
});
// ---------------------------------------------------------------------------
// ② 纯函数:Cargo.lock
// ---------------------------------------------------------------------------
test('Cargo.lock:只改指定 crate 的版本,同名依赖版本一律不动', () => {
const src = [
'version = 4',
'',
'[[package]]',
'name = "nodeagent-console"',
'version = "2.0.2"',
'dependencies = [',
' "thiserror",',
']',
'',
'[[package]]',
'name = "proc-macro-crate"',
'version = "2.0.2"',
'',
'[[package]]',
'name = "statuses"',
'version = "2.0.2"',
'',
].join('\n');
const { text, changed } = setCargoLockVersion(src, 'nodeagent-console', '9.9.9');
assert.equal(changed, true);
assert.match(text, /name = "nodeagent-console"\nversion = "9\.9\.9"/);
assert.equal(
text.match(/version = "2\.0\.2"/g)?.length,
2,
'两个同名版本的依赖(proc-macro-crate / statuses)必须原样保留',
);
// 顶部那个 `version = 4`(lock 格式版本)也不能被动
assert.match(text, /^version = 4\n/);
});
test('Cargo.lock:找不到该 crate 时返回原文并报 changed=false(未构建的 checkout)', () => {
const { text, changed } = setCargoLockVersion('[[package]]\nname = "other"\nversion = "1.0.0"\n', 'nodeagent-console', '9.9.9');
assert.equal(changed, false);
assert.match(text, /name = "other"\nversion = "1\.0\.0"/);
});
// ---------------------------------------------------------------------------
// ③ 编排:对临时目录里的**真实文件**跑一遍
// ---------------------------------------------------------------------------
function scaffold() {
const dir = mkdtempSync(join(tmpdir(), 'na-version-'));
const tauriDir = join(dir, 'apps', 'console', 'src-tauri');
mkdirSync(tauriDir, { recursive: true });
writeFileSync(join(dir, 'package.json'), `${JSON.stringify({ name: 'nodeagent', version: '2.0.2' }, null, 2)}\n`);
writeFileSync(join(tauriDir, 'tauri.conf.json'), `${JSON.stringify({ productName: 'x', version: '2.0.2' }, null, 2)}\n`);
writeFileSync(
join(tauriDir, 'Cargo.toml'),
['[package]', 'name = "nodeagent-console"', 'version = "2.0.2"', 'edition = "2021"', ''].join('\n'),
);
writeFileSync(
join(tauriDir, 'Cargo.lock'),
[
'version = 4',
'',
'[[package]]',
'name = "nodeagent-console"',
'version = "2.0.2"',
'',
'[[package]]',
'name = "statuses"',
'version = "2.0.2"',
'',
].join('\n'),
);
// 必须真的进一次 git 索引 —— `versionTargets` 走 `git ls-files`,
// 只认「已入库文件」,否则临时目录里它会返回空集,测试就会假绿。
execFileSync('git', ['init', '-q'], { cwd: dir });
execFileSync('git', ['add', '-A'], { cwd: dir });
return dir;
}
test('四处版本号一起改:package.json + tauri.conf.json + Cargo.toml + Cargo.lock', () => {
const dir = scaffold();
const res = writeVersionEverywhere(dir, '9.9.9');
const files = res.changed.map((c) => c.file);
assert.deepEqual(files.sort(), [
'apps/console/src-tauri/Cargo.lock',
'apps/console/src-tauri/Cargo.toml',
'apps/console/src-tauri/tauri.conf.json',
'package.json',
].sort());
assert.equal(res.skipped.length, 0, `不该有跳过项:${res.skipped.join(', ')}`);
assert.equal(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).version, '9.9.9');
assert.equal(JSON.parse(readFileSync(join(dir, 'apps/console/src-tauri/tauri.conf.json'), 'utf8')).version, '9.9.9');
assert.match(readFileSync(join(dir, 'apps/console/src-tauri/Cargo.toml'), 'utf8'), /version = "9\.9\.9"/);
const lock = readFileSync(join(dir, 'apps/console/src-tauri/Cargo.lock'), 'utf8');
assert.match(lock, /name = "nodeagent-console"\nversion = "9\.9\.9"/);
assert.match(lock, /name = "statuses"\nversion = "2\.0\.2"/, '依赖版本不得被连带改掉');
// JSON 写回后仍要是「2 空格缩进 + 尾换行」(否则会污染 diff)
const rawPkg = readFileSync(join(dir, 'package.json'), 'utf8');
assert.ok(rawPkg.endsWith('}\n') && rawPkg.includes('\n "version"'), 'JSON 格式应与既有文件一致');
});
test('dry-run 一个字节都不改', () => {
const dir = scaffold();
const before = readFileSync(join(dir, 'apps/console/src-tauri/Cargo.toml'), 'utf8');
const res = writeVersionEverywhere(dir, '9.9.9', { dryRun: true });
assert.equal(res.changed.length, 4);
assert.equal(readFileSync(join(dir, 'apps/console/src-tauri/Cargo.toml'), 'utf8'), before);
assert.match(readFileSync(join(dir, 'package.json'), 'utf8'), /"version": "2\.0\.2"/);
});
// ---------------------------------------------------------------------------
// ④ 发版漂移守卫:真实仓库里 11 处版本号必须互一致
// ---------------------------------------------------------------------------
test('仓库内全部版本号互一致(发版漏项会在这里变红)', () => {
const t = versionTargets(repoRoot);
// 防「枚举失效 → 空集通过」的假绿:数量不对就直接失败
assert.equal(t.pkgFiles.length, 8, `package.json 应枚举到 8 个,实际 ${t.pkgFiles.length}`);
assert.equal(t.tauriConfFiles.length, 1, 'tauri.conf.json 应枚举到 1 个');
assert.equal(t.cargoTomlFiles.length, 1, 'Cargo.toml 应枚举到 1 个');
assert.equal(t.cargoLockFiles.length, 1, 'Cargo.lock 应枚举到 1 个');
const versions = new Map();
for (const f of [...t.pkgFiles, ...t.tauriConfFiles]) {
versions.set(f, JSON.parse(readFileSync(join(repoRoot, f), 'utf8')).version);
}
for (const f of t.cargoTomlFiles) {
const m = /\[package\][\s\S]*?^version\s*=\s*"([^"]*)"/m.exec(readFileSync(join(repoRoot, f), 'utf8'));
versions.set(f, m?.[1]);
}
for (const f of t.cargoLockFiles) {
const m = /name\s*=\s*"nodeagent-console"\nversion\s*=\s*"([^"]*)"/.exec(readFileSync(join(repoRoot, f), 'utf8'));
versions.set(f, m?.[1]);
}
const distinct = new Set(versions.values());
const dump = [...versions].map(([f, v]) => ` ${v} ${f}`).join('\n');
assert.equal(distinct.size, 1, `版本号出现分叉(发版时漏改了某个清单):\n${dump}`);
assert.ok(!distinct.has(undefined), `有清单读不出版本号:\n${dump}`);
});
test('发版脚本用「实际改写清单」做脏文件白名单,不得回退成手写文件名列表', () => {
const src = readFileSync(join(repoRoot, 'scripts', 'release.mjs'), 'utf8');
assert.match(src, /writeVersionEverywhere/, 'release.mjs 必须走 lib/version-write.mjs');
assert.match(
src,
/const allowedDirty = new Set\(changed\.map/,
'白名单必须取自写入结果(手写列表会与写入逻辑漂移)',
);
assert.doesNotMatch(
src,
/allowedDirty = new Set\(\[\.\.\.pkgFiles/,
'不得再引用已删除的 pkgFiles/tauriConfFiles 变量',
);
});