Files
MailUI4Agents/client/electron/test/harmony-reply-target.test.mjs
JianFeeeee b6b50cbb88 修复: 套件因"清单自检"停了整整一天(一条判据都没跑)—— 补接线 3 个从未进 SUITE 的文件 + 修 2 处裸 readFileSync + 守卫补报"因此没有跑任何判据"
pi 报的 `ran`/`checks` 双计**早已修掉**(`d23ef69`,他读的是它的祖先 `2774b00`)。
但顺着那条线实测,发现了一个**更严重的现况**:**套件一条判据都没在跑。**

## 一、★ 现况:`run-all.mjs` 输出只有一行,0 条判据

```
$ node test/run-all.mjs
这些判据文件没接进套件(写了却不会跑):test/harmony-push-optin.test.mjs、…
退出码=1        stdout 0 行、stderr 1 行(build 与 install **两个相位都死**)
```

自检 2("test/ 下每个 *.test.mjs 都要在清单里")在**跑任何判据之前** `process.exit(1)`。

**逐提交对照(worktree 隔离)**:

| 提交 | 套件 |
|---|---|
| `cfe7808^` | `RESULT files=26 ran=23 checks=332 …`(正常)|
| `cfe7808`(09-16 07:46,加了 2 个文件)| **一行"没接进套件",0 条** |
| `c523c21`(09-17 17:43,又加 1 个)| 同上 |

⇒ 整仓**超过一天没有任何判据读数**,而失败信号只有那一行 ——
**看起来像环境问题**,不像"套件坏了"。三个文件**从未**进过 SUITE(`git log -S` 查证:0 次提及)。

## 二、修

1. **接线 3 个文件**(`harmony-push-optin` 4 / `harmony-reply-target` 7 / `harmony-widescreen` 6 条)。
2. **接线后立刻暴出 2 处违规** —— 这是"漏接线"的**第二层后果**:不只它自己不跑,
   **连它自身的违规也没有任何东西会看见**:
   - `harmony-reply-target.test.mjs:189` 裸 `readFileSync` 读 `.go` ⇒ 改走 `code()`
   - `harmony-apibase.test.mjs:266` 裸 `readFileSync` 读 JSON ⇒ 改走 `prose()`
   (两条在纯净 HEAD 上**都已存在**,`criteria-hygiene` 第 2 条现在 6/6 绿。)
3. **守卫补报后果**:原来只说"这些文件没接进套件",**没说"因此本次一条判据都没跑"**。
   ⇒ 补上那句 + 说明"修法是把文件接进 SUITE(不是改这里)"。
   **守卫本身保留**(漏接线不该静默),但**它要为自己造成的后果作证**。
   变异验证:临时摘掉一条 ⇒ 新报文出现;还原逐字节一致。

## 三、★ 我接线时被自己写的注释绊了一次(值得记)

`criteria-hygiene` 第 3 条扫"用了 `code`/原文读取/`bytes` 却没 import",而它**不剥注释**:
我在注释里写了"那个入口保留注释"**并带上括号**,它报「用了 … 但没 import」。
⇒ **"注释里说明禁令"不等于"违反禁令"**(我们早立的规则),
而其镜像也成立:**注释里提到函数名会被判据当成使用**。两个方向都得当心。

## 四、测量(29 个文件,两个相位都活了)

| 相位 | RESULT |
|---|---|
| build | `files=29 ran=29 checks=440 pass=438 fail=2 red=8 broken=0 unreported=0` |
| install | `files=29 ran=27 checks=428 pass=427 fail=1 red=7 broken=0 unreported=0` |

`broken=0 unreported=0`(接线前那三个文件是 3/3 —— 它们自报不了条数)。
剩下 8 条红**都不是本次改动**:`narrow-layout`/`nav-merge`/`harmony-presets`/`build-stamp`/
`commit-hygiene` 等是**并发会话未提交的工作**(`harmony-nav` 正被改),
在纯净 HEAD 上它们**看不到**(因为 HEAD 上套件根本没跑)。
2026-09-17 18:43:00 +08:00

220 lines
12 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.

/*
* 鸿蒙侧的三维地址拼装 —— 跑的是客户端真正会跑的那份逻辑。
*
* `client/harmony/entry/src/main/ets/model/ReplyTarget.ts` 是纯逻辑、无 UI 依赖,
* 本文件用 node 的 `--experimental-strip-types` **直接执行它**,断言的是**行为**
* (空 path 有没有留下 `@`、给人拼几段、工作目录从哪取)。
*
* 为什么必须有这个文件:`formatAddress` 的输出直接进 `send_mail` 的 `to` 字段,
* 拼错了就是**投递到错误的会话或直接 404**,而这件事在界面上看不出来
* (发送会成功,信只是进了别处)。这个坑 WebUI 侧踩过并写在注释里:
* 漏掉空 path 那个 `@` 的 `jianf.任务` 会被整串当成**名字**。
*
* 同时对齐后端的 `server/internal/models/address.go`(`FormatAddress`)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
/*
* `code()` 剥掉注释、原文读取保留 —— 本文件判的是**代码里有什么调用/什么值**,
* 所以必须用 `code()`。
*
* 这里踩过一次(写完就红):详情页的注释里**写着**那个 bug 形状
* (「原先是 `this.fromName + '@'`」),用原文去 match 会把它自己的解释当代码,
* 产生假红 —— 而 `read.mjs` 的头注释正是为这类误报写的。
*
* ⚠️ ★ 并且**别在这段话里写出那个函数名加括号的样子**(2026-09-17 实录):
* `criteria-hygiene` 第 3 条扫的是"用了某入口但没 import",而它**不剥注释** ——
* 我上面原本写了一句"那个原文读取入口保留注释"(并把它带着括号写了出来),于是它报
* 「用了那个入口但没 import」:**判据把注释里的解释当成了用法**。
* 这正是本仓那条规则的镜像:**"注释里说明禁令"不等于"违反禁令"**,
* 但反过来,**注释里提到函数名会被判据当成使用** —— 两个方向都得当心。
*/
import { code } from './lib/read.mjs';
/*
* ★ `.go` 那一半也必须走 `read.mjs` 的具名入口(2026-09-17 修)。
*
* 原来那里是裸 `readFileSync(goPath, 'utf8')` —— 违反 `criteria-hygiene` 第 2 条
* ("判据目录里不得出现裸 readFileSync")。**它违反了却一直没被发现**,因为本文件
* 当时**根本没接进 SUITE**(`616c994` 起套件被清单自检整体停了一天多)。
* ⇒ 这就是"漏接线的判据 = 写了却永远不跑"的**第二层后果**:
* 不只是它自己不跑,**连它自身的违规也没有任何东西会看见**。
* 接线之后,第一条报出来的就是这个。
*
* 用 `code()`(剥注释、留字符串),与上面 `.ets` 那半同一个入口,理由也一样:
* 判的是**代码形态**(函数签名 + `if` 分支),而 Go 的注释里完全可能写着这个签名,
* 用原文 match 会把它自己的解释当成代码。
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const MODULE_TS = join(HARMONY_ETS, 'model/ReplyTarget.ts');
const R = await import(pathToFileURL(MODULE_TS).href);
test('formatAddress:三段都要对(含空 path 仍留 `@`)', () => {
// 裸名字 = 默认会话(没有 @ 也没有 path)
assert.equal(R.formatAddress('jianf', '', ''), 'jianf');
// 有 path 无 session
assert.equal(R.formatAddress('pi', '/home/program/agentmail', ''), 'pi@/home/program/agentmail');
// 三段齐全
assert.equal(R.formatAddress('pi', '/home', 'fix-leak'), 'pi@/home.fix-leak');
/*
* ★ 空 path 但有 session:**必须留 `@`**。
* 去掉它得到 `jianf.任务`,后端 ParseAddress 会把整串当成 name
* (实测返回 name="jianf.任务"),那是个不存在的 Agent ⇒ 投递 404。
* 正确形式 `jianf@.任务` 解析成 name=jianf / path='' / session=任务。
*/
assert.equal(R.formatAddress('jianf', '', '任务'), 'jianf@.任务',
'空 path + 有 session 时必须保留 `@`,否则整串被当成名字、投递 404');
// 空名字给空串(不拼出 `@path.session` 这种没有收件人的地址)
assert.equal(R.formatAddress('', '/home', 'x'), '');
// 前后空白要 trim 掉(用户输入常带)
assert.equal(R.formatAddress(' pi ', ' /home ', ' x '), 'pi@/home.x');
});
test('★ participantAddress:人只有名字,Agent 要三段', () => {
/*
* 人没有工作目录、也不需要指定会话(发给人就是进他的收件箱)。
* 给人拼 `jianf@.某会话` 是把 Agent 的维度硬套在人身上。
*/
assert.equal(R.participantAddress('jianf', true, '/home/program/agentmail', '某会话'), 'jianf',
'人类参与者只显示名字,不带 path 也不带会话位');
// Agent:三段(哪一个 Agent、哪个目录、哪条线索 —— 少一段都不是可投递地址)
assert.equal(R.participantAddress('pi', false, '/home/program/agentmail', '任务'),
'pi@/home/program/agentmail.任务');
// Agent 但会话未命名:两段(path 仍在)
assert.equal(R.participantAddress('pi', false, '/home/program/agentmail', ''),
'pi@/home/program/agentmail');
});
test('★ 判据自检:workspace 必须来自**会话**(拿 from_workspace 会拼出 dsh@dsh)', () => {
/*
* 这是一个"容易写错且看不出来"的点:`mails.from_workspace` 对 Agent
* 存的是 **Agent 名**而不是路径(历史遗留,见 server 的 models.go 注释)。
* 用 participantAddress('dsh', false, 'dsh', '') 会得到 `dsh@dsh` ——
* 一个语法合法但语义错误的地址(指向名为 dsh 的目录)。
*
* 这条钉的是**调用方传的是哪个字段**,不是函数本身:
* 源码里必须出现 `session_workspace`,且详情页不得用 `from_workspace`/`to_workspace` 拼地址。
*/
const models = code(join(HARMONY_ETS, 'model/Models.ets'));
assert.match(models, /session_workspace:\s*string/,
'MailDetail 要有 session_workspace 字段(拼地址的 path 只能从会话取)');
const detail = code(join(HARMONY_ETS, 'pages/MailDetailPage.ets'));
assert.match(detail, /this\.sessionWorkspace/,
'详情页拼地址要用 sessionWorkspace');
assert.ok(!/from_workspace|to_workspace/.test(detail),
'★ 详情页不得用 from_workspace/to_workspace 拼地址 —— 它们对 Agent 存的是 Agent 名,会拼出 `dsh@dsh`');
// 显示的假名要示范一下那个错值,让读者知道判据防的是什么
assert.equal(R.participantAddress('dsh', false, 'dsh', ''), 'dsh@dsh',
'自检:拿 Agent 名当 path 正是会得到 `dsh@dsh`(判据要防的就是它)');
});
test('★ mailReplyTarget:会话位必须带上(否则回信落到默认会话)', () => {
/*
* 这是**线上真出现过的 bug 形状**:鸿蒙详情页原来写 `req.to = this.fromName + '@'`。
* 那个游离的 `@` 让 `pi@` 被解析成「有 path、无 session」⇒ 落到该 Agent 的
* **默认会话**,而不是用户正在看的这条线索。
*/
const target = R.mailReplyTarget(
'pi', false, 'jianf', true, '/home/program/agentmail', '某任务', 'jianf'
);
assert.equal(target, 'pi@/home/program/agentmail.某任务',
'对端是 pi(Agent)⇒ 三段,会话位必须在');
assert.ok(target.endsWith('.某任务'), '会话别名要在地址里');
assert.notEqual(target, 'pi@', '★ 绝不能是那个有 bug 的形式(游离的 @)');
/*
* 回信给**人**时:path 为空,但**会话位仍要带上**。
*
* 这里有个容易想反的点:**显示**地址与**回复**地址规则不同。
* · 显示走 `participantAddress()`:人只有名字(不带会话位)——
* 因为「他是谁」不需要用会话去限定;
* · 回复走 `mailReplyTarget()`:**无论对端是人是 Agent 都带会话位**——
* 因为「回进哪条线索」是必需的,人也会有多条并行的线索。
*
* 所以这里得到的是 `someone@.某任务`(空 path 仍留下 `@`,会话位在)。
* 与 WebUI 的 `mailReplyTarget` 逐字一致(它也是无条件传 `mail.session_alias`)。
*/
const toHuman = R.mailReplyTarget(
'someone', true, 'jianf', true, '/home/program/agentmail', '某任务', 'jianf'
);
assert.equal(toHuman, 'someone@.某任务',
'回给人:path 为空但会话位要在(显示与回复的规则不同,见注释)');
assert.ok(toHuman.includes('@'), '★ 空 path 也必须留下 `@`,否则整串被当成名字');
/* 回给人且**会话未命名**时:只剩名字(没有会话位可带) */
assert.equal(
R.mailReplyTarget('someone', true, 'jianf', true, '/home', '', 'jianf'),
'someone',
'会话未命名时回给人就只是名字'
);
/* 「我发的信」应该回给收件人,不是回给自己 */
const iSent = R.mailReplyTarget(
'jianf', true, 'pi', false, '/home/program/agentmail', '某任务', 'jianf'
);
assert.equal(iSent, 'pi@/home/program/agentmail.某任务',
'我发的信要回给收件人 pi,不是回给自己');
});
test('★ 详情页不得再用 `fromName + \'@\'` 拼回复地址(回归防护)', () => {
const detail = code(join(HARMONY_ETS, 'pages/MailDetailPage.ets'));
assert.ok(!/fromName\s*\+\s*'@'/.test(detail),
"★ `req.to = this.fromName + '@'` 是那个丢会话位的 bug 形状 —— 必须用 replyTargetAddress()");
assert.match(detail, /req\.to\s*=\s*this\.replyTargetAddress\(\)/,
'回复的收件地址要走 replyTargetAddress()(内部经 mailReplyTarget 带上会话位)');
});
test('★ 详情页的细节对齐 WebUI:本地时间 / 中文档位 / 三段地址', () => {
const detail = code(join(HARMONY_ETS, 'pages/MailDetailPage.ets'));
// 时间:必须是本地格式化,不是把 ISO 原文吐出来
assert.match(detail, /function localDateTime\(/, '要有本地时间格式化函数');
assert.ok(!/Text\(this\.createdAt\)/.test(detail),
'★ 不得直接把 createdAt(ISO 原文)显示给用户 —— 要过 localDateTime()');
assert.match(detail, /localDateTime\(this\.createdAt\)/, '时间行要用 localDateTime()');
// 档位:中文标签,不是英文 mode
assert.match(detail, /permissionLabel\(mode\)/,
'★ 档位要显示中文(permissionLabel),不是英文 plan/workspace/full');
assert.ok(!/Text\(mode\)\s*\n?\s*\.fontSize/.test(detail),
'不得直接显示英文 mode 字面量');
// 头部可折叠(默认收起)—— WebUI CollapsibleHeader 的语义
assert.match(detail, /headerOpen/, '头部要有可折叠状态');
assert.match(detail, /@State headerOpen:\s*boolean\s*=\s*false/,
'★ 头部要**默认收起**(WebUI:顶部信息常驻会把可读区压成一条缝)');
// 回复入口是右下悬浮球,不是底部通栏按钮
assert.match(detail, /iconName: 'chatBubble'/, '回复入口要用气泡图标(WebUI reply-fab)');
assert.ok(!/Text\('回复'\)\.fontSize\(15\)/.test(detail),
'★ 不得再用底部通栏「回复」按钮(WebUI 已改成右下悬浮球)');
});
test('★ 对齐后端:FormatAddress 的三分支与 Go 源码一致', () => {
/*
* 「拼地址」这件事有三份实现(WebUI TS / 鸿蒙 TS / 后端 Go),
* 三份不一致时**投递会静默失败**。这里直接去读 Go 源码,
* 确认分支结构与本模块相同(不是比字符串,是比**分支条件**)。
*/
const goPath = join(ROOT, 'server/internal/models/address.go');
if (!existsSync(goPath)) {
return; // 没这个文件就无从判起(判据不假装验过)
}
const go = code(goPath); // 走 read.mjs 的具名入口(见文件头那段 ★)
assert.match(go, /func FormatAddress\(name, path, session string\) string/,
'后端要有 FormatAddress');
// 三分支:session 空 + path 空 → name;session 空 + 有 path → name@path;否则 name@path.session
assert.match(go, /if session == "" \{\s*if path == "" \{\s*return name\s*\}\s*return name \+ "@" \+ path\s*\}/,
'后端 FormatAddress 的两个空 session 分支要与本模块一致');
assert.match(go, /return name \+ "@" \+ path \+ "\." \+ session/,
'后端第三分支(保留 @ 与 .)要与本模块一致');
});