Files
MailUI4Agents/client/electron/test/web-comment-only.test.mjs
JianFeeeee 2f17f62871 feat(deploy): 前端源码更新「仅为注释」时不再卡住部署
## 起因(实测,同一天两次)

`redeploy-gateway.sh` 用 mtime 比对源码与 dist:

    newer=$(find src -type f -newer dist/index.html)
    [ -n "$newer" ] && exit 1

mtime 只说「这个文件被碰过」,不说「它变了什么」。于是共享工作树里**任何人
改一行注释就会拦下部署** —— 那行注释不进 bundle,重建产物与现有 dist
逐字节相同。

2026-10-02 实测被拦两次,都是 `client/electron/src/api/client.ts` 的线程树
说明注释(另一会话的工作树在制品,未提交),每次多花一轮 `npm run build`。

## 为什么不是拆掉那道闸

2026-09-14 踩过它的来历:改了 `src/lib/appearance.ts` 的请求路径却没跑
vite build,部署照样「同步成功」,出去的还是旧 bundle —— 表现为接口 404
(`/api/v1/api/v1/…` 双前缀),而**所有单测都是绿的**。那种失败是沉默的,
所以闸不能拆。

本改动是在闸**前面**加一层精化:先问「差异是否只是注释」,是则放行并说明,
否则维持拦截。拿不准时一律偏向拦截:

    误拦的代价 = 多构建一次(几十秒)
    误放的代价 = 把旧界面打进二进制(接口 404,且没人立刻归因)

## 判定口径(deploy/web-comment-only.mjs)

对每个「比 dist 新」的文件取它相对 **HEAD** 的 diff,去掉 `---`/`+++` 头、
diff 元信息与整行注释后若还剩内容 ⇒ 真改动 ⇒ 拦截。

- **只看未提交差异**(`git diff HEAD --`)。已提交改动早于本次部署决策。
- **未跟踪的新文件**按真改动处理 —— 判不出就别放行。
- 滤的是「整行都是注释」的行;行尾注释(`code(); // 注释`)算真改动(保守)。
- 块注释中间行(` * …`)与结尾也算注释。

## 判据(7 格)

`client/electron/test/web-comment-only.test.mjs`。判据本身必须能区分
「仅注释」与「真代码」,所以每格都给**两侧**对照。其中「只有注释差异」
那格用的 diff **逐字取自当天实测的 git diff**。

**变异验证**:

    去掉注释分支(所有行算真改动)  → 红 7(部署会被那一行注释继续拦)
    isCodeChange 恒 false           → 红 7(把 2026-09-14 的静默失败放回来)

第二条是关键:它证明这套判据不会为了「少拦一次」而牺牲那道沉默失败的闸。

## 真实场景验证(不是只跑单测)

    把 dist/index.html 时间戳改早 → 5 个源文件「比 dist 新」
      ⇒ commentOnly=true,理由写明「差异仅为注释」
    临时往 sse.ts 追加一行真代码
      ⇒ commentOnly=false,理由点名那行 `+const __probe = 1;`
    bash deploy/redeploy-gateway.sh --dry-run
      ⇒ [WARN] 前端源码被更新,但差异**仅为注释** ⇒ 不重建

## 顺带

`find … | head -20`(原 head -3):文件多时不至于只看到前 3 个就下结论。
2026-10-02 13:58:42 +08:00

142 lines
6.1 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.

/**
* `deploy/web-comment-only.mjs` 的判据。
*
* ★ 这条判据本身必须能区分「仅注释」与「真代码」——
* 一条分不清的判据等于没有判据。所以下面每格都给**两侧**的对照:
* 注释 ⇒ 放行,代码 ⇒ 拦截。变异验证见文件末尾注释。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classifySourceChanges, isCodeChange } from '../../../deploy/web-comment-only.mjs';
/** 假 run:按 map 返回预置 stdout。 */
const fakeRun = (outputs) => (cmd) => {
for (const [key, value] of Object.entries(outputs)) {
if (cmd.join(' ').includes(key)) return { stdout: value, code: 0 };
}
return { stdout: '', code: 0 };
};
// ── 行级:什么算「真改动」 ──────────────────────────────
test('★ 行级判据:注释行不算改动,代码行算', () => {
// 注释(这三类都放过)
assert.equal(isCodeChange('+ * 自线索根整树 BFS 取一块'), false, '块注释中间行');
assert.equal(isCodeChange('- * dir=around 首屏'), false, '块注释行');
assert.equal(isCodeChange('+ // 说明一下'), false, '行注释(缩进)');
assert.equal(isCodeChange('+ */'), false, '块注释结尾');
assert.equal(isCodeChange('+'), false, '纯增行空行');
assert.equal(isCodeChange('-'), false, '纯删行空行');
// 文件头与元信息
assert.equal(isCodeChange('--- a/client/electron/src/api/client.ts'), false);
assert.equal(isCodeChange('+++ b/client/electron/src/api/client.ts'), false);
assert.equal(isCodeChange('@@ -1,3 +1,3 @@'), false);
// 代码(这些必须拦住)
assert.equal(isCodeChange('+const x = 1;'), true);
assert.equal(isCodeChange('- const base = url;'), true);
assert.equal(isCodeChange('+ const base = url; // 带行尾注释也算真改动'), true,
'行尾注释不改变「这行有代码」的事实');
});
// ── 文件级:只有注释 ⇒ 放行 ─────────────────────────────
test('★ 只有注释差异 ⇒ 放行(这正是 2026-10-02 拦了两次部署的那个形状)', () => {
// 逐字取自当天实测的 git diff:client/electron/src/api/client.ts
const diff = [
'diff --git a/client/electron/src/api/client.ts b/client/electron/src/api/client.ts',
'index 1111111..2222222 100644',
'--- a/client/electron/src/api/client.ts',
'+++ b/client/electron/src/api/client.ts',
'@@ -40,1 +40,1 @@',
'- * dir=around 首屏(锚点 + 部分祖先 + 部分子孙),up/down 配 offset 增量加载。',
'+ * 自线索根整树 BFS 取一块,offset 传上次返回的 next_offset 继续往后取。'
].join('\n');
const r = classifySourceChanges(
['client/electron/src/api/client.ts'],
'/repo',
fakeRun({ 'ls-files': '', 'diff': diff })
);
assert.equal(r.commentOnly, true, `应放行,实际 reason=${r.reason}`);
assert.match(r.reason, /仅为注释/);
});
// ── 文件级:有代码 ⇒ 拦截 ───────────────────────────────
test('★ 有非注释改动 ⇒ 拦截(否则旧界面会被嵌进二进制)', () => {
const diff = [
'--- a/client/electron/src/api/client.ts',
'+++ b/client/electron/src/api/client.ts',
'@@ -10,1 +10,1 @@',
'+ * 顺手加了句说明',
'+const base = url.replace(/\\/+$/, "");'
].join('\n');
const r = classifySourceChanges(
['client/electron/src/api/client.ts'],
'/repo',
fakeRun({ 'ls-files': '', 'diff': diff })
);
assert.equal(r.commentOnly, false, `应拦截,实际 reason=${r.reason}`);
assert.match(r.reason, /非注释改动/);
});
// ★ 这一格是 2026-09-14 那个沉默失败的守门:改了请求路径却没重建 ⇒
// 接口 404(/api/v1/api/v1/…)而单测全绿。
test('★ 单行代码改动也必须拦截(只改一个字母那种)', () => {
const diff = [
'--- a/client/electron/src/lib/appearance.ts',
'+++ b/client/electron/src/lib/appearance.ts',
'@@ -3,1 +3,1 @@',
'-const API = "/api/v1";',
'+const API = "/api/v1";'
].join('\n');
const r = classifySourceChanges(
['client/electron/src/lib/appearance.ts'],
'/repo',
fakeRun({ 'ls-files': '', 'diff': diff })
);
assert.equal(r.commentOnly, false, '单行改动也必须重建');
});
// ── 拿不准时一律拦 ─────────────────────────────────────
test('★ 新增未跟踪文件 ⇒ 拦截(判不出是否注释就别放行)', () => {
const r = classifySourceChanges(
['client/electron/src/new/Thing.ts'],
'/repo',
fakeRun({ 'ls-files': 'client/electron/src/new/Thing.ts' })
);
assert.equal(r.commentOnly, false);
assert.match(r.reason, /未跟踪/);
});
test('★ 没有文件更新 ⇒ 放行(空集不该拦)', () => {
const r = classifySourceChanges([], '/repo', fakeRun({}));
assert.equal(r.commentOnly, true);
});
// ── 路径规范化:绝对路径要能转成仓库相对 ───────────────
test('绝对路径会被转成仓库相对(find 给的是绝对路径)', () => {
const r = classifySourceChanges(
['/repo/client/electron/src/api/client.ts'],
'/repo',
fakeRun({ 'ls-files': '', 'diff': '+// 只有注释\n' })
);
assert.equal(r.commentOnly, true);
assert.match(r.reason, /client\/electron\/src\/api\/client\.ts/);
// 不能把绝对路径整条塞进 git diff -- 之后(那会查不到任何东西 ⇒ 静默「无改动」)
assert.doesNotMatch(r.reason, /\/repo\//, 'reason 里不该出现仓库根前缀');
});
/*
变异验证(本判据必须转红,否则它分不清):
把 isCodeChange 里的注释分支去掉(所有行都算真改动)
⇒ 「只有注释 ⇒ 放行」那格必须红 —— 否则部署会被那一行注释继续拦。
把 isCodeChange 恒返回 false
⇒ 「有代码 ⇒ 拦截」「单行改动 ⇒ 拦截」两格必须红 ——
否则等于把 2026-09-14 那个静默失败放回来。
*/