/* * 管理页(用户管理)的判据 —— **行为**判据(纯逻辑真跑)+ **形态**判据(`.ets` 只读源码)。 * * ── 为什么值得有 ── * * 这一页是本次「功能做全」新加的那一块,而它**本机跑不起来**(无设备/无模拟器)。 * 所以能钉住的每一条都要钉住,并且**分清哪一条是哪种**: * * · **真跑**(`model/AdminUsers.ts`,无 `@ohos` 依赖 ⇒ node strip-types 直接执行): * 勾选、最后登录文案、角色判定、受限徽标、异常兜底文案。 * · **读源码**(`pages/AdminUsersPage.ets` / `pages/SettingsPage.ets` / * `api/AdminApi.ets` / `main_pages.json`):路由注册、服务端调用都对应上、 * 没有写死色值、列表项每项一张卡、服务端文案不被吞。 * 这类**只能证明"代码里是这么写的"**,证不了"真机上长这样" —— 见文件末的未验清单。 * * ── 判代码一律用 `code()`(剥注释)── * * 本文件里判的标识符(`listUsers`/`#` 色值/`radiusCard`…)在解释性注释里大量出现, * 用原文读会产生假绿。这是 `lib/read.mjs` 存在的理由,也是我踩过两次的坑。 */ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { pathToFileURL } from 'node:url'; import { code, prose } from './lib/read.mjs'; /* * ★ 仓库根必须**从本文件的位置推**,不许硬编码绝对路径。 * * 原来这里写的是 `const ROOT = '/home/program/agentmail';` —— pi 2026-09-15 实测出后果: * 把带违规的提交检出到**别的目录**再跑,判据**读的仍是 `/home/program/agentmail`**, * 于是"在一个 import 顺序明显违规的检出上 3/3 全绿"。 * 两层后果,第二层最糟: * ① 它**永远无法验证任何别的 checkout / CI / 镜像**(换目录不是"红",是 readdirSync 直接抛); * ② 在本机做 worktree 复核时,它会**静默读另一棵树并报绿** —— 正是我们这几轮在消的形状, * 这次长在判据自己身上。**"规则进来了,对象没进来"**。 * 修法照邻居(10 个鸿蒙判据都是 `join(HERE, '..', '..', '..')`)。 */ const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..', '..'); const ETS = join(ROOT, 'client/harmony/entry/src/main/ets'); const ADMIN_TS = join(ETS, 'model/AdminUsers.ts'); const ADMIN_PAGE = join(ETS, 'pages/AdminUsersPage.ets'); const SETTINGS_PAGE = join(ETS, 'pages/SettingsPage.ets'); const ADMIN_API = join(ETS, 'api/AdminApi.ets'); const ACCOUNT_MGR = join(ETS, 'api/AccountManager.ets'); /** 退出登录的唯一实现(两个入口共用)—— 2026-09-19 抽出来后才有的文件 */ const LOGOUT_MOD = join(ETS, 'api/Logout.ets'); const PAGES_JSON = join(ROOT, 'client/harmony/entry/src/main/resources/base/profile/main_pages.json'); // 真跑纯逻辑(有 `@ohos` 依赖的文件不能这样跑;AdminUsers.ts 刻意零 import) const A = await import(pathToFileURL(ADMIN_TS).href); /* ────────────────────── ① 真跑:勾选白名单 ────────────────────── */ test('勾选:加进去、再去掉,而且**不就地改入参**', () => { const before = ['a', 'b']; const added = A.toggled(before, 'c'); assert.deepEqual(added, ['a', 'b', 'c'], '没勾上的项要加进去'); assert.deepEqual(before, ['a', 'b'], '★ 入参被就地改了:@State 靠**引用变化**触发重渲染,就地 push/splice 改同一个数组不会刷新界面,' + '表现是"点了没反应"而数据其实已改;而且入参可能是另一个 @State 的当前值,就地改会让两处互相污染'); const removed = A.toggled(added, 'a'); assert.deepEqual(removed, ['b', 'c'], '已勾上的项再点要取消'); assert.notEqual(added, removed, '★ 必须返回**新数组**(同一个引用不会被 @State 认成变化)'); }); test('勾选:边界(空表、重复项只当一次、原顺序不被打乱)', () => { assert.deepEqual(A.toggled([], 'x'), ['x'], '空表勾第一个'); assert.deepEqual(A.toggled(['x'], 'x'), [], '唯一项取消后是空表(不是 null/undefined)'); assert.deepEqual(A.toggled(['b', 'a'], 'c'), ['b', 'a', 'c'], '追加在末尾(不重排既有项:列表顺序变了会让用户以为选项跳了)'); }); /* ────────────────────── ② 真跑:最后登录文案 ────────────────────── */ test('最后登录:**缺席**与**空串**都显示"从未登录"(服务端是 omitempty)', () => { assert.equal(A.lastLoginLabel({ last_login: undefined }), '从未登录', '字段缺席 = 从未登录'); assert.equal(A.lastLoginLabel({}), '从未登录', '整个字段不存在也一样'); assert.equal(A.lastLoginLabel({ last_login: '' }), '从未登录', '★ 空串也要当"从未登录":只判 undefined 会让空串在界面上留一块空白,看起来像"读取失败"'); assert.equal(A.lastLoginLabel({ last_login: '2026-09-15 07:02' }), '2026-09-15 07:02', '有值就原样显示(不在这里改格式:服务端给的就是要显示的那个串)'); }); /* ────────────────────── ③ 真跑:角色判定 ────────────────────── */ test('角色判定:**严格等于 admin**(口径与 WebUI 的 user?.role === "admin" 一致)', () => { assert.equal(A.isAdminRole('admin'), true); assert.equal(A.isAdminRole('user'), false); assert.equal(A.isAdminRole(''), false); assert.equal(A.isAdminRole(undefined), false, '★ 读不到 role 一律 false:乐观放行会让任何一次 /me 失败都变成"对所有人显示管理入口",点进去一片 403'); for (const loose of ['Admin', 'ADMIN', 'admin ', ' admin', 'admin\n', 'superadmin', 'admin2']) { assert.equal(A.isAdminRole(loose), false, `★ ${JSON.stringify(loose)} 不该放行:只有严格相等才与 WebUI 同口径(宽松匹配会让某天服务端改大小写时两端行为分叉)`); } }); /* ────────────────────── ④ 真跑:受限徽标 ────────────────────── */ test('受限徽标:普通用户且白名单非空才显示(空 = 不限,不是"什么都不许")', () => { assert.equal(A.isRestricted({ role: 'user', allowed_agents: ['pimail'], allowed_paths: [] }), true); assert.equal(A.isRestricted({ role: 'user', allowed_agents: [], allowed_paths: ['/tmp'] }), true); assert.equal(A.isRestricted({ role: 'user', allowed_agents: [], allowed_paths: [] }), false, '★ 全空不叫受限:空 = 不限,打上「受限」会让用户以为自己什么都点不动'); assert.equal(A.isRestricted({ role: 'admin', allowed_agents: ['pimail'], allowed_paths: ['/tmp'] }), false, '★ 管理员一律 false:服务端对管理员**忽略**这两项,给他打「受限」是误导'); }); /* ────────────────────── ⑤ 真跑:服务端文案不被吞 ────────────────────── */ test('服务端文案原样透出(400/409 的中文文案是唯一能让人立刻改的东西)', () => { const cases = [ '该名称已被用户或 Agent 占用', '密码至少 8 位', '用户名只能包含小写字母、数字、点、下划线和连字符', '系统至少需要保留一个可用管理员', '用户不存在' ]; for (const msg of cases) { const out = A.messageOfApiError(true, msg); assert.ok(out.includes(msg), `★ 服务端文案被改写了:「${msg}」→「${out}」。管理页的失败原因几乎都是"人能立刻改的东西",吞掉只剩反复试`); assert.equal(out, msg, '不做任何包装:原样显示'); } assert.ok(A.messageOfApiError(true, '').length > 0, '服务端没给文案时也要有话说'); assert.ok(A.messageOfApiError(false, 'Network unreachable').includes('Network unreachable'), '本地异常(网络层)的 message 也要给用户看'); assert.ok(A.messageOfApiError(false, '').length > 0, '什么都没有时给兜底句'); }); test('兜底句要说清"服务端没给原因"(否则用户分不清"服务端说不行"和"客户端没收到")', () => { /* * ★ 这条判据第一版写的是 `/没(有)?给|未知|失败/`,**红不了** —— 变异测试抓出来的: * 把兜底句改成光秃秃的「操作失败」,它照样匹配上了(`失败` 这个分支太宽)。 * ⇒ 收窄成"必须出现**原因缺席**这件事",而不是"出现了某个失败词"。 */ const fallback = A.messageOfApiError(false, ''); assert.ok(/没(有)?(给|提供)原因|未(给|提供)原因|无原因/.test(fallback), `★ 兜底句「${fallback}」只说了"失败",没说**原因缺席** —— 用户会把它当成服务端的拒绝理由,` + '于是反复重试同一个注定失败的动作'); }); /* ────────────────────── ⑥ 形态:路由与入口 ────────────────────── */ test('管理页**注册成路由**(没注册的 @Entry 页 pushUrl 会失败)', () => { const pages = JSON.parse(prose(PAGES_JSON)); assert.ok(Array.isArray(pages.src), 'main_pages.json 要有 src 数组'); assert.ok(pages.src.includes('pages/AdminUsersPage'), `★ 管理页不在 main_pages.json 里(实际 ${JSON.stringify(pages.src)})⇒ pushUrl({url:'pages/AdminUsersPage'}) 起不来`); assert.equal(new Set(pages.src).size, pages.src.length, '清单里不该有重复项'); }); test('设置页的管理入口:仅管理员可见、点进管理页', () => { const src = code(SETTINGS_PAGE); assert.ok(/if \(this\.isAdmin\)/.test(src), '★ 管理入口没有 isAdmin 门禁 ⇒ 每个普通用户都会看到一个点进去 403 的入口'); assert.ok(/pushUrl\(\{ url: 'pages\/AdminUsersPage' \}\)/.test(src), '入口要真的推到管理页(推一个别的地方 = 用户点了看不到管理面)'); assert.ok(/isAdminRole\(/.test(src), '★ 门禁要用共用的 isAdminRole,而不是就地写 === "admin"(两处口径会漂移)'); assert.ok(/loadRole\(\)/.test(src), '要真去读一次身份'); }); test('身份读不到时**不**乐观显示入口(isAdmin 初值 false,失败也保持 false)', () => { const src = code(SETTINGS_PAGE); assert.ok(/@State isAdmin: boolean = false/.test(src), 'isAdmin 初值必须是 false'); const catchBlock = src.slice(src.indexOf('async loadRole()')); const body = catchBlock.slice(catchBlock.indexOf('catch'), catchBlock.indexOf('catch') + 200); assert.ok(/this\.isAdmin = false/.test(body), `★ loadRole 的 catch 里没有把 isAdmin 置 false;读一次片段:${body.slice(0, 120)}`); }); /* ────────────────────── ⑦ 形态:动作 ↔ 服务端调用 ────────────────────── */ test('管理页的每个动作都落到真实的服务端调用(不是只画了个按钮)', () => { const page = code(ADMIN_PAGE); // 「用户真正会点的那一层」:按钮的 onClick 要能走到这些调用 const required = ['listUsers', 'listScopes', 'createUser', 'updateUser', 'disableUser', 'resetPassword']; for (const fn of required) { assert.ok(new RegExp(`\\.${fn}\\(`).test(page), `★ 页面上没有调用 api.${fn}() ⇒ 那个动作是死的(点下去什么都不发生)`); assert.ok(new RegExp(`async ${fn}\\(`).test(code(ADMIN_API)), `★ AdminApi 里没有 ${fn} ⇒ 页面调的是不存在的东西,编不过`); } }); test('禁用只发 status 一个字段(服务端是**部分更新**,多发的字段会被当成"改成这个值")', () => { const page = code(ADMIN_PAGE); const idx = page.indexOf('async setStatus('); assert.ok(idx > 0, '要有 setStatus'); const body = page.slice(idx, page.indexOf('async resetPassword', idx)); assert.ok(/disableUser\(/.test(body), '禁用要调 disableUser(DEL)'); assert.ok(/onlyStatus\.status = 'active'/.test(body), '★ 启用路径要显式只设 status'); assert.ok(!/display_name/.test(body) && !/allowed_agents/.test(body), '★ 启停路径里出现了 display_name/allowed_agents ⇒ 服务端会把显示名与白名单一起改成这些值(部分更新的经典踩法)'); }); test('服务端拦"最后一个管理员"的文案有落点(409 要能显示出来)', () => { const page = code(ADMIN_PAGE); assert.ok(/this\.messageOf\(e\)/.test(page), '★ 捕获到的异常没走 messageOf ⇒ 服务端文案被吞,用户不知道"最后一个管理员不能禁用"'); assert.ok(/this\.errorText = this\.messageOf\(e\)/.test(page), '文案要落到可见的 errorText'); }); /* ────────────────────── ⑧ 形态:交付判据(pi 那几条) ────────────────────── */ test('不许新写死颜色(色一律走 Theme;品牌色只写在 Theme 那一个文件里)', () => { for (const f of [ADMIN_PAGE, SETTINGS_PAGE]) { const src = code(f); const hex = src.match(/#[0-9a-fA-F]{3,8}\b/g) || []; assert.deepEqual(hex, [], `★ ${f.replace(ROOT + '/', '')} 里出现了写死的色值 ${JSON.stringify(hex)} ⇒ 深浅两套会从这一处分叉`); const rgb = src.match(/\brgba?\(/g) || []; assert.deepEqual(rgb, [], `★ ${f.replace(ROOT + '/', '')} 里出现了手写 rgba/rgb`); } }); test('列表项每项一张卡(不是整列共用一张底)', () => { const src = code(ADMIN_PAGE); assert.ok(/ListItem\(\)/.test(src), '用户列表要用 List + ListItem'); assert.ok(/for \(const|ForEach\(this\.users/.test(src), '要真的遍历用户列表'); // 卡片样式必须落在被 ForEach 调用的那个 @Builder 里(`UserCard`),而不是外层容器上 const cardIdx = src.indexOf('UserCard(user: AdminUser)'); assert.ok(cardIdx > 0, '要有 UserCard @Builder'); const card = src.slice(cardIdx, cardIdx + 2000); assert.ok(/backgroundColor\(Theme\.surface\)/.test(card) && /borderRadius\(Theme\.radiusCard\)/.test(card), '★ 每项那张卡的底色/圆角要在 UserCard 里 ⇒ 否则是一整列共用一张底'); }); test('页面**真的用上**了那几个纯函数(否则纯逻辑全绿、界面却是死的)', () => { /* * ★ 这条是变异测试抓出来的缺口:把页面里的 `if (isRestricted(user))` 改成 `if (false)`, * 删掉「受限」徽标 —— **纯逻辑那几条判据全绿**(`isRestricted` 本身没错), * 而用户再也看不到徽标。这正是"判据覆盖了模块、没覆盖接线"的经典形状。 * ⇒ 每个纯函数都要在**页面代码**里出现一次(断言调用,不是断言注释里提过)。 */ const src = code(ADMIN_PAGE); const wired = [ ['isRestricted(user)', '「受限」徽标'], ['isAdminRole(', '管理员门禁'], ['lastLoginLabel(user)', '最后登录文案'], ['messageOfApiError(', '异常兜底文案'], ['toggled(', '白名单勾选'] ]; for (const [call, what] of wired) { assert.ok(src.includes(call), `★ 页面里没有 ${call} ⇒ ${what} 是死的(纯逻辑判据会全绿,而界面上什么都不会发生)`); } }); test('管理页不碰背景/模糊(同一张底只允许被模糊一次,那是 MainPage 外观层的事)', () => { const src = code(ADMIN_PAGE); assert.ok(!/blur\(|BackdropBlur|backgroundBlurStyle/.test(src), '★ 管理页里出现了模糊 ⇒ 与 MainPage 的壁纸层叠起来就是"一张底被模糊两次"'); }); /* ────────────────────── ⑨ 形态:ArkTS 编译坑 ────────────────────── */ test('ArkTS 硬坑:本页不出现解构 / any / unknown / 函数表达式', () => { const src = code(ADMIN_PAGE); assert.ok(!/\bany\b/.test(src), '不许 any'); assert.ok(!/\bunknown\b/.test(src), '不许 unknown'); assert.ok(!/\bfunction\s*\(/.test(src), '不许函数表达式(ArkTS 只认箭头函数)'); assert.ok(!/const\s*\{[^}]*\}\s*=/.test(src) && !/const\s*\[[^\]]*\]\s*=/.test(src), '不许解构赋值'); }); test('ArkTS 硬坑:页面文件只导出那个 struct(工具函数放 model/ 里)', () => { const src = prose(ADMIN_PAGE); const exports = src.match(/^export\s+(function|const|class|interface|enum)/gm) || []; assert.deepEqual(exports, [], `★ 页面里出现了 ${JSON.stringify(exports)} ⇒ 本仓库页面清一色只导出 struct(7 个页面 0 个 export function);` + '而且 .ets 里的函数判据跑不了,纯逻辑必须放 model/*.ts'); /* * ★ 这条原来写的是"页面要 `export struct`",**是错的**,被它自己抓出来了: * 本仓库 5 个 `@Entry` 页(LoginPage/MainPage/SettingsPage/SessionsPage/MailDetailPage) * 清一色 `struct Xxx {`(**不带** export),而 `export struct` 只出现在 * 被当子组件用的那些(`CalendarPage`/`BackgroundPicker`)。 * 路由页由 `main_pages.json` 指名加载,不需要导出。 * ⇒ 判据改成钉**这个**形态(两件事分别断言,不混在一句里)。 */ assert.ok(/^@Entry$/m.test(src) && /^@Component$/m.test(src), '页面要有 @Entry + @Component'); assert.ok(/^struct AdminUsersPage \{/m.test(src), '★ @Entry 页要写成不带 export 的 struct(与另外 5 个路由页同形)'); assert.ok(!/^export struct AdminUsersPage/m.test(src), '★ @Entry 路由页带 export 与本仓库既有 5 个路由页不一致(路由页由 main_pages.json 指名加载,不需要导出)'); }); test('页面里不 import `.ets` 进纯逻辑层(否则那个文件从"能真跑"退化成"只读源码")', () => { const src = prose(ADMIN_TS); const imports = src.match(/^import\s/gm) || []; assert.deepEqual(imports, [], `★ model/AdminUsers.ts 有 ${imports.length} 个 import。本目录下 Wallpaper/Calendar/Appearance 全是零 import,` + '那正是它们能被 node --experimental-strip-types 直接跑的原因;import 了 .ets 就再也跑不了,判据只能读源码'); }); /* ────────────────────── ⑩ 形态:ArkUI 状态绑定 ────────────────────── */ test('背景选择器:@Link 不许给初值(ArkTS 会报 "forbidden to specify default value for @Link")', () => { const src = code(join(ETS, 'common/BackgroundPicker.ets')); const links = src.match(/@Link\s+\w+\s*:\s*[^;]+;/g) || []; assert.ok(links.length > 0, '选择器要用 @Link 双向绑'); for (const l of links) { assert.ok(!/=/.test(l), `★ 「${l.trim()}」给 @Link 写了初值 ⇒ 编不过(V1 家规:@Link 不许有 initializer)`); } }); test('背景选择器:父组件用 $ 传 @Link(传 this.xxx 会变成单向 @Prop,改不动父状态)', () => { const src = code(SETTINGS_PAGE); const idx = src.indexOf('BackgroundPicker({'); assert.ok(idx > 0, '设置页要挂上 BackgroundPicker'); const mount = src.slice(idx, idx + 600); for (const name of ['bgKind', 'bgPresetId', 'bgDim', 'bgBlur']) { assert.ok(new RegExp(`${name}: \\$${name}`).test(mount), `★ ${name} 没用 $${name} 传 ⇒ 双向绑失效`); assert.ok(!new RegExp(`${name}: this\\.${name}`).test(mount), `★ ${name} 用了 this.${name} ⇒ 那是单向传值,选择器改不动页面的 @State`); } }); /* ────────── ⑪ 「我的」页对齐 WebUI AccountPage(2026-09-17)────────── */ test('★ `/me` 不是路由:MeApi 必须调 `/auth/me`(写错 ⇒ isAdmin 恒 false、管理入口永不显示)', () => { /* * 这是一个**线上真实存在过**的 bug,而且症状极隐蔽: * * `MeApi.get()` 原先写的是 `GET /me`。服务端**根本没有**这个路由 —— * `server/cmd/server/main.go` 只注册了 `r.Get("/auth/me", handler.Me)`, * `/me/*` 下挂的是 mail / sessions / keys / appearance 这些**子资源**。 * * 后果链:这把调用恒 404 → `loadRole()` 恒走 catch → `isAdmin` 恒 false * → 「管理」入口对**包括管理员在内**的所有人永远不显示。 * 而 catch 里是静默的(刻意不弹错,免得用户以为设置页坏了),所以界面上 * 只表现为"这个功能好像没做",没人会想到是路径写错。 * * 实证(2026-09-17,对着线上服务端): * GET /api/v1/me → 404 * GET /api/v1/auth/me → 200 {"user":{"role":"admin",...}} * * 这条同时钉三件事:客户端调的路径、服务端真的注册了这个路径、 * 以及响应形状(`{user:{…}}`)与客户端解析的一致。 */ const adminApi = code(ADMIN_API); assert.match(adminApi, /client\.get\('\/auth\/me'\)/, '★ MeApi.get() 必须调 `/auth/me`(`/me` 不存在 ⇒ 恒 404 ⇒ 管理入口永不显示)'); assert.ok(!/get\('\/me'\)/.test(adminApi), '★ 不得再调 `/me` —— 服务端没这个路由'); // 服务端真的注册了 /auth/me(否则两边都在猜) const mainGo = code(join(ROOT, 'server/cmd/server/main.go')); assert.match(mainGo, /r\.Get\("\/auth\/me",\s*handler\.Me\)/, '服务端要注册 GET /auth/me'); // 响应形状 {user:{…}} —— 与 AdminUserResponse 的字段名一致 const authGo = code(join(ROOT, 'server/internal/handler/auth.go')); assert.match(authGo, /type userOut struct/, '服务端要有 userOut'); /* * `Me` handler 用的是**内联 map** 而不是具名 struct,所以这里判的是 * 「它把 toUserOut 包在 `"user"` 键下」而不是一个不存在的 `meResponse`。 * 客户端 `AdminUserResponse` 的字段名就叫 `user` —— 两边必须对上, * 对不上就是“反序列化后全是空”(界面摆好了但没数据)。 */ const meFn = authGo.slice(authGo.indexOf('func Me(')); assert.match(meFn.slice(0, 400), /map\[string\]interface\{\}\{"user":\s*toUserOut\(u\)\}/, 'Me 的响应形状必须是 {"user": toUserOut(u)}(客户端按 `user` 这个键解析)'); const models2 = code(join(ETS, 'model/Models.ets')); const resp = models2.slice(models2.indexOf('export class AdminUserResponse')); assert.match(resp.slice(0, 200), /user:\s*AdminUser\s*=/, 'AdminUserResponse 的字段名要是 user'); }); test('★ 「我的」页要有 WebUI AccountPage 的八个 section(缺一块就是没对齐)', () => { /* * WebUI `AccountPage.tsx` 的 section 清单(按源码顺序): * ① 基本资料 ② 权限范围 ③ 修改密码 ④ 多账号(AccountList) * ⑤ 客户端连接密钥(KeyPanel) ⑥ 外观(ThemePicker+BackgroundPicker) * ⑦ 登录状态(退出) ⑧ 管理(仅管理员) * * 鸿蒙侧原来只有 ④⑥⑧ 加上推送与添加弹层 —— 差 ①②③⑤⑦ 五块。 * 而 `AuthApi` 里的 `listKeys`/`createKey`/`revokeKey`/`logout` * **一直写好但没人调**,`/auth/password` 客户端根本没包 —— 全是"半成品"。 */ const src = code(SETTINGS_PAGE); // ① 基本资料:六个字段都要有(服务端一直在返回,客户端原来只取 role) for (const label of ['用户名', '显示名', '角色', '状态', '创建时间', '最后登录']) { assert.ok(src.includes(`'${label}'`), `基本资料要有「${label}」行`); } // ② 权限范围:空数组 = 不限(与 WebUI 同义,不是"一个都不能用") assert.ok(src.includes('可调用 Agent') && src.includes('可访问目录'), '权限范围要有「可调用 Agent」「可访问目录」两行'); assert.match(src, /'不限(全部可用)'/, '空清单要显示「不限(全部可用)」—— 空数组的语义是不限,不是"一个都不能用"'); // ③ 修改密码:三个输入框 + 提交 for (const ph of ['当前密码', '新密码(至少 8 位)', '确认新密码']) { assert.ok(src.includes(`'${ph}'`), `修改密码要有「${ph}」输入框`); } assert.match(src, /changePassword\(/, '修改密码要真的调 AuthApi.changePassword'); assert.match(src, /两次密码不一致/, '两次不一致要就地提示(与 WebUI 同一行为)'); // ⑤ 客户端连接密钥 assert.match(src, /listKeys\(\)/, '密钥列表要调 AuthApi.listKeys'); assert.match(src, /createKey\(/, '新建密钥要调 AuthApi.createKey'); assert.match(src, /revokeKey\(/, '吊销密钥要调 AuthApi.revokeKey'); assert.match(src, /全文仅显示这一次/, '新密钥的一次性提示与 WebUI NewKeyBanner 同口径(服务端之后只返回 token_hint)'); // ⑦ 退出登录 assert.match(src, /doLogout\(\)/, '「我的」页要有退出登录'); assert.match(src, /退出登录/, '退出按钮文案'); }); test('★ 退出登录必须清**全部**账号(否则被 LoginPage 快速登录路径立刻恢复)', () => { /* * `LoginPage.aboutToAppear()` 的快速路径是: * `getActiveAccount() !== null` ⇒ 直接 `pushUrl MainPage`,不做任何网络认证。 * * 所以退出时若只在内存里把当前 token 清掉、**库里还留着账号**, * 下一次回到登录页会立刻被自动恢复 ⇒ 看起来"点了退出没反应"。 * 这是鸿蒙特有的路径(WebUI 没有"本机多账号库自动恢复", * 所以它的 `logout()` 只 `set({phase:'anonymous'})` 就够)。 */ /* * ★★ 2026-09-19 修:退出逻辑被抽成了 `api/Logout.ets` 的 `performLogout()`。 * * 为什么要抽(不是为了好看):退出有**两个入口** —— 「我的」页的按钮、 * 宽屏侧栏底部那一簇的退出键。复制一份实现的话迟早分叉,而最容易分叉的 * 恰恰是**最不能少的那一步**(注销推送 token —— 少了它,退出后服务器还会 * 往一台"已退出"的设备推通知)。所以两个入口都走同一个函数。 * * 本判据相应改成**追到实际执行处**:断言「我的」页真的调了 performLogout, * 且 performLogout 里真的清了全部账号。**两半都要断** —— * 只断第一半会放过"performLogout 被改成空实现", * 只断第二半会放过"按钮没接上"(那种情况页面里照样有 performLogout 这几个字)。 * * ⚠️ 别退回"直接匹配 SETTINGS_PAGE 里的 clearAll()"的写法: * 那会随重构假红(今天就是),于是下一个人只会去改判据、而不去看行为。 */ const src = code(SETTINGS_PAGE); assert.match(src, /performLogout\(/, '★ 「我的」页的退出要调 performLogout()(与侧栏底部簇共用同一个实现)'); /* 第二半:那个函数真的清了**全部**账号,而不是只清当前 */ const logoutSrc = code(LOGOUT_MOD); assert.match(logoutSrc, /clearAll\(\)/, '★ 退出要调 AccountManager.clearAll()(清全部账号)'); assert.ok(!/clearActive\(\)/.test(logoutSrc), '不得只清"当前"账号 —— 库里的其余账号会让快速登录路径把退出撤销掉'); // 被调的那个方法真的存在,且真的清空 const mgr = code(ACCOUNT_MGR); assert.match(mgr, /async clearAll\(\): Promise/, 'AccountManager 要有 clearAll()'); const body = mgr.slice(mgr.indexOf('async clearAll()')); assert.match(body.slice(0, 400), /this\.accounts = \[\]/, 'clearAll 要清空账号数组'); assert.match(body.slice(0, 400), /this\.activeId = ''/, 'clearAll 要清空 activeId'); assert.match(body.slice(0, 400), /await this\.persist\(\)/, 'clearAll 要落盘(否则重启后又回来了)'); }); test('★ 「我的」页整页一个滚动容器(排在 flex-1 之后的 section 会被裁掉且滚不到)', () => { /* * WebUI `AccountPage` 把这个坑写在注释里: * 「缺了它的后果:这个页的内容(资料 + 权限 + 改密码 + 密钥 + 退出)比视口高, * 而父级是 overflow-hidden 的 flex 列 —— 超出那段直接被裁掉,没有任何办法滚到。 * 实测 390px 下内容需 860px、容器只有 795px;1280x800 的桌面上同样看不到 * 最后的『退出登录』」。 * * 鸿蒙侧原来的形状**正是那个错的**:账号列表 `layoutWeight(1)`(占满剩余高度), * 后面几块 section 直接排它下面 ⇒ 被挤出可视区。账号越多 / 屏越矮越明显, * 而「退出登录」正好在最下面 —— 等于退不出去。 */ const src = code(SETTINGS_PAGE); assert.match(src, /Scroll\(\)\s*\{/, '整页要有一个 Scroll 容器'); /* * 关键:section 必须在**那个 Scroll 里面**,而不是排在 flex-1 的兄弟位置。 * 判法是取 Scroll 的花括号正文,看各个 section builder 在不在里面。 */ const at = src.indexOf('Scroll()'); assert.ok(at > 0, '要能找到 Scroll'); let depth = 0; let start = src.indexOf('{', at); let end = -1; for (let i = start; i < src.length; i++) { if (src[i] === '{') depth++; else if (src[i] === '}') { depth--; if (depth === 0) { end = i; break; } } } assert.ok(end > start, 'Scroll 的花括号要闭合'); const inside = src.slice(start, end); for (const sec of ['ProfileSection', 'KeySection', 'SecuritySection', 'AdminSection']) { assert.ok(inside.includes(`this.${sec}()`), `★ ${sec} 必须在 Scroll **内部** —— 排在 flex-1 之后会被裁掉且滚不到(WebUI 的注释里就有这个坑)`); } assert.ok(inside.includes('退出登录') || /SecuritySection/.test(inside), '退出登录要在可滚动区域内(否则退不出去)'); // 滚动容器要挂上下渐隐(与列表同一纪律) assert.match(src, /\.fadingEdge\(true,/, '滚动容器要挂 fadingEdge(WebUI .overflow-y-auto 的 mask-image)'); }); test('★ 「我的」页的数据都来自服务端真实字段(不是写死/编造)', () => { /* * 「基本资料」那一块最容易变成"界面摆好了但字段是空的": * `/auth/me` 返回 `status`/`created_at`/`last_login`/`allowed_agents`/`allowed_paths`, * 而客户端 `Me` 原来只声明了 4 个字段 —— 反序列化时多出来的键被丢掉。 */ const models = code(join(ETS, 'model/Models.ets')); const me = models.slice(models.indexOf('export class Me {'), models.indexOf('export class Me {') + 900); for (const f of ['status', 'created_at', 'last_login', 'allowed_agents', 'allowed_paths']) { assert.ok(new RegExp(`${f}:`).test(me), `Me 类要有 ${f} 字段(服务端一直在返回它)`); } // 字段名要和服务端 JSON tag 一致,否则反序列化后是空 const authGo = code(join(ROOT, 'server/internal/handler/auth.go')); /* * 注意 `last_login` / `created_at` 服务端带 `omitempty`(值空时**整个键不出现**), * 所以客户端那两处必须容忍 `undefined` —— 详情页用的是 `?? '-'` 与 * 显式 undefined 判断,不是假定一定有时。 */ for (const tag of ['status', 'created_at', 'allowed_agents', 'allowed_paths', 'last_login']) { assert.ok(new RegExp(`json:"${tag}(,omitempty)?"`).test(authGo), `服务端 userOut 要有 json:"${tag}"`); } // 密钥的"可用/已使用/已过期"要靠这两个字段算(WebUI keyState 同口径) const key = models.slice(models.indexOf('export class UserKey'), models.indexOf('export class UserKey') + 700); assert.ok(key.includes('expires_at') && key.includes('used_at'), 'UserKey 要有 expires_at / used_at(否则算不出"可用/已使用/已过期")'); assert.ok(key.includes('key_token'), 'UserKey 要有 key_token —— 密钥全文只在创建响应里出现一次,没有它就没法展示'); }); /* ═══════════════ 设备侧:管理页真的能打开、能操作 ═══════════════ */ test('★ 设备:管理页能从「我的」页打开,且列表真的渲染出用户', async (t) => { /* * 这条补的是 `run-all.mjs` 的 `STATIC_ONLY` 登记里说的那个缺口: * 「用户管理页:`.ets` 页面要 hvigorw 才能编译、要设备才能点(本机两者都没有)」 * * 上面 27 条判的都是**逻辑与接线形态**。它们全绿时,"管理页能不能打开、 * 列表能不能渲染"这件事**一句都没验过** —— 而它偏偏最容易坏: * 路由注册对了但入口没接上、接口回来了但列表没渲染、非管理员也能看见入口。 * * ★★ 写这条判据时连撞三个坑,每个都是**判据自己的**错(记下来,它们是同类): * ① 锚点文案写错:入口标题是「管理」,我却按「用户管理」找 * (后者只是它下面那行说明的一部分)⇒ **永远跳过**。 * 而那个跳过看起来像"非管理员、行为正确"—— 最危险的那种静默。 * ② 元素在**滚动下方**:dumpLayout 只报可见节点 ⇒ 不滚到底根本搜不到。 * ③ 文字节点不可点:`Text('管理')` 的 `clickable=false`, * 点击挂在包住它的容器上(ArkUI 的常态)。 * * ⇒ 结论:设备判据里"找不到元素"必须先分清是**功能缺失**还是**判据没摆好现场**。 * 前两个坑都是后者,而它们伪装成前者。 */ const D = await import('./lib/harmony-device.mjs'); const hdc = D.findHdc(); if (!hdc || !D.hasTarget(hdc)) { return t.skip('设备不在 —— 本条的设备半边本次不跑(上面静态层仍把住逻辑与接线)'); } assert.ok(await D.launchOurApp(hdc), '要能拉起应用并等到它到前台'); /* * ★★ 先回主界面:前面跑过的设备判据可能把前台留在 **push 出去的页面**上 * (详情页 / 管理页 / 写信页 —— 它们没有侧栏)。实测:设备停在邮件详情页时 * 本判据跑了 201 秒才失败,报"要能进「我的」页"—— 而那是**判据间干扰**, * 不是功能坏了(`backToMain` 的注释里记着同一个根因)。 */ assert.ok(await D.backToMain(hdc), '要能回到主界面(侧栏/底栏可见)'); await new Promise((r) => setTimeout(r, 1500)); const dims = () => { let w = 0; let h = 0; for (const n of D.walk(D.dumpLayout(hdc))) { const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || ''); if (m) { w = Math.max(w, Number(m[1])); h = Math.max(h, Number(m[2])); } } return { w, h }; }; const hasText = (needle) => [...D.walk(D.dumpLayout(hdc))] .some((n) => (n.attributes?.text || '').includes(needle)); /* 已经在管理页了?那直接用(判据不该强求"必须从某处点进来") */ if (!hasText('新建用户')) { const { w: scrW, h: scrH } = dims(); const wide = scrW / scrH > 1.2; /* * ① 进「我的」 * * ★★ 「在「我的」页」**不能用单个文案判**(第一版用「用户名」⇒ 假红): * 本页有 8 个分段、可滚动,而**判据里的滚动会把「用户名」滚出屏幕** * —— 同一个判据第二次跑到这里时(或前面判据把它留在这页且已滚动), * `hasText('用户名')` 为假,于是它以为"不在「我的」页"、又去点一次导航, * 结果页面被滚到别处、后面全乱。 * * 改用**分段标记**判定:这些文案分布在本页的不同段落, * 滚动到任何位置都至少能看见一个。 */ const inMePane = () => ['用户名', '多账号', '外观', '客户端连接密钥', '修改密码'] .some((marker) => hasText(marker)); if (!inMePane()) { if (wide) { const avatar = [...D.walk(D.dumpLayout(hdc))].find((n) => { const a = n.attributes || {}; if (a.clickable !== 'true') return false; const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || ''); if (!m) return false; const [, , y1, x2, y2] = m.map(Number); return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && (y2 - y1) > 80; }); if (!avatar) return t.skip('宽屏侧栏头像找不到 —— 无法进「我的」'); const c = D.boundsCenter(avatar.attributes.bounds); assert.ok(D.tap(hdc, c.cx, c.cy), '要能点到侧栏头像'); } else { assert.ok(D.tapText(hdc, '我的'), '要能点到「我的」'); } let landed = false; for (let i = 0; i < 20; i++) { await new Promise((r) => setTimeout(r, 500)); if (inMePane()) { landed = true; break; } } assert.ok(landed, '要能进「我的」页'); } /* * ② 找「管理」入口(在 8 个分段之后,屏幕外)。 * * ★★ 滚动必须**小步 + 每步检查**(我前两版都栽在这里): * 大步滑(`0.8h → 0.25h`)会**跳过**那个入口 —— 实测:滚 1~2 次时它在屏外, * 第 3 次才出现(`y=1994`,屏高 2232,贴着底)。而"滑到底再看" * 会因为惯性/回弹停在别处,于是"找不到"——看起来像功能缺失。 * * 现在每步只滑 ~1/4 屏,滚动后立刻查一次。 */ /* * ★★ 第四版(诊断逼出来的真相):小步滚也会**越过**它。 * 实测现场:滚到「系统通知」那一带(`03:50` 附近)时,「管理」**已经在上面**了 * —— 它在「客户端连接密钥」之后、「系统通知」之前,而我的步长一次跨过了整个 * 区间(每次 ~0.2 屏,而它只占 ~0.04 屏)。 * * ⇒ 正确策略是**先滚到底,再从下往上找**(到底之后它一定在屏幕内或其上方, * 再按小步回滚就必然扫过它)。"只管往前找"在不均匀列表上必然漏。 */ const scrollDown = async (steps) => { for (let i = 0; i < steps; i++) { D.swipe(hdc, Math.round(scrW * 0.5), Math.round(scrH * 0.7), Math.round(scrW * 0.5), Math.round(scrH * 0.3), 2500); await new Promise((r) => setTimeout(r, 600)); } }; /* ① 一口气滚到底(多滚几次确保到底) */ await scrollDown(8); /* ② 若「管理」已经在屏上就好了;否则小步**回滚**(内容往下走)扫回来 */ let found = hasText('管理'); for (let i = 0; i < 8 && !found; i++) { D.swipe(hdc, Math.round(scrW * 0.5), Math.round(scrH * 0.35), Math.round(scrW * 0.5), Math.round(scrH * 0.6), 1500); await new Promise((r) => setTimeout(r, 650)); found = hasText('管理'); } if (!found) { const seen = [...D.walk(D.dumpLayout(hdc))] .map((n) => (n.attributes?.text || '').trim()) .filter(Boolean).slice(-20).join(' | '); return t.skip(`滚到底后仍找不到「管理」入口(非管理员时这是正确行为)—— 末尾读到:${seen}`); } /* * ③ 点它。文字节点不可点(`.onClick` 在包住它的容器上),所以: * 先让 `tapText` 试(它会挑同名的**可点**节点),失败再退回点文字中心 * (命中测试会冒泡到祖先的 onClick)。 * * ★ 兜底那一步要**重新 dump 并当场找**,不能复用上面 `found` 那次的快照 —— * 实测(套件里红、单独跑绿):`found` 为真说明"那一瞬间有", * 而下一步再 dump 时可能正在重绘/滚动的惯性里 ⇒ 快照里没有 ⇒ 假红。 * 设备上的两次 dump 之间窗口状态是可以变的,别假设它们一致。 */ /* * ★★ 第三版:**直接按文字节点的矩形中心点**,不走 `tapText`。 * * 为什么 `tapText` 在这里不可靠(实测:单独跑绿、套件里红): * 它先找"同名且**可点**"的节点;这个入口的 `.onClick` 挂在 * **外面那层 `Column`** 上(`SettingsPage.ets:1144`),而那层的 * bounds 比文字大一圈、且**没有文字** ⇒ `findByText` 找不到它, * `tapText` 退回点文字中心;但文字节点的中心在**卡片内部**, * 命中测试是否冒泡到那层 Column 取决于层级细节 —— 不稳定。 * * 而实测(手动点 `(1592, 2017)`,即文字所在行的中心)**每次都进管理页**。 * ⇒ 用文字节点的**矩形中心**直接点,这条路径最接近"人点那一行"。 */ /* 诊断入口:`AGENTMAIL_ADMIN_DEBUG=1` 时把现场打出来(排查设备判据专用) */ const dbg = process.env.AGENTMAIL_ADMIN_DEBUG ? (m) => console.log(`[admin] ${m}`) : () => {}; const snap = D.dumpLayout(hdc); const node = [...D.walk(snap)] .find((n) => (n.attributes?.text || '').trim() === '管理'); if (!node) { const seen = [...D.walk(snap)] .map((n) => (n.attributes?.text || '').trim()).filter(Boolean).slice(-18).join(' | '); dbg(`找不到「管理」节点;末尾文本:${seen}`); } assert.ok(node, '滚到之后「管理」文字节点应在 dump 里'); const c = D.boundsCenter(node.attributes.bounds); assert.ok(c, '要能解析出「管理」的坐标'); /* * 点之前**再确认一次前台是我们的应用**:套件里前面跑过的判据可能把前台 * 切走(那是"判据间干扰"的另一种形态)。切走了就不是"点不到入口", * 而是"点在了别人的界面上"—— 后者必须显式失败,不能算通过。 */ assert.equal(D.foregroundBundle(hdc), D.ourBundle(), '点之前前台必须还是我们的应用(前面判据若切走了前台,这里点的是别人的界面)'); const ok = D.tap(hdc, c.cx, c.cy); dbg(`点 (${c.cx},${c.cy}) ⇒ ${ok}`); assert.ok(ok, `要能点到「管理」入口(坐标 ${c.cx},${c.cy})`); } /* ④ 等页面真的切过去 */ let onAdmin = false; for (let i = 0; i < 20; i++) { await new Promise((r) => setTimeout(r, 500)); if (hasText('新建用户')) { onAdmin = true; break; } } assert.ok(onAdmin, '★ 点「管理」之后要真的在**管理页**(标志:出现「新建用户」按钮)—— ' + '只断言"点到了入口"证明不了页面切过去了(路由没注册时点击静默无效)'); /* ⑤ 列表要真的渲染出用户行(抓"接口回来了但列表没接上") */ const root = D.dumpLayout(hdc); const texts = [...D.walk(root)].map((n) => (n.attributes?.text || '').trim()).filter(Boolean); const userRows = texts.filter((x) => /用户管理|启用|管理员|用户$/.test(x)); assert.ok(userRows.length > 0, '★ 管理页要真的渲染出**用户行**(带角色/状态的那种)—— ' + `只有标题而没有行,说明数据没渲染。实际读到:${texts.slice(0, 25).join(' | ')}`); }); test('★ 详情页的两个动作球必须**分开摆**(Stack 的同一个角 + 各自 margin = 重叠)', () => { /* * ★★ 2026-09-19 真 bug,设备实测撞出来的: * * 详情页右下角有两个动作球(转发 + 回复)。第一版把它们**各自**写在 * `Stack({ alignContent: Alignment.BottomEnd })` 里、各带一个 margin —— * 于是它们**几乎完全重叠**。实测 dump 的 bounds(密度 2.875): * * 转发 [2984,2010][3122,2148] * 回复 [2949,1998][3110,2159] * 重叠区 x ∈ [2984, 3110] * * 屏幕上只看得到一个球,**转发入口等于不存在**。 * 根因是 ArkUI 的 `Stack.alignContent` 把每个子元素都摆到**同一个角**, * margin 只是各自微调 —— 想并排就得用**一个容器**把子元素排起来。 * * 判据形状:找"同一容器下的多个圆形 Button,各自只带 margin"这种写法。 * 正确写法是外面包一层 `Row({ space })`。 */ const src = code(join(ETS, 'pages/MailDetailPage.ets')); /* * 找 `Stack({ alignContent: ... })` 块里**直接**出现的两个以上圆形按钮 * (`.borderRadius(N)` 且 N 使它是圆:宽高等于 2N)。 * 简化判定:在同一个 Stack 块内出现 ≥2 处 `.borderRadius(` + 各自 `.margin(` * 而没有包在 Row 里。 */ const stackBlocks = [...src.matchAll(/Stack\(\{[^}]*alignContent[^}]*\}\)\s*\{([\s\S]*?)\n\s{8}\}/g)]; const bad = []; for (const m of stackBlocks) { const body = m[1]; const circles = (body.match(/\.borderRadius\(\d+\)/g) ?? []).length; if (circles >= 2 && !/Row\(\{\s*space:/.test(body)) { const line = src.slice(0, m.index).split('\n').length; bad.push(`MailDetailPage.ets:${line}(Stack 里有 ${circles} 个圆角元素且没有 Row 包住)`); } } assert.deepEqual(bad, [], '★ `Stack.alignContent` 会把**每个**子元素都摆到同一个角 —— ' + '多个圆形按钮各自带 margin 时会**重叠**(实测:屏幕上只看得到一个球)。\n' + `命中:\n ${bad.join('\n ')}\n` + '正确写法:外面包一层 `Row({ space: N })`,由 Row 负责把它们排开、' + 'Row 自己带 margin 到角落。'); }); test('★ 底部弹层必须有明确高度(否则键盘一弹,按钮全被顶出屏幕)', () => { /* * ★★ 2026-09-19 真 bug(与上面那条同一次实测): * * 详情页的转发弹层第一版**没有高度**(只有 `padding(16)`)⇒ 高度完全由内容决定。 * 而 ArkUI 默认的键盘避让是 `KeyboardAvoidMode.OFFSET`(整体上移)—— * 上移之后,弹层里的 `TextArea` 与「取消 / 转发」按钮**跑到键盘下面**去了。 * 实测截图:只看得见收件人输入框 + 键盘,「转发」按钮**点不到**。 * * 修法两半(缺一不可): * ① 弹层要有**明确高度**(与回复弹层一致,`height('60%')`)—— * 内容才能在里面重新分配空间; * ② 会撑高的那个子元素(说明框)要用 `layoutWeight(1)` 而不是固定高度 —— * 键盘顶上来时它自己缩短,把按钮留在屏内。 */ const src = code(join(ETS, 'pages/MailDetailPage.ets')); /* 找所有"底部弹层"(`borderRadius({ topLeft: ... })` 是这类弹层的标志) */ const sheets = [...src.matchAll(/\.borderRadius\(\{\s*topLeft:\s*\d+/g)]; assert.ok(sheets.length >= 2, `详情页应有多个底部弹层(回复 / 转发)—— 实际找到 ${sheets.length} 个`); /* * 逐个弹层:从 borderRadius 往回找它的高度声明。 * 在 `.borderRadius({ topLeft` 之前的一段里找 `.height(`。 */ const noHeight = []; for (const m of sheets) { const before = src.slice(Math.max(0, m.index - 1200), m.index); /* 最近的 .height(...) 要在这一段里(弹层的修饰符链上) */ if (!/\.height\(('100%'|\d+|'[0-9]+%')\)/.test(before)) { const line = src.slice(0, m.index).split('\n').length; noHeight.push(`MailDetailPage.ets:${line}`); } } assert.deepEqual(noHeight, [], '★ 底部弹层必须有明确高度 —— 没有高度时它的尺寸由内容决定,' + '而键盘弹出(默认 `KeyboardAvoidMode.OFFSET`)会把内容整体上移,' + '**「取消 / 转发」按钮会被顶到键盘下面**(实测截图:按钮点不到)。\n' + `缺高度的弹层:\n ${noHeight.join('\n ')}`); /* ② 说明框用 layoutWeight 而不是固定高度(否则它撑住高度、按钮仍被挤出) */ const ta = /TextArea\(\{[\s\S]{0,200}?转发说明[\s\S]{0,400}?\n\s*\}\)([\s\S]{0,300}?)\.onChange/.exec(src); assert.ok(ta, '要能找到转发弹层的说明框(TextArea)'); assert.match(ta[1], /\.layoutWeight\(1\)/, '★ 转发说明框要用 `layoutWeight(1)`(不是固定 `height(N)`)—— ' + '弹层高度固定后,只有它会伸缩才能把「取消 / 转发」按钮留在键盘之上'); });