两份审查报告(`docs/reviews/electron-gui-review.md` /
`harmony-client-review.md`)里两条**数据隔离**缺陷。
## ① 换身份不清数据 ⇒ 在新账号的界面下显示旧账号的邮件
`setActive` 之后 `api/config` 单例里的 API_BASE 与 bearer 就翻到了新账号,
于是此后每个请求都带**新账号**的凭证。而各 store 里还留着**旧**账号的:
· mailStore.sent / currentMail
· sessionStore.sessions / currentSession / currentSessionMails
· contactStore.contacts / archivedContacts
⇒ 肉眼完全看不出来(不报错、不空屏),而**从旧视图发出的写操作**
(归档 / 转发 / 批准权限)改的是**新账号**。
新增 `src/lib/resetAccountData.ts` —— **一处实现,三个入口都调它**:
① 主动切账号(AccountSwitcher.pick)
② 登出(authStore.logout)
③ 任意接口 401(api/client.ts 的 unauthorized 回调 → markAnonymous)
只在 ① 里清是最容易漏的那种做法:② 和 ③ 各自还会重新泄露一次,
而它们都不在切换账号的代码路径上,grep 也找不到。
**身份变化有三条路径,清空也该有三条。**
★ 只碰**数据** store;`uiStore` 的 reset 仍由 App.tsx 负责
(它还要复位窄屏分栏、写信态那些纯界面状态)。
判据:`test/stores/resetAccountData.test.ts`(4 格)。
## ② 鸿蒙管理台门禁**失败开放**(fail-open)
`AdminUsersPage.ets` 原来的条件是 `roleKnown && !this.isAdmin`,
于是 `roleKnown === false`(loadRole() 失败、**身份还没读到**)
落进 else 分支 ⇒ **把完整管理台整个渲染出来**。
一次网络抖动 = 管理入口对所有人可见。
讽刺的是该文件自己的头注释写的就是正确规则
(「不能把读不到当成是管理员」)—— 代码做的正是这条注释禁止的事。
⇒ 改三态:`!roleKnown` 显示「正在确认身份…」、`!isAdmin` 显示墙、
否则管理台。
服务端 `middleware/user.go` 的 `AdminOnly` 仍在,所以**不是越权**;
但非管理员会看到完整用户列表、建号表单、改密入口 ——
属于客户端信息泄露 + 无意义的失败请求风暴。
581 lines
35 KiB
JavaScript
581 lines
35 KiB
JavaScript
/*
|
||
* 壁纸上传(P4c)的判据 —— **行为**判据(`model/ImagePrep.ts` 真跑)+ 形态判据(`.ets` 读源码)。
|
||
*
|
||
* ── 这一层判的是"数值与策略",不是"图好不好看" ──
|
||
*
|
||
* 压图那一段本机**跑不了**(要 `@ohos.multimedia.image` + 真机)。
|
||
* 所以能做的是把**决定行为的那些数**抽到纯逻辑层(`model/ImagePrep.ts`,零 `@ohos` 依赖)
|
||
* 并在这里真跑它们 —— 于是"缩到多大 / 什么质量 / 什么时候退第二档 / 什么时候放弃"
|
||
* 全都有判据,而不是埋在 `.ets` 的 async 函数里只能拿正则猜。
|
||
*
|
||
* ⚠️ **本判据不能证明** "压出来的图能看"、"真机上 picker 能打开"、
|
||
* "服务端真的收下了" —— 那几条见文件末的未验清单。
|
||
*/
|
||
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 PREP_TS = join(ETS, 'model/ImagePrep.ts');
|
||
const PICKER = join(ETS, 'common/BackgroundPicker.ets');
|
||
const SETTINGS_PAGE = join(ETS, 'pages/SettingsPage.ets');
|
||
|
||
const P = await import(pathToFileURL(PREP_TS).href);
|
||
|
||
/*
|
||
* 服务端壁纸上限 —— **从 Go 源里读出来,不许硬编码**。
|
||
*
|
||
* ★ 原来这里是 `const SERVER_LIMIT = 4 << 20;`,注释还写着"(`appearance.go` 的
|
||
* `appearanceMaxBytes()` 默认值)"—— 但**它一个字节的 Go 源都没读**。
|
||
* 实测(dsh 2026-09-19):把 Go 里那个默认值改成 `8 << 20`,本文件
|
||
* **29 pass / 0 fail 一个字都没变** ⇒ 真漂移了它不会红。
|
||
*
|
||
* ★★ 而**我第一版修法读错了地方**(自查抓出,如实记下):
|
||
* 我去读 `appearance.go` 的 `return 4 << 20` —— 那是**兜底分支**
|
||
* (`if config.C != nil && config.C.MaxAppearanceBytes > 0` 之后才轮到它)。
|
||
* 生产里 `config.C` 非 nil ⇒ **真正生效的值来自 `config.go` 里
|
||
* `MaxAppearanceBytes: getEnvInt64("AGENTMAIL_MAX_APPEARANCE_BYTES", 4<<20)`**。
|
||
* ⇒ 只读兜底分支的修法,**在"有人分了 config 默认值"时会漏**。
|
||
*
|
||
* ⇒ 现在**两处都读,并要求它们相等**:
|
||
* · `config.go` 的 env 默认值 = **运行时真正生效的那一处**(生产);
|
||
* · `appearance.go` 的 `return` = 兜底分支;
|
||
* 两者不一致本身就报红(那意味着"config 没设时"与"config 设了但为 0"走不同的门)。
|
||
* ★ **仍然读不到运行期实际值**:`AGENTMAIL_MAX_APPEARANCE_BYTES` 可以被环境变量覆盖 ⇒
|
||
* 静态读源码永远只能说"与**默认值**对齐"。要判运行值得真起服务端。别把本条读成
|
||
* "413 已经不可能发生"(见 `CRITERIA.md §16.4` 的标签范围)。
|
||
*/
|
||
const APPEARANCE_GO = join(ROOT, 'server/internal/handler/appearance.go');
|
||
const CONFIG_GO = join(ROOT, 'server/internal/config/config.go');
|
||
|
||
/** 从 `appearance.go` 的 `appearanceMaxBytes()` **兜底分支**取默认值 */
|
||
function fallbackLimit() {
|
||
const src = code(APPEARANCE_GO);
|
||
const fn = /func appearanceMaxBytes\(\)[^{]*\{([\s\S]*?)\n\}/.exec(src);
|
||
if (!fn) return { err: `在 appearance.go 里找不到 func appearanceMaxBytes()` };
|
||
// 取函数体内**最后**一个 return(前面的 return 是配置分支)
|
||
const all = [...fn[1].matchAll(/return\s+(\d+)\s*<<\s*(\d+)/g)];
|
||
if (!all.length) return { err: 'appearanceMaxBytes() 里没有 `return <n> << <m>` 兜底值' };
|
||
const m = all[all.length - 1];
|
||
return { value: Number(m[1]) * 2 ** Number(m[2]), raw: `(${m[1]} << ${m[2]})` };
|
||
}
|
||
|
||
/** 从 `config.go` 的 `MaxAppearanceBytes:` 取 env 默认值 —— **这才是生产里生效的那一处** */
|
||
function configLimit() {
|
||
const src = code(CONFIG_GO);
|
||
const m = /MaxAppearanceBytes:\s*getEnvInt64\(\s*"[^"]*"\s*,\s*(\d+)\s*<<\s*(\d+)\s*\)/.exec(src);
|
||
if (!m) return { err: '在 config.go 里找不到 `MaxAppearanceBytes: getEnvInt64("…", <n> << <m>)`' };
|
||
return { value: Number(m[1]) * 2 ** Number(m[2]), raw: `(${m[1]} << ${m[2]})` };
|
||
}
|
||
|
||
const LIMIT_FALLBACK = fallbackLimit();
|
||
const LIMIT_CONFIG = configLimit();
|
||
// 生效值以 config 那处为准(生产里 config.C 非 nil,走的是它)
|
||
const SERVER_LIMIT_SRC = LIMIT_CONFIG;
|
||
const SERVER_LIMIT = SERVER_LIMIT_SRC.value;
|
||
|
||
/* ────────────────────── ① 缩放 ────────────────────── */
|
||
|
||
test('缩放:**横竖都不超过**最长边(竖拍按宽算会超,所以必须除以 max(w,h))', () => {
|
||
const sizes = [
|
||
[4000, 3000], [3000, 4000], // 横 / 竖
|
||
[8000, 600], [600, 8000], // 极端长条
|
||
[1080, 1080], [2560, 2560],
|
||
[5120, 2880], [1, 1], [1, 5000]
|
||
];
|
||
for (const [w, h] of sizes) {
|
||
const s = P.scaleToMaxEdge(w, h, P.MAX_EDGE);
|
||
const longest = Math.max(s.width, s.height);
|
||
assert.ok(longest <= P.MAX_EDGE,
|
||
`★ ${w}×${h} 缩成 ${s.width}×${s.height}:最长边 ${longest} 超过 MAX_EDGE=${P.MAX_EDGE}` +
|
||
'(竖拍照片会被按宽算的公式放大到超出最长边)');
|
||
assert.ok(s.width >= 1 && s.height >= 1, `${w}×${h} 缩出了 0 边(0 边会让解码直接抛)`);
|
||
}
|
||
});
|
||
|
||
test('缩放:**不放大小图**(放大同时变糊与变大,而"变大"浪费掉那道字节上限)', () => {
|
||
const s = P.scaleToMaxEdge(800, 600, P.MAX_EDGE);
|
||
assert.deepEqual({ w: s.width, h: s.height }, { w: 800, h: 600 },
|
||
`★ 800×600 被放大成 ${s.width}×${s.height}:小图不该被放大`);
|
||
const tiny = P.scaleToMaxEdge(320, 240, P.MAX_EDGE);
|
||
assert.deepEqual({ w: tiny.width, h: tiny.height }, { w: 320, h: 240 }, '更小的图更不该放大');
|
||
});
|
||
|
||
test('缩放:保持长宽比(差一像素的舍入可以,比例不能变)', () => {
|
||
for (const [w, h] of [[4000, 3000], [3840, 2160], [3000, 4000]]) {
|
||
const s = P.scaleToMaxEdge(w, h, P.MAX_EDGE);
|
||
const before = w / h;
|
||
const after = s.width / s.height;
|
||
assert.ok(Math.abs(before - after) / before < 0.01,
|
||
`★ ${w}×${h} → ${s.width}×${s.height}:长宽比从 ${before.toFixed(4)} 变成 ${after.toFixed(4)}(照片会被拉变形)`);
|
||
}
|
||
});
|
||
|
||
test('缩放:退化输入不抛(0 / 负数边)', () => {
|
||
for (const [w, h] of [[0, 0], [-1, 100], [0, 500]]) {
|
||
const s = P.scaleToMaxEdge(w, h, P.MAX_EDGE);
|
||
assert.ok(Number.isFinite(s.width) && Number.isFinite(s.height),
|
||
`${w}×${h} 给出了非有限值 ${s.width}×${s.height}`);
|
||
}
|
||
});
|
||
|
||
/* ────────────────────── ② 两档策略 ────────────────────── */
|
||
|
||
test('两档:首档 2560/0.85,超限档 1280/0.78(与 WebUI `prepareImage` 同值)', () => {
|
||
const plan = P.planCompress(4000, 3000);
|
||
assert.equal(plan.passes.length, 2, '★ 档数不是 2:少一档 ⇒ 第一档超限后没有退路,直接失败');
|
||
const [first, second] = plan.passes;
|
||
assert.equal(first.maxEdge, 2560, '首档最长边');
|
||
assert.equal(first.quality, 0.85, '首档质量');
|
||
assert.equal(first.attempt, 1, '首档 attempt');
|
||
assert.equal(second.maxEdge, 1280, '★ 退档最长边不是 1280(WebUI 用 MAX_EDGE/2)');
|
||
assert.equal(second.quality, 0.78, '退档质量');
|
||
assert.equal(second.attempt, 2, '退档 attempt');
|
||
assert.ok(second.maxEdge < first.maxEdge && second.quality < first.quality,
|
||
'★ 第二档必须**同时**更小更低质:只降一个的话省不下来多少(分辨率不变时质量降 0.07 几乎不省)');
|
||
});
|
||
|
||
test('两档:第二档的估算体积**明显**小于第一档(否则退档没有意义)', () => {
|
||
const plan = P.planCompress(4000, 3000);
|
||
const a = P.estimateJpegBytes(2560, 1920, plan.passes[0].quality);
|
||
const b = P.estimateJpegBytes(1280, 960, plan.passes[1].quality);
|
||
assert.ok(b < a / 2,
|
||
`★ 退档只小了 ${(100 - b / a * 100).toFixed(0)}%(${a} → ${b} 字节)⇒ 这一档救不回"首档超限"的情况`);
|
||
});
|
||
|
||
test('计划的目标尺寸按**首档**算(界面显示"将缩到 W×H",用户看的是第一档的结果)', () => {
|
||
const plan = P.planCompress(4000, 3000);
|
||
assert.deepEqual({ w: plan.targetWidth, h: plan.targetHeight }, { w: 2560, h: 1920 },
|
||
'目标尺寸要等于首档缩放结果');
|
||
const scaled = P.scaleToMaxEdge(4000, 3000, plan.passes[0].maxEdge);
|
||
assert.deepEqual({ w: plan.targetWidth, h: plan.targetHeight }, { w: scaled.width, h: scaled.height },
|
||
'★ 目标尺寸与 passes[0] 的 maxEdge 算出来的不一致 ⇒ 界面写的和实际压的不是一件事');
|
||
});
|
||
|
||
/* ────────────────────── ③ 估算与真实字节数 ────────────────────── */
|
||
|
||
test('估算:随像素数与质量**单调**增(否则"要不要退档"的判断会给出反直觉的结论)', () => {
|
||
const base = P.estimateJpegBytes(1000, 1000, 0.85);
|
||
assert.ok(P.estimateJpegBytes(2000, 1000, 0.85) > base, '像素多一倍,估算该更大');
|
||
assert.ok(P.estimateJpegBytes(1000, 2000, 0.85) > base, '竖过来也该更大');
|
||
assert.ok(P.estimateJpegBytes(1000, 1000, 0.95) > base, '质量更高,估算该更大');
|
||
assert.ok(P.estimateJpegBytes(1000, 1000, 0.5) < base, '质量更低,估算该更小');
|
||
});
|
||
|
||
test('★ 服务端上限**真的从 Go 源读出来了** —— 两处都读到、且两处相等(读不到必须红,不许静默回退)', () => {
|
||
assert.ok(!SERVER_LIMIT_SRC.err,
|
||
`★ 读不出生效上限(config.go):${SERVER_LIMIT_SRC.err} ⇒ 下面两条"余量"断言就退化成**
|
||
用我猜的数去比客户端**(那正是这次修掉的那条缝:注释说是服务端的值,实际一个字节没读)`);
|
||
assert.ok(!LIMIT_FALLBACK.err,
|
||
`★ 读不出兜底上限(appearance.go):${LIMIT_FALLBACK.err} ⇒ 兜底分支没人守`
|
||
+ '(config 没设/为 0 时走的是它,与生产走的是**不同的门**)');
|
||
assert.equal(SERVER_LIMIT, SERVER_LIMIT_SRC.value,
|
||
'客户端用的上限与从 Go 源解析出来的值不一致 ⇒ 解析没接上');
|
||
// ★ 两处必须相等:不相等意味着"config 为 nil 时"与"config 设了"走不同的门
|
||
assert.equal(SERVER_LIMIT, LIMIT_FALLBACK.value,
|
||
`★ 两处默认值不一致:config.go ${SERVER_LIMIT} ≠ appearance.go 兜底 ${LIMIT_FALLBACK.value}`
|
||
+ ' ⇒ config 未注入时服务端会用另一个上限(客户端余量就是按错的那个算的)');
|
||
// 上限本身要是个合理的正数(防止解析出 0 让"小于上限"变成恒真)
|
||
assert.ok(SERVER_LIMIT > 0, `★ 解析出的服务端上限是 ${SERVER_LIMIT} ⇒ 不是有效上限`);
|
||
// ⚠️ 标签范围:这只证明"与**默认值**对齐",不证明"与运行值对齐"——
|
||
// AGENTMAIL_MAX_APPEARANCE_BYTES 可覆盖(见 §16.4)
|
||
});
|
||
|
||
test('估算:**不超过**服务端那道门的那一档,估算值要落在上传上限之内', () => {
|
||
// 2560×1920 是 4000×3000 缩到首档后的实际尺寸 —— 正常照片走的就是这一档
|
||
const est = P.estimateJpegBytes(2560, 1920, 0.85);
|
||
assert.ok(est < P.MAX_UPLOAD_BYTES,
|
||
`★ 正常照片首档估算 ${est} 就已经超过上传上限 ${P.MAX_UPLOAD_BYTES} ⇒ 每张照片都会被白白多压一档`);
|
||
assert.ok(est > P.MAX_UPLOAD_BYTES / 4,
|
||
`★ 正常照片首档估算只有 ${est}(上限的 ${(est / P.MAX_UPLOAD_BYTES * 100).toFixed(0)}%)⇒ 上限压得过狠,白扔分辨率`);
|
||
});
|
||
|
||
test('上限:**留在**服务端那道门之内,且留出了余量(贴着上限会在服务端调小后变 413)', () => {
|
||
assert.ok(P.MAX_UPLOAD_BYTES < SERVER_LIMIT,
|
||
`★ 客户端上限 ${P.MAX_UPLOAD_BYTES} ≥ 服务端 ${SERVER_LIMIT} ⇒ 客户端会放过去一个必然 413 的东西`);
|
||
assert.ok(P.MAX_UPLOAD_BYTES > SERVER_LIMIT * 0.7,
|
||
`★ 客户端上限只有服务端的 ${(P.MAX_UPLOAD_BYTES / SERVER_LIMIT * 100).toFixed(0)}% ⇒ 白扔分辨率`);
|
||
});
|
||
|
||
test('真实字节数:**严格大于**上限才退档(等于上限要放行)', () => {
|
||
assert.equal(P.shouldRetryWithActual(P.MAX_UPLOAD_BYTES - 1), false, '差一字节不该退档');
|
||
assert.equal(P.shouldRetryWithActual(P.MAX_UPLOAD_BYTES), false,
|
||
'★ 等于上限被判定为要退档 ⇒ 多压一档,白损失清晰度(服务端那边 `>` 才是拒)');
|
||
assert.equal(P.shouldRetryWithActual(P.MAX_UPLOAD_BYTES + 1), true, '超过一字节就该退档');
|
||
assert.equal(P.shouldRetryWithActual(0), false, '0 字节不退档');
|
||
});
|
||
|
||
test('估算**不是**测量:源码里要写明"拿到真实长度后再判一次"', () => {
|
||
const src = prose(PREP_TS);
|
||
assert.ok(/shouldRetryWithActual/.test(src) && /真实/.test(src),
|
||
'★ 估算函数的注释里没写"要拿真实字节数再判一次" ⇒ 下一个人会把估算当测量值用,' +
|
||
'于是"估出来没超、实际超了"的图会被直传成 413');
|
||
});
|
||
|
||
/* ────────────────────── ④ 入口检查:每条拒绝路径都有原因 ────────────────────── */
|
||
|
||
test('入口检查:非图片 / 超过 20MB / 正好 20MB 三条边界的判定与文案', () => {
|
||
const ok = P.judgePick(3 * 1024 * 1024, 'image/jpeg');
|
||
assert.equal(ok.ok, true, '正常照片该放行');
|
||
assert.equal(ok.reason, '', '放行时不该带原因');
|
||
|
||
const notImage = P.judgePick(1000, 'application/pdf');
|
||
assert.equal(notImage.ok, false, '★ 非图片被放行了 ⇒ 会走到解码那一步才炸');
|
||
assert.equal(notImage.reason, P.NOT_IMAGE_REASON, '非图片的原因要用常量(文案改了判据要跟着红)');
|
||
assert.ok(notImage.reason.length > 0, '★ 拒绝必须**带原因**:静默失败在 WebUI 那边踩过(P4c 第②条)');
|
||
|
||
const tooBig = P.judgePick(P.MAX_SOURCE_BYTES + 1, 'image/jpeg');
|
||
assert.equal(tooBig.ok, false, '超过 20MB 该拒');
|
||
assert.equal(tooBig.reason, P.SOURCE_TOO_LARGE_REASON);
|
||
assert.ok(tooBig.reason.length > 0, '★ 拒绝必须带原因');
|
||
|
||
assert.equal(P.judgePick(P.MAX_SOURCE_BYTES, 'image/jpeg').ok, true,
|
||
'★ 正好 20MB 被拒了 ⇒ 边界用错(WebUI 是 `> 20MB` 才拒)');
|
||
|
||
for (const mime of ['image/jpeg', 'image/png', 'image/webp', 'image/gif']) {
|
||
assert.equal(P.judgePick(1000, mime).ok, true, `${mime} 是服务端认的图片类型,该放行`);
|
||
}
|
||
});
|
||
|
||
test('入口检查:先判类型再判体积(非图片且超大时给的是"不是图片",不是"太大")', () => {
|
||
const both = P.judgePick(999 * 1024 * 1024, 'application/zip');
|
||
assert.equal(both.reason, P.NOT_IMAGE_REASON,
|
||
'★ 两条都不满足时给了"太大" ⇒ 用户会去裁剪一个本来就不是图片的文件');
|
||
});
|
||
|
||
test('入口估算:偏小的一侧才安全(估偏大会**误拒正常照片**)', () => {
|
||
// 12MP 手机照片(4032×3024)的典型 JPEG 大小是 3–5MB
|
||
const est = P.estimateSourceBytes(4032, 3024);
|
||
assert.ok(est < P.MAX_SOURCE_BYTES,
|
||
`★ 一张 4032×3024 的普通手机照片直接被判成"超过 20MB"(估出 ${est})⇒ 误拒正常需求`);
|
||
assert.ok(est > 1024 * 1024,
|
||
`估出 ${est} 太小 ⇒ 这道粗筛形同没有(那个系数写成 0.35 是有意的:4032×3024×0.35 ≈ 4.1MB,` +
|
||
'正好落在手机照片的真实区间里)');
|
||
});
|
||
|
||
/* ────────────────────── ⑤ 上传失败必须带原因 ────────────────────── */
|
||
|
||
test('上传失败:服务端文案**原样**透出(415/413 的中文文案是唯一能让人立刻改的东西)', () => {
|
||
const serverMsg = '壁纸必须是图片(image/png、image/jpeg、image/webp、image/gif)';
|
||
const out = P.uploadFailureHint(serverMsg);
|
||
assert.ok(out.includes(serverMsg),
|
||
`★ 服务端文案被改写了:「${serverMsg}」→「${out}」⇒ 用户只知道"失败了",不知道该换成什么格式`);
|
||
assert.ok(out.length > 0);
|
||
});
|
||
|
||
test('上传失败:服务端**没给**文案时也要说清"没给原因"(别只说"上传失败")', () => {
|
||
const out = P.uploadFailureHint('');
|
||
assert.ok(/没(有)?给/.test(out) || /未/.test(out),
|
||
`★ 兜底句「${out}」没说原因缺席 ⇒ 用户分不清"服务端拒了"和"网断了"`);
|
||
});
|
||
|
||
test('成功文案要说"以服务端为准"(P4c 第③条:上传成功后仍以服务端为权威)', () => {
|
||
assert.ok(/服务端/.test(P.UPLOAD_OK_HINT),
|
||
`★ 成功文案「${P.UPLOAD_OK_HINT}」没提服务端 ⇒ 用户不知道这张壁纸会不会跟着账号走`);
|
||
// ★ 重新同步在**页面**层(组件只负责"报结果",见 BackgroundPicker 文件头的分工)。
|
||
// 我第一版把这条判在组件上,判据直接红了 —— 红得对:那是真位置不同,不是漏实现。
|
||
const page = code(SETTINGS_PAGE);
|
||
const picker = code(PICKER);
|
||
assert.ok(/onUploaded\(/.test(picker), '上传成功要通知页面(组件不自己宣布成功)');
|
||
assert.ok(/async onWallpaperUploaded\(/.test(page), '页面要有上传成功后的处理');
|
||
const idx = page.indexOf('async onWallpaperUploaded(');
|
||
const body = page.slice(idx, idx + 900);
|
||
assert.ok(/syncFromServer\(/.test(body),
|
||
'★ onWallpaperUploaded 里没有重新同步 ⇒ 本地自作主张标成 image 档,而服务端才是权威(P4c 第③条)');
|
||
assert.ok(!/this\.bgKind = 'image'/.test(page),
|
||
"★ 页面里直接写了 bgKind = 'image' ⇒ 那是本地宣布成功;服务端没记成 image 的话," +
|
||
'界面会显示一块取不回来的空白(resolveBackground 对"image 档但没图"给 none)');
|
||
});
|
||
|
||
/* ────────────────────── ⑥ 形态:压缩链真的接上了 ────────────────────── */
|
||
|
||
test('压缩链:picker → 尺寸 → 入口检查 → 计划 → 逐档 → 打包 → 上传', () => {
|
||
const src = code(PICKER);
|
||
const chain = [
|
||
['PhotoViewPicker', '打开相册(picker)'],
|
||
['getImageInfo', '读原始尺寸'],
|
||
['judgePick', '入口检查(P4c:失败必须给原因)'],
|
||
['planCompress', '造压图计划'],
|
||
['scaleToMaxEdge', '按档缩放'],
|
||
['desiredSize', '按目标尺寸解码(峰值内存只有目标那一份)'],
|
||
['shouldRetryWithActual', '用**真实**字节数判要不要退档'],
|
||
['uploadImageBytes', '上传(内存直传)'],
|
||
['onUploaded', '报结果给页面(重新同步在页面层,见下一条判据)']
|
||
];
|
||
for (const [needle, why] of chain) {
|
||
assert.ok(new RegExp(needle).test(src), `★ 压缩链断了:没有 ${needle}(${why})`);
|
||
}
|
||
// 最后一环落在页面上:组件报结果 ⇒ 页面重新同步(两半都要在,缺一半链子就是断的)
|
||
assert.ok(/syncFromServer\(/.test(code(SETTINGS_PAGE)),
|
||
'★ 压缩链断了:页面里没有 syncFromServer(以服务端为准重新同步)');
|
||
});
|
||
|
||
test('压缩链:档位循环遍历 plan.passes(不是写死"试一次")', () => {
|
||
const src = code(PICKER);
|
||
assert.ok(/for \(let i = 0; i < plan\.passes\.length; i\+\+\)/.test(src),
|
||
'★ 没有遍历 plan.passes ⇒ 第二档永远不会被走到(两档策略等于只有一档)');
|
||
assert.ok(/break/.test(src), '不超限要能提前跳出(否则每张图都被压两遍,白等一遍)');
|
||
});
|
||
|
||
test('压缩链:**退档判定只有一处**,而且循环外不许重判一次(重判会把判据架空)', () => {
|
||
/*
|
||
* ★ 这条是变异测试抓出来的**真洞**,值得把来龙去脉留在这里:
|
||
*
|
||
* 原实现里"要不要退下一档"判在**两处**:
|
||
* ① 循环里 `if (!shouldRetryWithActual(usedBytes)) break;`
|
||
* ② 循环后 `if (packed === null || shouldRetryWithActual(usedBytes)) fail(TOO_LARGE_REASON)`
|
||
* 这两处**互相抵消**:把 ① 改成 `if (true)`(永远只压一档,**第二档彻底死掉**),
|
||
* 超限这件事仍然被 ② 接住 ⇒ 整套判据全绿。
|
||
* 也就是"两个判据各自描述同一件事",最后**谁都没被真正钉住**。
|
||
*
|
||
* ⇒ 形态上钉死:`shouldRetryWithActual` 在这条链上只许出现**两次**——
|
||
* 循环里一次(产生结论)、`overLimit` 声明处一次(初值)——
|
||
* 且**循环结束之后**不许再出现调用。
|
||
*/
|
||
const src = code(PICKER);
|
||
const calls = src.match(/(?<!\{ )shouldRetryWithActual\(/g) || [];
|
||
assert.ok(calls.length >= 1,
|
||
'★ 循环里根本没**调用** shouldRetryWithActual ⇒ "要不要退下一档"压根没按真实字节数判' +
|
||
'(拆掉这一处、只把循环外那句留着也能靠另一条判据)');
|
||
assert.equal(calls.length, 1,
|
||
`★ BackgroundPicker 里 shouldRetryWithActual 被**调用** ${calls.length} 次(应为 1)。` +
|
||
'多于 1 次通常就是"循环外又重判一次"——两处判定互相抵消,把循环里那处改成 if(true) 也不会红');
|
||
|
||
const loopEnd = src.indexOf('overLimit = shouldRetryWithActual(usedBytes);');
|
||
assert.ok(loopEnd > 0, '★ 循环里没有 `overLimit = shouldRetryWithActual(usedBytes);` ⇒ 退档结论不是从循环里产生的');
|
||
assert.ok(/if \(!overLimit\) \{\s*\n\s*break;/.test(src),
|
||
'★ `overLimit` 算出来了却没拿它决定 `break` ⇒ 循环不会因为"不超限"而提前结束(每张图都白压两档)');
|
||
assert.ok(/overLimit = shouldRetryWithActual\(usedBytes\);\s*\n\s*if \(!overLimit\) \{\s*\n\s*break;/.test(src),
|
||
'★ `overLimit` 的赋值与紧跟的 `if (!overLimit) { break; }` 之间被改了 ⇒ 结论没有真的接上判定');
|
||
const loopClose = src.indexOf('}', src.indexOf('if (!overLimit)', loopEnd));
|
||
const afterLoop = src.slice(loopClose, loopClose + 400);
|
||
assert.ok(!/shouldRetryWithActual/.test(afterLoop),
|
||
'★ 循环**之后**又调了一次 shouldRetryWithActual ⇒ 两处判定互相抵消(把循环里那处改成 if(true) 也不会红)。' +
|
||
'循环外只该读 overLimit 这个结论');
|
||
});
|
||
|
||
test('压缩链:两档都超限 ⇒ **必须**明确拒绝(P4c:失败必须给原因)', () => {
|
||
const src = code(PICKER);
|
||
assert.ok(/if \(packed === null \|\| overLimit\) \{/.test(src),
|
||
'★ 没有"两档都超限就拒绝"这条分支 ⇒ 超限的图会被直传,服务端回 413,而用户在界面上看不到"为什么"');
|
||
const idx = src.indexOf('if (packed === null || overLimit) {');
|
||
assert.ok(/TOO_LARGE_REASON/.test(src.slice(idx, idx + 200)),
|
||
'★ 拒绝时要给出 TOO_LARGE_REASON("图片压缩后仍过大,请换一张更小的图片"),不能静默');
|
||
});
|
||
|
||
test('压缩链:PackingOption 的 quality 是 0~100(SDK 口径),而计划里是 0~1 —— 换算只在一处', () => {
|
||
const src = code(PICKER);
|
||
const conversions = src.match(/quality \* 100/g) || [];
|
||
assert.equal(conversions.length, 1,
|
||
`★ 出现 ${conversions.length} 处 \`quality * 100\` ⇒ 换算散在多处会漂移(一边 85 一边 0.85 就会压出比原图还大的东西)。` +
|
||
'计划里的 0~1 是照 WebUI 的 toDataURL 口径,换成 SDK 的 0~100 只该有一处');
|
||
assert.ok(/format: 'image\/jpeg'/.test(src), '格式要是 image/jpeg(与 mime 一致,否则服务端 415)');
|
||
});
|
||
|
||
test('压缩链:图片资源在**每条出口**都被释放(release 是 Promise,要 await)', () => {
|
||
const src = code(PICKER);
|
||
const releases = src.match(/await \w+\.release\(\)/g) || [];
|
||
assert.ok(releases.length >= 3,
|
||
`★ 只找到 ${releases.length} 处 \`await …release()\`:ImageSource(头) + PixelMap + ImageSource(档) 至少三处。` +
|
||
'不释放的话,连选几张图就会把相册/解码器的内存吃光(真机上表现为"选第三张时闪退")');
|
||
const unawaited = (src.match(/^\s*(?!await)[a-zA-Z]+\.release\(\)/gm) || []);
|
||
assert.deepEqual(unawaited, [],
|
||
`★ 有没 await 的 release(): ${JSON.stringify(unawaited)} ⇒ 返回的是 Promise<void>,"没人管的 promise"在 ArkTS 里是编不过/丢异常`);
|
||
assert.ok(/finally \{/.test(src),
|
||
'★ 释放没有放在 finally 里 ⇒ 中途抛异常时资源不释放(而且容易在"提前 return"那条路上漏掉)');
|
||
});
|
||
|
||
test('压缩链:上传的字段名与 mime 交给 uploadBytes(发错 mime 会被服务端 415)', () => {
|
||
const api = code(join(ETS, 'api/ApiClient.ets'));
|
||
/*
|
||
* ★ 判据要**钉在方法体里**,不能钉在整个文件里 —— 变异测试抓出来的:
|
||
* `ApiClient` 里有**两个** multipart 构造(`uploadFile` 用 filePath 那份、
|
||
* `uploadBytes` 第二份),原来那条 `/name: 'file'/` 在整个文件里匹配,
|
||
* 把 `uploadBytes` 里的字段名改成 `'image'` **照样全绿**(另一份还在)。
|
||
* ⇒ 先切出 `uploadBytes` 的方法体,再在那里判。这是"判据范围比结论范围宽"的典型形状。
|
||
*/
|
||
const upIdx = api.indexOf('async uploadBytes(');
|
||
assert.ok(upIdx > 0, 'ApiClient 要有 uploadBytes(内存直传那一份)');
|
||
const upBody = api.slice(upIdx, api.indexOf('async uploadFile(', upIdx) > 0
|
||
? api.indexOf('async uploadFile(', upIdx)
|
||
: upIdx + 2000);
|
||
assert.ok(/name: 'file'/.test(upBody),
|
||
"★ uploadBytes 里的 multipart 字段名不是 'file' ⇒ 服务端 appearance.go 读不到文件" +
|
||
"(415,或服务端报缺少 file 字段)");
|
||
assert.ok(/multiFormDataList/.test(api), '要真的走 multipart');
|
||
assert.ok(/data: data/.test(api),
|
||
'★ 没把内存字节放进 data ⇒ 那就得走 filePath(临时文件),而上限检查在两边都会多一个失败面');
|
||
const appApi = code(join(ETS, 'api/AppearanceApi.ets'));
|
||
assert.ok(/'image\/jpeg'/.test(appApi),
|
||
'★ 上传的 mime 不是 image/jpeg ⇒ 服务端 detectContentType 会拒(415)');
|
||
});
|
||
|
||
test('压缩链:解码尺寸用的是**算出来**的目标尺寸,不是原图尺寸', () => {
|
||
/*
|
||
* ★ 这条也是变异测试抓出来的:原来只断言出现了 `desiredSize` 这个词,
|
||
* 把它改成 `desiredSize: { width: info.size.width, height: info.size.height }`
|
||
* (= 按**原图**尺寸解码,等于完全不缩放,4K 照片在低端机上直接爆内存)**判据照样全绿**。
|
||
* ⇒ 必须钉到"喂进去的是哪个变量",不能只钉"这个键存在"。
|
||
*/
|
||
const src = code(PICKER);
|
||
const idx = src.indexOf('desiredSize:');
|
||
assert.ok(idx > 0, '要有 desiredSize(按目标尺寸解码,峰值内存只有目标那一份)');
|
||
const block = src.slice(idx, idx + 160);
|
||
assert.ok(/width: size\.width/.test(block) && /height: size\.height/.test(block),
|
||
`★ desiredSize 用的不是算出来的目标尺寸(片段:${block.split('\n').slice(0, 3).join(' ')})。` +
|
||
'若用 info.size(原图尺寸)就等于完全没缩放,4K 照片解码后约 48MB,会把低端机推爆');
|
||
assert.ok(!/desiredSize:[^}]*info\.size/.test(block),
|
||
'★ desiredSize 里出现了 info.size ⇒ 那是原图尺寸,不是目标尺寸');
|
||
});
|
||
|
||
/* ────────────────────── ⑦ 形态:交付判据 ────────────────────── */
|
||
|
||
test('不许新写死颜色(背景选择器也一样)', () => {
|
||
const src = code(PICKER);
|
||
const hex = src.match(/#[0-9a-fA-F]{3,8}\b/g) || [];
|
||
assert.deepEqual(hex, [],
|
||
`★ BackgroundPicker.ets 里出现了写死的色值 ${JSON.stringify(hex)} ⇒ 深浅两套会从这一处分叉`);
|
||
assert.ok(/Theme\./.test(src), '色要走 Theme');
|
||
});
|
||
|
||
test('成员名不与通用属性冲突(@State opacity 这类会编不过)', () => {
|
||
const src = code(PICKER);
|
||
// 通用属性名清单:这些都是 ArkUI 的 attribute,同名成员会冲突
|
||
const reserved = ['opacity', 'visibility', 'width', 'height', 'scale', 'rotate', 'translate', 'margin', 'padding'];
|
||
for (const name of reserved) {
|
||
const decl = new RegExp(`@(State|Prop|Link)\\s+${name}\\s*:`);
|
||
assert.ok(!decl.test(src),
|
||
`★ 出现了「@State/@Prop/@Link ${name}」⇒ 与 ArkUI 通用属性同名,ArkTS 判据会报冲突`);
|
||
}
|
||
});
|
||
|
||
test('未验必须如实标注(本机无设备 ⇒ 观感类结论不许写成已验)', () => {
|
||
for (const f of [PICKER, join(ETS, 'pages/AdminUsersPage.ets')]) {
|
||
const src = prose(f);
|
||
assert.ok(/未验/.test(src),
|
||
`★ ${f.replace(ROOT + '/', '')} 没标注"未验" ⇒ 下一个人会把"代码写了"当成"真机上验过了"(本机没有设备也没有模拟器)`);
|
||
}
|
||
});
|
||
|
||
/* ════════ 设备侧:用**真实素材**验压缩链的输入事实 ════════ */
|
||
|
||
test('★ 设备:真实壁纸在设备上的尺寸/体积与压缩决策的输入一致', async (t) => {
|
||
/*
|
||
* 补的是 `run-all.mjs` 的 `STATIC_ONLY` 登记里说的那个缺口:
|
||
* 「上传链的设备侧:`@ohos.multimedia.image` + 相册要设备才能真跑」
|
||
*
|
||
* 上面 30 条判的都是 `model/ImagePrep.ts` 的**纯逻辑**(阈值、单调性、
|
||
* 边界…),它们全绿时有一件事从未验过:**那些数字与设备上真实的图片对得上吗**。
|
||
* 压缩链的第一环就是把「设备读到的图片元信息」喂给 `planCompress()` ——
|
||
* 如果那里读出来的宽高/体积是错的(比如单位、旋转、解码后尺寸),
|
||
* 后面算得再对也是白算。
|
||
*
|
||
* ★ 素材用**真实数据**而不是合成图:`/data/local/tmp/real-wallpaper.jpg`
|
||
* 是用户真上传过的那张壁纸(1402×1122、152570 字节),
|
||
* 由 `GET /me/appearance/image` 取回。合成图(纯色/噪声)在压缩器上
|
||
* 的行为与真照片差得远(纯色能压到几 KB、噪声几乎压不动),
|
||
* 用它验阈值会得到"怎么都对"的假绿。
|
||
*
|
||
* 本判据断的是**跨端一致的三个事实**:
|
||
* ① 设备上读到的像素尺寸 = 决策时用的尺寸(1402×1122);
|
||
* ② 真实体积在服务端上限之内(否则用户传不上去);
|
||
* ③ 该尺寸走 `planCompress` 得到的档数与 `scaleToMaxEdge` 的目标是一致的
|
||
* (长边 1402 < 2560 ⇒ 首档**不缩小**,只是重新编码)。
|
||
*
|
||
* ★★ **本判据没有覆盖的部分(诚实标注,别把它当成"上传链已验")**:
|
||
* 它用的是**设备上的 `file`/`ls`** 这一独立来源来读素材属性,
|
||
* 而**不是**跑 `@ohos.multimedia.image` 的 `createImageSource` + `ImagePacker`。
|
||
* 也就是说:
|
||
* · 已验:真实素材在设备上的尺寸/体积,与压缩决策的输入对得上;
|
||
* · **未验**:应用真的用 `image.createImagePacker()` 压一次时,
|
||
* 产出的字节数是否落在预期区间(`packJpeg` 那条路径)。
|
||
*
|
||
* 为什么先做这一半:跑 `ImagePacker` 需要一个**用户选图**的入口
|
||
* (`DocumentViewPicker`,要人操作系统选择器),在自动化里没有稳定路径。
|
||
* 编一个"绕过选择器直接调 packJpeg"的测试专用入口,会是**只有测试在用的代码**
|
||
* —— 那种代码不会被真实场景触到,验它等于验一个不存在的东西。
|
||
*
|
||
* ⇒ 这一半的欠账记在 `docs/DEBTS.json` 的 `harmony-p4c-boundary-decls` 那组里
|
||
* (它本来就是"上传链的边界声明"那笔账),不在这里假装完成。
|
||
*/
|
||
const D = await import('./lib/harmony-device.mjs');
|
||
const hdc = D.findHdc();
|
||
if (!hdc || !D.hasTarget(hdc)) {
|
||
return t.skip('设备不在 —— 本条的设备半边本次不跑(上面静态层仍把住纯逻辑)');
|
||
}
|
||
|
||
/* 素材在设备上吗?不在就显式跳过(不假装通过) */
|
||
const probe = D.shellOn(hdc, 'ls -la /data/local/tmp/real-wallpaper.jpg');
|
||
const listed = (probe.stdout || '').trim();
|
||
/*
|
||
* ★ 判"在不在"必须看**退出码**,不能只看输出里有没有那个文件名。
|
||
*
|
||
* `hdc shell` 会把 stderr 并进 stdout,于是文件不存在时 stdout 是
|
||
* `ls: /data/local/tmp/real-wallpaper.jpg: No such file or directory` ——
|
||
* **里面仍然含有文件名**,于是原来的 `listed.includes('real-wallpaper.jpg')`
|
||
* 判成"在",本该跳过的判据继续往下跑,撞在 `/bin/file` 上**报错**。
|
||
* 设备上没有素材是正常状态(没传过壁纸的设备),不该让整条判据变红。
|
||
*/
|
||
if (probe.status !== 0 || !/^-.*\breal-wallpaper\.jpg$/m.test(listed)) {
|
||
return t.skip('设备上没有真实素材 /data/local/tmp/real-wallpaper.jpg —— 本次不跑');
|
||
}
|
||
|
||
/* ① 设备自己报的像素尺寸(用设备上的 `file`,那是独立于本应用的第二来源) */
|
||
const fileOut = (D.shellOn(hdc, '/bin/file /data/local/tmp/real-wallpaper.jpg').stdout || '').trim();
|
||
const dim = /(\d+)x(\d+)/.exec(fileOut);
|
||
assert.ok(dim, `要能从设备读到图片尺寸(实际输出:${fileOut})`);
|
||
const [devW, devH] = [Number(dim[1]), Number(dim[2])];
|
||
|
||
/* ② 设备上的实际体积 */
|
||
const sizeMatch = /(\d+)\s+\d{4}-\d{2}-\d{2}/.exec(listed) || /(\d+)/.exec(listed);
|
||
const devBytes = sizeMatch ? Number(sizeMatch[1]) : 0;
|
||
assert.ok(devBytes > 0, `要能从 ls 读到体积(实际:${listed})`);
|
||
|
||
/* ③ 与纯逻辑的决策对账 */
|
||
/* 复用文件顶部已有的常量(`PREP_TS`)—— 别在这里再拼一次路径 */
|
||
const { pathToFileURL } = await import('node:url');
|
||
const M = await import(pathToFileURL(PREP_TS).href);
|
||
|
||
assert.ok(devBytes <= M.MAX_UPLOAD_BYTES,
|
||
`真实素材(${devBytes} 字节)必须在客户端上限(${M.MAX_UPLOAD_BYTES})之内 —— ` +
|
||
'超了会被 judgePick 拦下,用户看到"图片太大"');
|
||
|
||
const plan = M.planCompress(devW, devH);
|
||
assert.ok(plan.passes.length >= 1, 'planCompress 至少给一档');
|
||
|
||
/*
|
||
* ★ 长边 1402 < MAX_EDGE(2560) ⇒ 首档**不缩小**(只重编码)。
|
||
* 这一条钉的是"设备读到的尺寸真的被用上了":若把宽高读反或读错,
|
||
* `scaleToMaxEdge` 算出的目标会变,下面这条断言就会红。
|
||
*/
|
||
const scaled = M.scaleToMaxEdge(devW, devH, plan.passes[0].maxEdge);
|
||
assert.equal(scaled.width, devW,
|
||
`★ 首档不应缩小(${devW}×${devH} 的长边 ${Math.max(devW, devH)} ` +
|
||
`< 首档上限 ${plan.passes[0].maxEdge})—— 不等说明设备读到的尺寸与决策用的尺寸不一致`);
|
||
assert.equal(scaled.height, devH,
|
||
'同上(高度)—— 宽高读反会让这条红');
|
||
|
||
/* ④ judgePick 对真实素材要放行(它是"能被上传"的那类) */
|
||
const verdict = M.judgePick(devBytes, 'image/jpeg');
|
||
assert.equal(verdict.ok, true,
|
||
`真实素材应被 judgePick 放行(实际 reason="${verdict.reason}")—— ` +
|
||
'拦下真壁纸说明阈值或 MIME 判定有问题');
|
||
});
|