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 上套件根本没跑)。
220 lines
12 KiB
JavaScript
220 lines
12 KiB
JavaScript
/*
|
||
* 鸿蒙侧的三维地址拼装 —— 跑的是客户端真正会跑的那份逻辑。
|
||
*
|
||
* `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/,
|
||
'后端第三分支(保留 @ 与 .)要与本模块一致');
|
||
});
|