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)。
This commit is contained in:
1 parent
73ed06f5f1
commit
6adf11c85a
4 files changed
+257
-29
No files matched your search
@@ -375,6 +375,44 @@ e2e 新增的 ACL / 畸形报文用例有个必须记住的姿势:**每个场
|
||||
> (`.msi` / NSIS);直接重启已装的旧版控制台只是**临时绕开**(进程内缓存被清空 → 重新拉起 sidecar),
|
||||
> 症状会消失,但下次 sidecar 再退出仍会复发。
|
||||
|
||||
### 修复 —— 两条守卫在 Windows 上必红(平台差异:CRLF 与路径分隔符)
|
||||
|
||||
CI 实证(run `38380306200`,提交 `73ed06f`):`Windows 真机验证 → 单元测试` **319/321**,
|
||||
macOS 上同样两条却是绿的 —— 典型的「只在 Windows 暴露」。两条各有明确根因:
|
||||
|
||||
**① `仓库内全部版本号互一致`:CRLF 把版本号读成 `undefined`**
|
||||
|
||||
报错原文:`undefined apps/console/src-tauri/Cargo.lock`(其余 10 处均为 `2.0.3`,`2 !== 1`)。
|
||||
根因:Windows 上 `actions/checkout` 默认 `core.autocrlf=true`,工作区文本是 **CRLF**,
|
||||
而 `Cargo.lock` 的版本提取把 `\n` 写死了。更严重的是 —— **同一处隐患也在生产代码里**:
|
||||
`version-write.mjs` 的写入路径另有一条 `name…\nversion…` 正则读旧值,CRLF 下同样返回 `undefined`,
|
||||
于是它把 Cargo.lock 当成「没找到该 crate」记入 `skipped` 并**跳过** → 发版后该文件滞留旧版本,
|
||||
**正是本模块要消灭的那类分叉,且不报错**。
|
||||
|
||||
- 收敛为**单一真源**:读旧值改走 `readCargoTomlVersion()` / `setCargoLockVersion().before`,
|
||||
删掉那条会漂移的重复正则。
|
||||
- 新增 `splitLineEol()`:写值前先把行尾拆下来、改完正文再拼回去 ——
|
||||
不能偷懒写 `(".*)$` 兜行尾,因为 **JS 的 `.` 不匹配 `\r`**(与 `\n` 同属行终止符),照样失配。
|
||||
- 读值一律 `split('\n')` 后对 `line.trim()` 匹配 → LF / CRLF 结果一致,写回**逐字节保真**。
|
||||
- 新增 3 条守卫:Cargo.lock / Cargo.toml 纯函数级 CRLF 用例 + **CRLF 工作区上真跑一遍
|
||||
`writeVersionEverywhere` 并读回文件断言**(注入故障后 3 条如期变红)。
|
||||
|
||||
**② `W-01 根因守卫:PowerShell 单引号字面量必须来自 util/quote.ts`:把唯一真源自己判成违规**
|
||||
|
||||
报错原文:`以下文件手写了 PS 单引号转义:util\quote.ts`。
|
||||
根因:守卫用 `file.slice(dir.length + 1)` 得相对路径,Windows 上是 `util\quote.ts`,
|
||||
与白名单里的 `util/quote.ts` 比不中 → **守卫自己错**(铁律 3 的第 ② 类),macOS 上复现不出。
|
||||
|
||||
- 新增 `relPosix()` 把相对路径统一归一成 `/`;并补一条**用 `path.win32` 形态字符串在 macOS 上
|
||||
直接测掉 Windows 分支**的用例 —— 跨平台差异不必真上 Windows 才能验(铁律 4)。
|
||||
|
||||
回归:`test:unit` **325/325**(321 + 新增 4),注入故障实测 4 条守卫如期变红、还原逐字节一致。
|
||||
|
||||
> 附带发现(不构成缺陷,但值得记档):控制台随包的 `agent.mjs` 与发布版 `agent.mjs`
|
||||
> **逐行一致、同报 2.0.3**,唯二差异是 esbuild 的模块相对路径串与内嵌 commit 常量
|
||||
> (随包 `73ed06f` / 发布版 `a1c51d6`)—— 源于两次构建的**基准目录不同**。
|
||||
> 因此二者 **sha256 必然不同**,不能用发布清单的哈希去校验控制台播种出来的那份。
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.2] —— 2026-10-11
|
||||
|
||||
@@ -11,6 +11,17 @@
|
||||
* 铁律「守卫要落在**最终产物字节**上」。若版本写入逻辑埋在 `release.mjs` 里,
|
||||
* 单测只能断言「源码里出现过某个字符串」—— 那种守卫注入故障都不会变红。
|
||||
* 抽成纯函数后,测试可以对**临时目录里的真实文件**跑一遍再断言文件内容。
|
||||
*
|
||||
* ⚠️ 换行符纪律(2026-10-13 Windows CI 实证后补):
|
||||
* Windows 上 `actions/checkout` 默认 `core.autocrlf=true`,检出的文本是 **CRLF**。
|
||||
* 因此本模块内**一律不得**对原文跑把 `\n` 写死的正则 —— 那种写法在 CRLF 检出上会
|
||||
* **静默返回 `undefined`**,而调用方普遍把它当成「没这个字段」直接跳过:不报错、
|
||||
* 只是悄悄没改(正是本模块要消灭的那类漂移)。
|
||||
* 统一做法:
|
||||
* ① **读**值一律 `split('\n')` 后对 `line.trim()` 匹配(`\r` 被 `trim()` 吃掉);
|
||||
* ② **写**值先用 `splitLineEol()` 把行尾拆下来、改完正文再拼回去。
|
||||
* (不能偷懒写 `(".*)$` 兜行尾 —— JS 的 `.` **不匹配 `\r`**,那样照样失配。)
|
||||
* 两条合起来保证:LF / CRLF 两种检出**结果一致**,且写回**逐字节保真**。
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -47,6 +58,18 @@ export function versionTargets(root = process.cwd()) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一行拆成「正文 + 行尾」—— 行尾可能是 `\r`(CRLF 检出)或空串。
|
||||
*
|
||||
* ⚠️ 为什么必须拆:JS 里 `.` **不匹配 `\r`**(`\r` 与 `\n` 同属行终止符),
|
||||
* 所以 `^(\s*version\s*=\s*")[^"]*(".*)$` 这类写法在 `version = "2.0.3"\r`
|
||||
* 上会**直接失配** —— 行没改成、`done` 也没置位,全程不报错。
|
||||
* 2026-10-13 本地复现(CRLF 文本喂给纯函数)即命中此处。
|
||||
*/
|
||||
function splitLineEol(line) {
|
||||
return line.endsWith('\r') ? [line.slice(0, -1), '\r'] : [line, ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* 改 `Cargo.toml` 的版本号 —— **只在 `[package]` 段内**改。
|
||||
*
|
||||
@@ -67,16 +90,33 @@ export function setCargoTomlVersion(text, version, label = 'Cargo.toml') {
|
||||
}
|
||||
if (!inPackage || done) continue;
|
||||
if (/^version\s*=/.test(trimmed)) {
|
||||
lines[i] = lines[i].replace(/^(\s*version\s*=\s*")[^"]*(".*)$/, `$1${version}$2`);
|
||||
done = true;
|
||||
const [body, eol] = splitLineEol(lines[i]);
|
||||
const m = /^(\s*version\s*=\s*")([^"]*)(".*)$/.exec(body);
|
||||
if (m) {
|
||||
lines[i] = `${m[1]}${version}${m[3]}${eol}`;
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!done) throw new Error(`${label} 的 [package] 段里找不到 version 行`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** 从 `Cargo.toml` 的 `[package]` 段读 crate 名(用于去 `Cargo.lock` 里定位同一个包)。 */
|
||||
export function readCargoPackageName(text) {
|
||||
/** 从 `Cargo.toml` 的 `[package]` 段读版本号(读不到返回 `undefined`)。 */
|
||||
export function readCargoTomlVersion(text) {
|
||||
return packageSectionValue(text, 'version');
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 `[package]` 段内取某个 `key = "值"` 的值。
|
||||
*
|
||||
* ⚠️ 一律走 `line.trim()` 再匹配(而不是对原文跑带 `$` / `\n` 的正则)——
|
||||
* **这样换行符就与结果无关**:CRLF 的 `\r` 被 `trim()` 吃掉,
|
||||
* LF / CRLF 两种检出得到同一结果。2026-10-13 Windows CI 实证:
|
||||
* 写死 `\n` 的提取在 CRLF 检出上**静默返回 undefined**,
|
||||
* 而调用方把它当成「没有这个字段」直接跳过 —— 不报错、只是悄悄没改。
|
||||
*/
|
||||
function packageSectionValue(text, key) {
|
||||
const lines = text.split('\n');
|
||||
let inPackage = false;
|
||||
for (const line of lines) {
|
||||
@@ -86,10 +126,15 @@ export function readCargoPackageName(text) {
|
||||
continue;
|
||||
}
|
||||
if (!inPackage) continue;
|
||||
const m = /^name\s*=\s*"([^"]*)"$/.exec(trimmed);
|
||||
const m = new RegExp(`^${key}\\s*=\\s*"([^"]*)"$`).exec(trimmed);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 从 `Cargo.toml` 的 `[package]` 段读 crate 名(用于去 `Cargo.lock` 里定位同一个包)。 */
|
||||
export function readCargoPackageName(text) {
|
||||
return packageSectionValue(text, 'name') ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,6 +149,7 @@ export function setCargoLockVersion(text, pkgName, version) {
|
||||
const lines = text.split('\n');
|
||||
let currentName = null;
|
||||
let done = false;
|
||||
let before;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed === '[[package]]') {
|
||||
@@ -116,11 +162,18 @@ export function setCargoLockVersion(text, pkgName, version) {
|
||||
continue;
|
||||
}
|
||||
if (currentName === pkgName && !done && /^version\s*=/.test(trimmed)) {
|
||||
lines[i] = lines[i].replace(/^(\s*version\s*=\s*")[^"]*(".*)$/, `$1${version}$2`);
|
||||
done = true;
|
||||
// 先拆掉行尾再改正文,改完把原行尾接回去 → CRLF 检出下换行符**逐字节保真**,
|
||||
// 不会把 Windows 工作区悄悄转成 LF(那会污染整个文件的 diff)。
|
||||
const [body, eol] = splitLineEol(lines[i]);
|
||||
const m = /^(\s*version\s*=\s*")([^"]*)(".*)$/.exec(body);
|
||||
if (m) {
|
||||
before = m[2];
|
||||
lines[i] = `${m[1]}${version}${m[3]}${eol}`;
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { text: lines.join('\n'), changed: done };
|
||||
return { text: lines.join('\n'), changed: done, before };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,28 +207,31 @@ export function writeVersionEverywhere(root, version, { dryRun = false } = {}) {
|
||||
}
|
||||
|
||||
// ---- Cargo.toml + Cargo.lock:成对处理(lock 里要按 crate 名定位) ----
|
||||
//
|
||||
// ⚠️ 「读旧值」与「写新值」**必须共用同一份实现**(`readCargoTomlVersion` /
|
||||
// `setCargoLockVersion`)。早先这里另写了一条 `name…\nversion…` 的正则读旧值,
|
||||
// 那与写入实现是**两份会各自漂移的解析**:2026-10-13 Windows CI 实证,
|
||||
// CRLF 检出上那条正则返回 `undefined` → 本函数把它当成「没找到该 crate」记入
|
||||
// `skipped` 并**跳过 Cargo.lock**,发版后该文件滞留旧版本 ——
|
||||
// 正是本模块要消灭的那类分叉,而且**不报错**。
|
||||
for (const rel of targets.cargoTomlFiles) {
|
||||
const abs = join(root, rel);
|
||||
const text = readFileSync(abs, 'utf8');
|
||||
const before = /\[package\][\s\S]*?^version\s*=\s*"([^"]*)"/m.exec(text)?.[1];
|
||||
const crateName = readCargoPackageName(text);
|
||||
changed.push({ file: rel, before: before ?? '?', kind: 'cargo-toml' });
|
||||
changed.push({ file: rel, before: readCargoTomlVersion(text) ?? '?', kind: 'cargo-toml' });
|
||||
if (!dryRun) writeFileSync(abs, setCargoTomlVersion(text, version, rel));
|
||||
|
||||
if (!crateName) continue;
|
||||
const lockRel = targets.cargoLockFiles.find((f) => f.startsWith(rel.slice(0, rel.length - 'Cargo.toml'.length)));
|
||||
if (!lockRel) continue;
|
||||
const lockAbs = join(root, lockRel);
|
||||
const lockText = readFileSync(lockAbs, 'utf8');
|
||||
const lockBefore = new RegExp(`name\\s*=\\s*"${crateName}"\\nversion\\s*=\\s*"([^"]*)"`).exec(lockText)?.[1];
|
||||
if (lockBefore === undefined) {
|
||||
const lockRes = setCargoLockVersion(readFileSync(lockAbs, 'utf8'), crateName, version);
|
||||
if (lockRes.before === undefined) {
|
||||
skipped.push(`${lockRel}(未找到 crate ${crateName},可能尚未构建)`);
|
||||
continue;
|
||||
}
|
||||
changed.push({ file: lockRel, before: lockBefore, kind: 'cargo-lock' });
|
||||
if (dryRun) continue;
|
||||
const result = setCargoLockVersion(lockText, crateName, version);
|
||||
if (result.changed) writeFileSync(lockAbs, result.text);
|
||||
changed.push({ file: lockRel, before: lockRes.before, kind: 'cargo-lock' });
|
||||
if (!dryRun && lockRes.changed) writeFileSync(lockAbs, lockRes.text);
|
||||
}
|
||||
|
||||
return { changed, skipped };
|
||||
|
||||
@@ -108,6 +108,18 @@ function walk(dir) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 相对路径一律归一成 `/` 分隔 —— 否则「白名单」在 Windows 上必然比不中。
|
||||
*
|
||||
* ⚠️ 2026-10-13 Windows CI 实证:`file.slice(dir.length + 1)` 在 Windows 上得到
|
||||
* `util\quote.ts`,与白名单里的 `util/quote.ts` 不等 → **唯一真源自己被判成违规**
|
||||
* (报错原文:`以下文件手写了 PS 单引号转义:util\quote.ts`)。
|
||||
* 守卫自己错(铁律 3 的第 ② 类),而 macOS 上 100% 复现不出 —— 平台差异必须收敛掉。
|
||||
*/
|
||||
function relPosix(dir, file) {
|
||||
return file.slice(dir.length + 1).replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉注释后再扫。
|
||||
*
|
||||
@@ -136,7 +148,7 @@ test('W-01 根因守卫:源文件里不得出现 `${JSON.stringify(…)}` 拼
|
||||
];
|
||||
const offenders = [];
|
||||
for (const file of walk(AGENT_SRC)) {
|
||||
const rel = file.slice(AGENT_SRC.length + 1);
|
||||
const rel = relPosix(AGENT_SRC, file);
|
||||
const lines = stripComments(readFileSync(file, 'utf8')).split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
if (!line.includes('${JSON.stringify(')) return;
|
||||
@@ -156,7 +168,7 @@ test('W-01 根因守卫:PowerShell 单引号字面量必须来自 util/quote.t
|
||||
const ALLOWED = new Set(['util/quote.ts', 'capabilities/network.ts']);
|
||||
const offenders = [];
|
||||
for (const file of walk(AGENT_SRC)) {
|
||||
const rel = file.slice(AGENT_SRC.length + 1);
|
||||
const rel = relPosix(AGENT_SRC, file);
|
||||
if (ALLOWED.has(rel)) continue;
|
||||
const text = stripComments(readFileSync(file, 'utf8'));
|
||||
// 手写 `replace(/'/g, "''")` 就是「又发明了一份 psLit」——两份实现必然漂移
|
||||
@@ -166,3 +178,21 @@ test('W-01 根因守卫:PowerShell 单引号字面量必须来自 util/quote.t
|
||||
}
|
||||
assert.deepEqual(offenders, [], `以下文件手写了 PS 单引号转义,应改用 util/quote.ts:\n${offenders.join('\n')}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 守卫自身的可信度:路径归一
|
||||
//
|
||||
// 前面两条守卫都靠「相对路径 = 白名单里的字面量」来判断。若这一步在 Windows 上
|
||||
// 比不中,守卫就会**冤枉唯一真源自己**(CI 原文:`以下文件手写了 PS 单引号转义:util\quote.ts`)。
|
||||
// 这里用 `path.win32` 形态的字符串在 macOS 上直接把 Windows 分支测掉 ——
|
||||
// 跨平台差异不必真上 Windows 才能验(铁律 4)。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('守卫自身的路径归一:Windows 反斜杠必须归一成 /(否则白名单比不中)', () => {
|
||||
const winDir = 'C:\\x\\apps\\agent\\src';
|
||||
assert.equal(relPosix(winDir, 'C:\\x\\apps\\agent\\src\\util\\quote.ts'), 'util/quote.ts');
|
||||
assert.equal(relPosix(winDir, 'C:\\x\\apps\\agent\\src\\capabilities\\network.ts'), 'capabilities/network.ts');
|
||||
|
||||
const posixDir = '/x/apps/agent/src';
|
||||
assert.equal(relPosix(posixDir, '/x/apps/agent/src/util/quote.ts'), 'util/quote.ts');
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
readCargoPackageName,
|
||||
readCargoTomlVersion,
|
||||
setCargoLockVersion,
|
||||
setCargoTomlVersion,
|
||||
versionTargets,
|
||||
@@ -107,19 +108,77 @@ test('Cargo.lock:找不到该 crate 时返回原文并报 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');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ③ 编排:对临时目录里的**真实文件**跑一遍
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function scaffold() {
|
||||
/**
|
||||
* 铺一份「真实文件」的临时仓库。
|
||||
*
|
||||
* `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)}\n`);
|
||||
writeFileSync(join(tauriDir, 'tauri.conf.json'), `${JSON.stringify({ productName: 'x', version: '2.0.2' }, null, 2)}\n`);
|
||||
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('\n'),
|
||||
['[package]', 'name = "nodeagent-console"', 'version = "2.0.2"', 'edition = "2021"', ''].join(eol),
|
||||
);
|
||||
writeFileSync(
|
||||
join(tauriDir, 'Cargo.lock'),
|
||||
@@ -134,7 +193,7 @@ function scaffold() {
|
||||
'name = "statuses"',
|
||||
'version = "2.0.2"',
|
||||
'',
|
||||
].join('\n'),
|
||||
].join(eol),
|
||||
);
|
||||
// 必须真的进一次 git 索引 —— `versionTargets` 走 `git ls-files`,
|
||||
// 只认「已入库文件」,否则临时目录里它会返回空集,测试就会假绿。
|
||||
@@ -177,6 +236,43 @@ test('dry-run 一个字节都不改', () => {
|
||||
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 处版本号必须互一致
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -194,13 +290,21 @@ test('仓库内全部版本号互一致(发版漏项会在这里变红)', ()
|
||||
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) {
|
||||
const m = /\[package\][\s\S]*?^version\s*=\s*"([^"]*)"/m.exec(readFileSync(join(repoRoot, f), 'utf8'));
|
||||
versions.set(f, m?.[1]);
|
||||
versions.set(f, readCargoTomlVersion(readFileSync(join(repoRoot, f), 'utf8')));
|
||||
}
|
||||
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 { before } = setCargoLockVersion(
|
||||
readFileSync(join(repoRoot, f), 'utf8'),
|
||||
'nodeagent-console',
|
||||
'__probe__',
|
||||
);
|
||||
versions.set(f, before);
|
||||
}
|
||||
|
||||
const distinct = new Set(versions.values());
|
||||
|
||||
Reference in new issue
Block a user