Files
nodeagent/tests/unit/version-write.test.mjs
T
jkooe 6adf11c85a fix(test): 两条守卫在 Windows 上必红(CRLF 与路径分隔符)
CI run 38380306200(提交 73ed06f)Windows 单元测试 319/321,macOS 全绿。

① 仓库内全部版本号互一致 → 报 undefined apps/console/src-tauri/Cargo.lock
   根因:actions/checkout 在 Windows 默认 core.autocrlf=true → CRLF,
   而 Cargo.lock 的版本提取把 \n 写死。**同一隐患也在生产代码里**:
   version-write.mjs 另有一条 name…\nversion… 正则读旧值,CRLF 下返回
   undefined → 把 Cargo.lock 记入 skipped 并跳过 → 发版后滞留旧版本
   (正是本模块要消灭的分叉,且不报错)。
   修法:读旧值收敛到 readCargoTomlVersion / setCargoLockVersion().before
   单一真源,删掉重复正则;新增 splitLineEol() 拆「正文+行尾」后改写
   (不能写 ("".*)$ 兜行尾 —— JS 的 . 不匹配 \r)。

② W-01 根因守卫 → 把唯一真源自己判成违规(util\quote.ts)
   根因:file.slice(dir.length+1) 在 Windows 得到反斜杠路径,与白名单
   的 util/quote.ts 比不中 → 守卫自己错。
   修法:新增 relPosix() 归一成 /。

新增 4 条守卫:Cargo.lock / Cargo.toml 的 CRLF 纯函数用例、
CRLF 工作区上真跑 writeVersionEverywhere 再读回文件、
以及用 path.win32 形态字符串在 macOS 上直接测 Windows 分支。
注入故障实测 4 条如期变红;test:unit 325/325(321 + 4)。
2026-10-13 18:57:04 +08:00

330 lines
15 KiB
JavaScript
Raw 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,
readCargoTomlVersion,
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"/);
});
// ---------------------------------------------------------------------------
// ②' 换行符无关性(CRLF)—— 2026-10-13 Windows CI 实证后补
//
// 为什么必须单列:Windows 上 `actions/checkout` 默认 `core.autocrlf=true`,
// 工作区文本是 CRLF。把 `\n` 写死进正则会在 CRLF 上**静默失配**,
// 而调用方普遍把 `undefined` 当成「没这个字段」→ **不报错、只是悄悄没改**。
// CI 报错原文:`undefined apps/console/src-tauri/Cargo.lock`(其余 10 处均为 2.0.3)。
// ---------------------------------------------------------------------------
test('Cargo.lock:CRLF 检出下同样读出旧值、改对目标 crate、换行符逐字节保真', () => {
const lf = 'version = 4\n\n[[package]]\nname = "nodeagent-console"\nversion = "2.0.3"\n\n[[package]]\nname = "statuses"\nversion = "2.0.2"\n';
const crlf = lf.replaceAll('\n', '\r\n');
// 读旧值:LF / CRLF 必须给出**同一个**结果(这正是 Windows 上失效的那一步)
assert.equal(setCargoLockVersion(lf, 'nodeagent-console', '9.9.9').before, '2.0.3');
assert.equal(
setCargoLockVersion(crlf, 'nodeagent-console', '9.9.9').before,
'2.0.3',
'CRLF 下读不出旧值 → 发版会静默跳过 Cargo.lock(Windows CI 实证失败点)',
);
const r = setCargoLockVersion(crlf, 'nodeagent-console', '9.9.9');
assert.equal(r.changed, true);
assert.match(r.text, /name = "nodeagent-console"\r\nversion = "9\.9\.9"\r\n/);
assert.match(r.text, /name = "statuses"\r\nversion = "2\.0\.2"/, '依赖版本不得被连带改掉');
assert.ok(!/[^\r]\n/.test(r.text), '换行符必须保持 CRLF —— 悄悄转成 LF 会污染整个文件的 diff');
});
test('Cargo.toml:CRLF 检出下 version 读写一致,且不碰非 [package] 段', () => {
const lf = [
'[package]',
'name = "nodeagent-console"',
'version = "2.0.3"',
'edition = "2021"',
'',
'[package.metadata.deb]',
'version = "2.0.3"',
'',
].join('\n');
const crlf = lf.replaceAll('\n', '\r\n');
assert.equal(readCargoTomlVersion(crlf), '2.0.3', 'CRLF 下必须读得出 [package] 的 version');
assert.equal(readCargoPackageName(crlf), 'nodeagent-console');
const out = setCargoTomlVersion(crlf, '9.9.9', 'Cargo.toml');
assert.match(out, /\[package\]\r\nname = "nodeagent-console"\r\nversion = "9\.9\.9"\r\n/);
assert.equal(out.match(/version = "2\.0\.3"/g)?.length, 1, '非 [package] 段那处必须原样保留');
assert.ok(!/[^\r]\n/.test(out), '换行符必须保持 CRLF');
});
// ---------------------------------------------------------------------------
// ③ 编排:对临时目录里的**真实文件**跑一遍
// ---------------------------------------------------------------------------
/**
* 铺一份「真实文件」的临时仓库。
*
* `crlf: true` 模拟 **Windows 检出**(`actions/checkout` 默认 `core.autocrlf=true`
* 会让工作区文本变成 CRLF)。2026-10-13 CI 实证:CRLF 下 Cargo.lock 的版本号
* 曾读出 `undefined`,生产代码据此**静默跳过**该文件 —— 必须能被本地复现(铁律 4)。
*/
function scaffold({ crlf = false } = {}) {
const eol = crlf ? '\r\n' : '\n';
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)}${eol}`);
writeFileSync(join(tauriDir, 'tauri.conf.json'), `${JSON.stringify({ productName: 'x', version: '2.0.2' }, null, 2)}${eol}`);
writeFileSync(
join(tauriDir, 'Cargo.toml'),
['[package]', 'name = "nodeagent-console"', 'version = "2.0.2"', 'edition = "2021"', ''].join(eol),
);
writeFileSync(
join(tauriDir, 'Cargo.lock'),
[
'version = 4',
'',
'[[package]]',
'name = "nodeagent-console"',
'version = "2.0.2"',
'',
'[[package]]',
'name = "statuses"',
'version = "2.0.2"',
'',
].join(eol),
);
// 必须真的进一次 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"/);
});
// ---------------------------------------------------------------------------
// ③' 编排层 + CRLF:这是**能打红生产缺陷**的那条守卫
//
// 前一条 CRLF 测试只证「纯函数对 CRLF 免疫」;但 Windows 上翻车的是**编排层**
// ——它曾另写一条正则读旧值,失配后把 Cargo.lock 记进 `skipped` 直接跳过。
// 所以必须在 CRLF 工作区上真跑一遍 `writeVersionEverywhere`,再读回文件断言。
// ---------------------------------------------------------------------------
test('CRLF 工作区(Windows 检出):四处版本号照样全改,且 skipped 必须为空', () => {
const dir = scaffold({ crlf: true });
const res = writeVersionEverywhere(dir, '9.9.9');
assert.deepEqual(
res.changed.map((c) => c.file).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,
`CRLF 下不得有跳过项(Windows CI 上 Cargo.lock 曾被静默跳过):${res.skipped.join(', ')}`,
);
const lock = readFileSync(join(dir, 'apps/console/src-tauri/Cargo.lock'), 'utf8');
assert.match(lock, /name = "nodeagent-console"\r\nversion = "9\.9\.9"/, 'Cargo.lock 必须真的被改到');
assert.match(lock, /name = "statuses"\r\nversion = "2\.0\.2"/, '依赖版本不得被连带改掉');
assert.ok(!/[^\r]\n/.test(lock), 'Cargo.lock 的换行符必须保持 CRLF');
const toml = readFileSync(join(dir, 'apps/console/src-tauri/Cargo.toml'), 'utf8');
assert.match(toml, /version = "9\.9\.9"/);
assert.ok(!/[^\r]\n/.test(toml), 'Cargo.toml 的换行符必须保持 CRLF');
});
// ---------------------------------------------------------------------------
// ④ 发版漂移守卫:真实仓库里 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);
}
// ⚠️ 读旧值一律走**生产函数**,不在这里另写一条正则。
// 2026-10-13 Windows CI 实证:本文件曾自己写 `name…\nversion…` 的正则,
// CRLF 检出上返回 undefined → 报「版本号分叉」;而**生产代码里有同一条正则**,
// 真实后果是发版时静默跳过 Cargo.lock(比测试变红严重得多)。
// 守卫与生产必须共用同一份解析,否则守卫只能验证「两份实现是否一致」。
for (const f of t.cargoTomlFiles) {
versions.set(f, readCargoTomlVersion(readFileSync(join(repoRoot, f), 'utf8')));
}
for (const f of t.cargoLockFiles) {
const { before } = setCargoLockVersion(
readFileSync(join(repoRoot, f), 'utf8'),
'nodeagent-console',
'__probe__',
);
versions.set(f, before);
}
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 变量',
);
});