Files
MailUI4Agents/client/electron/test/harmony-reply-target.test.mjs
JianFeeeee c523c21e22 feat(harmony): 邮件详情与「我的」页 1:1 对齐 WebUI,并修两个线上 bug
用户:「为什么邮件页面没有对齐 webui?」「我的页面也没有对齐」,
选定「完全 1:1」。两个页面都不是「没做」,而是**做了一半** ——
`AuthApi` 的 logout/listKeys/createKey/revokeKey 全都写好但没人调,
`Models.ets` 的字段声明漏了服务端一直在返回的那些。

── 邮件详情页(对齐 WebUI `MailView.Header` / `CollapsibleHeader`)──
· 头部改**可折叠**(默认收起):收起只留标题 + 必须常驻的状态点
  (未读 / 权限请求)+ 箭头。WebUI 的理由:顶部信息常驻会把可读区压成
  一条缝(实测 1280×800 下头部 17% + 回复框 31%,正文只剩 48%)。
· 发件/收件改**三段式完整地址**:Agent → `pi@/home/program/agentmail.别名`,
  人 → 只有名字。新增 `model/ReplyTarget.ts`(逐字移植 WebUI `replyTarget.ts`,
  并对齐后端 `models.FormatAddress`)。
· 时间:`localDateTime()` → `2026/09/15 11:37:07`(原为裸 ISO `2026-09-15T03:37:07.14758Z`)。
· 档位:`permissionLabel()` → `只读/目录内/全权`(原为英文 `plan/workspace/full`;
  这个函数早就在 `MailGrouping.ts` 里、列表页也在用,只有详情页没用)。
· 新增抄送行;回复入口改**右下悬浮球**(WebUI `reply-fab`),
  不再是底部 56vp 通栏按钮。

── 「我的」页(对齐 WebUI `AccountPage`,补 5 个缺失 section)──
标题由「账号管理」改为「我的」(多账号只是其中一段,不是整页的目的)。
· 基本资料:用户名/显示名/角色/状态/创建时间/最后登录。
· 权限范围:可调用 Agent / 可访问目录(非管理员;空数组 = 不限)。
· 修改密码:三输入框 + 不一致就地提示(新增 `AuthApi.changePassword`,
  该端点服务端一直有、客户端从未包)。
· 客户端连接密钥:列表 / 新建 / 吊销 + 一次性全文提示
  (`AuthApi` 的三个方法终于被调用)。
· 退出登录:先注销推送 token → 清凭证 → **清全部账号** → 回登录页。
· 整页改为**一个 Scroll**:原来账号列表 `layoutWeight(1)` 占满剩余高度,
  排在它后面的 section 被挤出可视区且滚不到(WebUI 注释里正是这个坑),
  而「退出登录」在最下面 ⇒ 等于退不出去。

── 顺带修掉的两个线上 bug(都是判据发现的)──
① `MeApi.get()` 调 `GET /me`,但服务端**没有**这个路由(只有 `/auth/me`;
   `/me/*` 下是 mail/sessions/keys/appearance 子资源)。
   后果:这把调用恒 404 → `loadRole()` 恒走 catch → `isAdmin` 恒 false
   → 「管理」入口对**包括管理员在内**的所有人永远不显示。
   实证:`GET /api/v1/me` → 404;`GET /api/v1/auth/me` → 200
   `{"user":{"role":"admin",...}}`。修复后截图里「管理」入口已出现。
② 详情页回复写 `req.to = this.fromName + '@'`:那个游离的 `@` 让 `name@`
  被后端 `ParseAddress` 解析成「有 path、无 session」⇒ 落到该 Agent 的
  **默认会话**,而不是用户正在看的那条线索。改用 `replyTargetAddress()`。

── 判据(新增 7 + 8 条,全部含变异自检)──
· `harmony-reply-target.test.mjs`(新):直接执行 `ReplyTarget.ts` 断言行为
  —— 空 path 必须留 `@`(否则整串被当成名字、投递 404)、人只有名字、
  会话位必须带上、`from_workspace` 不得用来拼地址(会得到 `dsh@dsh`);
  并读 Go 源码比对三分支结构。
· `harmony-admin.test.mjs`(+5):`/auth/me` 路径(含服务端注册与响应形状)、
  八个 section 齐全、退出清全部账号、整页一个滚动容器、字段来自服务端。
  变异自检:把 `/auth/me` 改回 `/me` → 判据变红(已验证)。

测试:212 passed / 0 failed;`devecocli build` 通过;
模拟器截图逐项核对(详情收起/展开两态、我的页全部 section)。
未验:真机观感;横屏 Auto Split 双栏。
2026-09-17 17:43:03 +08:00

198 lines
11 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 { readFileSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
/*
* `code()` 剥掉注释、`prose()` 保留 —— 本文件判的是**代码里有什么调用/什么值**,
* 所以必须用 `code()`。
*
* 这里踩过一次(写完就红):详情页的注释里**写着**那个 bug 形状
* (「原先是 `this.fromName + '@'`」),用原文去 match 会把它自己的解释当代码,
* 产生假红 —— 而 `read.mjs` 的头注释正是为这类误报写的。
*/
import { code } from './lib/read.mjs';
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 = readFileSync(goPath, 'utf8');
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/,
'后端第三分支(保留 @ 与 .)要与本模块一致');
});