@mrrisega/dsh-remote 0.7.9-beta.1 → 0.7.10-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -123,6 +123,9 @@ class El {
123
123
  }
124
124
  get className() { return [...this._cls].join(" "); }
125
125
  set className(v) { this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); }
126
+ /* 真实 DOM 里 nodeName 一定存在(大小写语义与 tagName 相同):适配层的「插入前图片钩子」
127
+ 按 nodeName 判 IMG,假 DOM 缺了这个属性就会把正确实现判成失效(假红)。 */
128
+ get nodeName() { return this.tagName; }
126
129
  set innerHTML(html) {
127
130
  this._html = String(html);
128
131
  this.children = [];
@@ -1211,3 +1214,172 @@ test("★ C6:scrim 不变量与下闸次数必须进诊断(用户报「又
1211
1214
  assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "不变量必须摘掉遮罩");
1212
1215
  assert.ok(env.win.__dshMaDiag.scrim.invariantHits >= 1, "摘除次数必须计数(区分「遮罩在拦」与「遮罩早被摘了」)");
1213
1216
  });
1217
+
1218
+ // ══════════════════════════════════════════════════════════════════════════════
1219
+ // 首屏优化(2026-10-2x):这几条守的是「适配层自己别成为首屏/打字卡顿的原因」。
1220
+ //
1221
+ // 现场:手机镜像页首屏 p50 3.9s / p90 21.6s、20.2% ≥12s(生产遥测 n=8903)。
1222
+ // 大头(TTFB、官方 13MB 级 bundle、中继带宽)不在适配层手里,但下面两件事是我们自己造的:
1223
+ // ① 旧 HOSTISH() 把 `outerHTML.slice(0, 200000)` 写在或的左边 → 每拍 boot 都先全量序列化一遍;
1224
+ // ② 看门狗每拍要扫全文档(设置面板识别)与全部 <img>(懒加载)→ 用户敲字时在和输入法抢主线程。
1225
+ // ══════════════════════════════════════════════════════════════════════════════
1226
+
1227
+ test("★ 首屏优化:有官方结构信号时一次全量序列化都不做(旧实现每拍 boot 先 outerHTML.slice(0,200000))", () => {
1228
+ const { js } = injected();
1229
+ const env = run(harness({ width: 390 }), js);
1230
+ const perf = env.win.__dshMaDiag && env.win.__dshMaDiag.perf;
1231
+ assert.ok(perf, "必须暴露 __dshMaDiag.perf(只本地可读,不上报)");
1232
+ assert.ok(perf.hostishCalls >= 1, "boot 必须走过 HOSTISH(这是主机门)");
1233
+ assert.equal(perf.hostishSerializations, 0,
1234
+ "★ 官方 frame/开合属性已经能证明是官方页面 → 绝不能再把整页序列化一遍(那是最贵的一步)");
1235
+ });
1236
+
1237
+ test("★ 首屏优化:打字期间暂停自己的后台扫描(别和输入法抢主线程)", () => {
1238
+ const { js } = injected();
1239
+ const env = run(harness({ width: 390 }), js);
1240
+ const far = new El("img");
1241
+ far.setAttribute("src", "/assets/far-below-fold.png");
1242
+ far._rect = { left: 0, top: 2000, right: 300, bottom: 2200, width: 300, height: 200 };
1243
+ env.body.appendChild(far);
1244
+ // 用户正在打字:input 事件冒泡到 document(适配层在捕获阶段记下"忙 1.2 秒")
1245
+ env.composerChild.dispatchEvent(new Ev("input", {}));
1246
+ env.tickTimers(); // 看门狗一拍 = 图片懒加载的一次扫描机会
1247
+ assert.equal(far.getAttribute("loading"), null,
1248
+ "★ 打字期间必须跳过扫描(暂停的是优化,不是交互 —— 页面行为一点不受影响)");
1249
+ assert.ok(env.win.__dshMaDiag.perf.imgScanPaused >= 1, "跳过次数要进诊断(证明它真的让路了)");
1250
+ });
1251
+
1252
+ test("★ 首屏优化:视口外的图片打 loading=lazy + decoding=async,首屏附近的一个字节都不动", () => {
1253
+ const { js } = injected();
1254
+ const env = run(harness({ width: 390 }), js); // 该假 DOM 没有 IntersectionObserver → 走批量几何兜底
1255
+ const far = new El("img");
1256
+ far.setAttribute("src", "/assets/far.png");
1257
+ far._rect = { left: 0, top: 2000, right: 300, bottom: 2200, width: 300, height: 200 };
1258
+ const near = new El("img");
1259
+ near.setAttribute("src", "/assets/near.png");
1260
+ near._rect = { left: 0, top: 20, right: 300, bottom: 220, width: 300, height: 200 };
1261
+ env.body.appendChild(far);
1262
+ env.body.appendChild(near);
1263
+ env.tickTimers(); // 看门狗一拍 → 扫描新出现的图片
1264
+
1265
+ assert.equal(far.getAttribute("loading"), "lazy", "★ 视口外的图片必须按需加载(首屏少发请求、少抢带宽)");
1266
+ assert.equal(far.getAttribute("decoding"), "async", "解码异步,别阻塞主线程");
1267
+ assert.equal(near.getAttribute("loading"), null, "★ 首屏附近的图片绝不能被改成 lazy");
1268
+ assert.equal(near.getAttribute("decoding"), null, "首屏附近的图片保持官方原样");
1269
+ const perf = env.win.__dshMaDiag.perf;
1270
+ assert.ok(perf.imgLazy >= 1 && perf.imgKept >= 1, "省下的请求数 / 保持原样的张数都要能读出来");
1271
+ });
1272
+
1273
+ test("★ 护栏:首启字号提示是 FAB 内的绝对定位气泡(不是 body 级浮层、不是全屏层)", () => {
1274
+ // 0.6.14 的教训:一旦在用户页面上放覆盖层,判断错一次就是"永远进不去"。
1275
+ // 字号提示必须遵守同一条纪律:只能挂在 FAB 内部、只能小、只能自己消失。
1276
+ const { css, js } = injected();
1277
+ const rule = /button\.dsh-ma-fab \.dsh-ma-fs-tip\s*\{([\s\S]*?)\}/.exec(css);
1278
+ assert.ok(rule, "应有字号提示的样式规则(且限定在 FAB 内)");
1279
+ assert.match(rule[1], /position:\s*absolute/, "必须是 FAB 内的绝对定位子元素");
1280
+ assert.ok(!/position:\s*fixed/.test(rule[1]), "不得是 fixed 浮层");
1281
+ assert.ok(!/inset:\s*0/.test(rule[1]), "不得铺满屏幕(inset:0 = 全屏层)");
1282
+ assert.match(js, /dsh-ma-fs-tip-v1/, "必须用 localStorage 记住「已提示过」(同一浏览器只提示一次)");
1283
+ assert.match(js, /FS_TIP_DELAY_MS/, "必须是延迟出现(不许在首屏关键路径上创建节点)");
1284
+ });
1285
+
1286
+ // ══════════════════════════════════════════════════════════════════════════════
1287
+ // 「插入前」图片钩子(2026-10-2x,唯一一处会碰官方页面全局对象的地方,必须逐条钉住)
1288
+ //
1289
+ // 为什么需要它(headless Chrome 实测,同一份 24 张图的页面):
1290
+ // · append **之前**带 loading="lazy" → 首屏只发 11 个图片请求
1291
+ // · append **之后**(哪怕只晚一个微任务)补属性 → 24 个请求一个不少(属性有了、请求照发)
1292
+ // 所以"插入后观察器"拿不到带宽收益,真正省带宽的是这层极窄的钩子:
1293
+ // Node.prototype.appendChild / insertBefore 里,**只对 IMG**、只在插入前,补两个属性。
1294
+ // 下面每条都是它的安全边界 —— 破任何一条都可能影响官方页面的现有交互。
1295
+ // ══════════════════════════════════════════════════════════════════════════════
1296
+
1297
+ /** 给假 DOM 补一个最小的 Node:脚本只在有 Node.prototype 的宿主里才装钩子。 */
1298
+ function installFakeNode(env) {
1299
+ function FakeNode() {}
1300
+ const nativeAppend = function (n) { (this._kids || (this._kids = [])).push(n); return n; };
1301
+ const nativeInsert = function (n) { (this._kids || (this._kids = [])).push(n); return n; };
1302
+ FakeNode.prototype.appendChild = nativeAppend;
1303
+ FakeNode.prototype.insertBefore = nativeInsert;
1304
+ FakeNode.nativeAppend = nativeAppend;
1305
+ env.ctx.Node = FakeNode;
1306
+ return FakeNode;
1307
+ }
1308
+
1309
+ test("★ 图片钩子:插入**前**给 <img> 补 loading=lazy + decoding=async(这是唯一能真的省请求的时机)", () => {
1310
+ const { js } = injected();
1311
+ const env = harness({ width: 390 });
1312
+ const FakeNode = installFakeNode(env);
1313
+ run(env, js);
1314
+
1315
+ assert.equal(typeof FakeNode.prototype.appendChild.__dshMaImgHook, "boolean",
1316
+ "应给 appendChild 包一层钩子(标记便于排障)");
1317
+ const host = {};
1318
+ const img = new El("img");
1319
+ img.setAttribute("src", "/chat/1.png");
1320
+ const ret = FakeNode.prototype.appendChild.call(host, img);
1321
+ assert.equal(ret, img, "返回值必须原样透传(包装不能改变任何调用语义)");
1322
+ assert.equal(host._kids.length, 1, "原生 appendChild 必须照常执行");
1323
+ assert.equal(img.getAttribute("loading"), "lazy", "★ 插入前就必须带上 loading=lazy(晚一步等于没打)");
1324
+ assert.equal(img.getAttribute("decoding"), "async", "解码异步,不阻塞主线程");
1325
+
1326
+ // 非 IMG 节点:一个字节都不碰
1327
+ const div = new El("div");
1328
+ FakeNode.prototype.appendChild.call({}, div);
1329
+ assert.equal(div.getAttribute("loading"), null, "非图片节点不得被改动");
1330
+
1331
+ // 已有 loading 属性(官方自己给的)→ 不动
1332
+ const img2 = new El("img");
1333
+ img2.setAttribute("src", "/chat/2.png");
1334
+ img2.setAttribute("loading", "eager");
1335
+ FakeNode.prototype.appendChild.call({}, img2);
1336
+ assert.equal(img2.getAttribute("loading"), "eager", "官方已经给过 loading 属性 → 必须原样保留");
1337
+ });
1338
+
1339
+ test("★ 图片钩子:DocumentFragment 批量插入(React 列表常见)也要覆盖到一级 img 子节点", () => {
1340
+ const { js } = injected();
1341
+ const env = harness({ width: 390 });
1342
+ const FakeNode = installFakeNode(env);
1343
+ run(env, js);
1344
+ const a = new El("img"); a.setAttribute("src", "/a.png");
1345
+ const b = new El("img"); b.setAttribute("src", "/b.png");
1346
+ const frag = { nodeType: 11, childNodes: [a, b] };
1347
+ FakeNode.prototype.appendChild.call({}, frag);
1348
+ assert.equal(a.getAttribute("loading"), "lazy", "fragment 里的图片同样要按需加载");
1349
+ assert.equal(b.getAttribute("loading"), "lazy");
1350
+ });
1351
+
1352
+ test("★ 图片钩子:逃生口 ?dsh_ma_img_lazy=0 生效(线上出问题不用等发版)", () => {
1353
+ const { js } = injected();
1354
+ const env = harness({ width: 390 });
1355
+ const FakeNode = installFakeNode(env);
1356
+ env.win.location.search = "?dsh_ma_img_lazy=0";
1357
+ run(env, js);
1358
+ assert.ok(!FakeNode.prototype.appendChild.__dshMaImgHook,
1359
+ "带逃生口参数时绝不安装钩子(页面行为与今天完全一致)");
1360
+ const img = new El("img");
1361
+ img.setAttribute("src", "/x.png");
1362
+ FakeNode.prototype.appendChild.call({}, img);
1363
+ assert.equal(img.getAttribute("loading"), null, "没装钩子就不许改任何图片属性");
1364
+ });
1365
+
1366
+ test("★ 图片钩子:pagehide 必须还原 Node.prototype(bfcache 里不留改写过的全局方法)", () => {
1367
+ const { js } = injected();
1368
+ const env = harness({ width: 390 });
1369
+ const FakeNode = installFakeNode(env);
1370
+ run(env, js);
1371
+ assert.ok(FakeNode.prototype.appendChild.__dshMaImgHook, "前提:钩子已装上");
1372
+ // 适配层把 maTeardown 挂在 window 的 pagehide 上(假 DOM 的 window.addEventListener 转发到 document)
1373
+ env.doc.dispatchEvent(new Ev("pagehide", {}));
1374
+ assert.equal(FakeNode.prototype.appendChild, FakeNode.nativeAppend,
1375
+ "★ pagehide 之后 appendChild 必须还原成原生函数(绝不给页面留悬挂改写)");
1376
+ });
1377
+
1378
+ test("★ 图片钩子只在手机窄屏装(桌面镜像页一个字节都不碰)", () => {
1379
+ const { js } = injected();
1380
+ const env = harness({ width: 1200 });
1381
+ const FakeNode = installFakeNode(env);
1382
+ run(env, js);
1383
+ assert.ok(!FakeNode.prototype.appendChild.__dshMaImgHook,
1384
+ "★ >820px(电脑端镜像页)不得改写 Node.prototype —— 这条优化只属于手机");
1385
+ });
@@ -74,6 +74,15 @@ class El {
74
74
  prepend() {} focus() {} blur() {}
75
75
  hasAttribute(n) { return n in this.attrs; }
76
76
  appendChild(c) { c.parent = this; this.children.push(c); return c; }
77
+ /* 真实 DOM 里 removeChild/parentNode 是基本能力:适配层的「字号提示」用它把自己摘掉。
78
+ 假 DOM 缺了它 → 提示永远摘不掉 → 用例会把一个正确的实现判成错误(假红)。 */
79
+ removeChild(c) {
80
+ const i = this.children.indexOf(c);
81
+ if (i >= 0) this.children.splice(i, 1);
82
+ if (c) c.parent = null;
83
+ return c;
84
+ }
85
+ get parentNode() { return this.parent || null; }
77
86
  addEventListener(t, fn) { (this._listeners.get(t) || this._listeners.set(t, []).get(t)).push(fn); }
78
87
  removeEventListener() {}
79
88
  dispatch(t, ev = {}) { for (const fn of this._listeners.get(t) || []) fn({ stopPropagation() {}, preventDefault() {}, ...ev }); }
@@ -106,7 +115,9 @@ class El {
106
115
  * 折叠时**没有拖拽把手**——这正是 2026-09-19 线上"整屏遮罩"的现场结构。
107
116
  */
108
117
  function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRect = null, width = 390, withToggle = true,
109
- variant = "rc1", rightbarAttr = undefined, overlayRect = null, rightbarRect = null, fetchImpl = null } = {}) {
118
+ variant = "rc1", rightbarAttr = undefined, overlayRect = null, rightbarRect = null, fetchImpl = null,
119
+ /* 2026-10-2x:给"输入框可用性小修补"用例提供一个官方 Lexical 输入区(默认不提供,旧用例现场不变)。 */
120
+ withComposer = false } = {}) {
110
121
  const html = new El("html");
111
122
  // HOSTISH 门禁会读 documentElement.outerHTML 找官方特征(__ModuleLoader__ / DeepSeek Harness)
112
123
  html.outerHTML = '<html><head><script>window.__ModuleLoader__ = { mode: "queue" }</script>'
@@ -134,6 +145,9 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
134
145
  }
135
146
  const toggle = new El("button");
136
147
  toggle.setAttribute("aria-label", "打开侧边栏");
148
+ const composer = new El("div", {
149
+ contenteditable: "true", "data-composer-input": "true", role: "textbox", "aria-multiline": "true",
150
+ });
137
151
 
138
152
  const pickByClass = (sel) => {
139
153
  // 只支持本用例需要的极少数选择器(脚本里 querySelector 的用法就这些)
@@ -156,9 +170,28 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
156
170
  if (s2.includes("aria-label=")) return withToggle ? toggle : null;
157
171
  if (s2.includes(".hHd-Xa_toggle")) return withToggle ? toggle : null;
158
172
  if (s2.includes("hHd-Xa")) return sidebar; // 子列识别用(真实页面里这是侧栏)
173
+ if (s2.includes("data-composer-input")) return withComposer ? composer : null;
159
174
  return pickByClass(s2);
160
175
  },
161
- querySelectorAll() { return []; },
176
+ /* 只额外支持 `img`:适配层的「图片按需加载」要扫 document.querySelectorAll("img")。
177
+ 其它选择器仍按旧行为返回 [](不改变既有用例的现场)。 */
178
+ querySelectorAll(sel) {
179
+ if (String(sel) !== "img") return [];
180
+ const out = [];
181
+ const seen = new Set();
182
+ const walk = (el) => {
183
+ if (!el || seen.has(el)) return;
184
+ seen.add(el);
185
+ for (const c of el.children || []) {
186
+ if (String(c.tagName) === "IMG") out.push(c);
187
+ walk(c);
188
+ }
189
+ };
190
+ /* 这套假 DOM 里 body 不一定挂在 documentElement 下(旧用例不需要),所以两个根都走一遍。 */
191
+ walk(doc);
192
+ walk(doc.body);
193
+ return out;
194
+ },
162
195
  createElement: (t) => new El(t),
163
196
  createTextNode: (t) => ({ nodeValue: t }),
164
197
  getElementById: () => null,
@@ -179,7 +212,9 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
179
212
  fetch: fetchImpl || undefined,
180
213
  // unref:同上 —— 适配层的 4 分钟兜底计时器会让测试进程空等到期(本来 240s,只剩 0.2s)。
181
214
  // 用例里的 `_pendingTimers` 仍会被 runAdapter() 主动跑一轮,行为不变。
182
- setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); try { id.unref?.(); } catch { /* 忽略 */ } timers.set(id, fn); return id; },
215
+ // 2026-10-2x:额外把 (fn, ms) 记进 timers,便于 `flushTimers(env, maxMs)` 手动推进
216
+ // 「延迟才做」的首屏后优化(字号提示 1200ms、图片懒加载 600ms),不必真等。
217
+ setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); try { id.unref?.(); } catch { /* 忽略 */ } timers.set(id, { fn, ms }); return id; },
183
218
  clearTimeout: (id) => clearTimeout(id),
184
219
  MutationObserver: class { observe() {} disconnect() {} },
185
220
  ResizeObserver: class { observe() {} disconnect() {} },
@@ -203,7 +238,7 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
203
238
  ctx.console = dbgConsole;
204
239
  ctx.globalThis = ctx;
205
240
  ctx.self = win;
206
- return { ctx, html, body, frame, sidebar, rightbar, overlay, toggle, win };
241
+ return { ctx, html, body, frame, sidebar, rightbar, overlay, toggle, composer, win, timers };
207
242
  }
208
243
 
209
244
  const ADAPTER_DEBUG = process.env.DSH_ADAPTER_DEBUG === "1";
@@ -221,6 +256,16 @@ function runAdapter(env) {
221
256
  return env;
222
257
  }
223
258
 
259
+ /** 手动推进"延迟才做"的定时器(≤ maxMs),模拟时间流逝(不必真等 1.2 秒)。 */
260
+ function flushTimers(env, maxMs = 2000) {
261
+ for (const [, rec] of [...(env.timers || new Map())]) {
262
+ if (!rec || typeof rec.fn !== "function") continue;
263
+ if (rec.ms <= maxMs) {
264
+ try { rec.fn(); } catch { /* 用例里吞掉:被测代码自己的兜底由它自己保证 */ }
265
+ }
266
+ }
267
+ }
268
+
224
269
  const findScrim = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-scrim"));
225
270
  const findHamburger = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-hamburger"));
226
271
 
@@ -625,3 +670,142 @@ test("网络延迟:无样本 / 失败请求绝不显示 0ms(显示「测速
625
670
  const menu = findMenu(env);
626
671
  assert.match(menu.innerHTML, /测速中…/, "空态文案必须是「测速中…」");
627
672
  });
673
+
674
+ // ══════════════════════════════════════════════════════════════════════════════
675
+ // 首屏性能 & 移动端体验(2026-10-2x)
676
+ //
677
+ // 用户原声:「字体太小,因此不用」(字号档位其实早就有,藏在悬浮菜单里,没人知道);
678
+ // 生产遥测 n=8903:首屏 p50 3.9s / p90 21.6s、20.2% ≥12s。
679
+ // 这里锁住适配层**自己**能控制的那部分:
680
+ // ① 首启字号提示:一次性、挂在 FAB 内部(不新增 body 级浮层)、点它直接打开菜单、
681
+ // 延迟 1.2 秒才出现(绝不掺和首屏)、用户调过字号就不再提示;
682
+ // ② 图片按需加载:只给「视口外且没在加载」的图片打 loading=lazy + decoding=async,
683
+ // 首屏附近的图一个字节都不动(该立刻出现的必须立刻出现);
684
+ // ③ 输入体验:输入框字号跟着档位放大但永不低于 16px(防 iOS 聚焦缩放)、只打属性不改结构;
685
+ // ④ 性能可测量:__dshMaDiag.perf 里有首屏里程碑 / 适配层自身 boot 耗时 / 懒加载计数,
686
+ // 且**不新增遥测事件名**(服务端只认白名单)。
687
+ // ══════════════════════════════════════════════════════════════════════════════
688
+
689
+ test("首启字号提示:1.2s 后出现、挂在 FAB 内部、点它直接打开字号菜单、且只提示一次", () => {
690
+ const env = harness({ width: 390 });
691
+ runAdapter(env);
692
+ const fab = findFab(env);
693
+ assert.ok(fab, "前提:悬浮按钮存在");
694
+ assert.equal(fab.querySelector(".dsh-ma-fs-tip"), null,
695
+ "首屏阶段绝不能出现提示 —— 延迟 1.2 秒就是为了不跟首屏抢主线程/抢视线");
696
+ const bodyKids = env.body.children.length;
697
+
698
+ flushTimers(env, 1200); // 推进「首屏之后」的延迟定时器(不必真等 1.2 秒)
699
+
700
+ const tip = fab.querySelector(".dsh-ma-fs-tip");
701
+ assert.ok(tip, "1.2 秒后应出现「字太小?」提示(字号档位就藏在这颗按钮里)");
702
+ assert.equal(env.body.children.length, bodyKids,
703
+ "★ 提示必须挂在 FAB 内部:body 下不得多出任何浮层(适配层曾经用全屏层干过这事)");
704
+ assert.match(String(tip.innerHTML), /点这里调字号/, "文案必须直接告诉用户该点哪里");
705
+ assert.match(String(tip.innerHTML), /✕/, "必须可关闭(✕)");
706
+ assert.equal(env.win.localStorage.getItem("dsh-ma-fs-tip-v1"), "1",
707
+ "显示即记「已提示过」:同一浏览器只提示一次(localStorage)");
708
+ assert.equal(env.win.__dshMaDiag.perf.tipShown, true, "诊断里要能看到提示是否展示过");
709
+
710
+ // 点气泡 = 直接打开字号菜单(不用先学会点按钮),并收起提示
711
+ const menu = findMenu(env);
712
+ assert.equal(menu.hidden, true, "前提:菜单默认收起");
713
+ tip.dispatch("click");
714
+ assert.equal(menu.hidden, false, "★ 点提示必须打开远程控制菜单(里面就是字号滑块)");
715
+ assert.equal(fab.querySelector(".dsh-ma-fs-tip"), null, "点过之后提示必须收起");
716
+ });
717
+
718
+ test("首启字号提示:已提示过 / 用户自己调过字号 → 都不再提示(不打扰)", () => {
719
+ const env = harness({ width: 390 });
720
+ env.win.localStorage.setItem("dsh-ma-fs-tip-v1", "1"); // 上次已经提示过
721
+ runAdapter(env);
722
+ flushTimers(env, 1200);
723
+ assert.equal(findFab(env).querySelector(".dsh-ma-fs-tip"), null, "已提示过 → 不得再出现");
724
+
725
+ const env2 = harness({ width: 390 });
726
+ env2.win.localStorage.setItem("dsh-ma-font-scale", "2"); // 用户自己调过字号
727
+ runAdapter(env2);
728
+ flushTimers(env2, 1200);
729
+ assert.equal(findFab(env2).querySelector(".dsh-ma-fs-tip"), null,
730
+ "用户已经找到并调过字号 → 不必再告知(提示的目的已达成)");
731
+ });
732
+
733
+ test("图片按需加载:视口外的图打 loading=lazy + decoding=async,首屏附近的图一个字节都不动", () => {
734
+ const env = harness({ width: 390 });
735
+ const far = new El("img");
736
+ far.setAttribute("src", "/assets/chat-far.png");
737
+ far._rect = { left: 0, top: 2000, right: 300, bottom: 2200, width: 300, height: 200 };
738
+ const near = new El("img");
739
+ near.setAttribute("src", "/assets/chat-near.png");
740
+ near._rect = { left: 0, top: 10, right: 300, bottom: 210, width: 300, height: 200 };
741
+ env.body.appendChild(far);
742
+ env.body.appendChild(near);
743
+
744
+ assert.equal(far.getAttribute("loading"), null, "前提:官方 <img> 本来没有 loading 属性");
745
+ runAdapter(env);
746
+ flushTimers(env, 600); // boot 后 600ms 的「首屏之后」扫描
747
+
748
+ assert.equal(far.getAttribute("loading"), "lazy", "★ 视口外的图片必须按需加载(首屏少发请求、少抢带宽)");
749
+ assert.equal(far.getAttribute("decoding"), "async", "解码异步,别阻塞主线程");
750
+ assert.equal(near.getAttribute("loading"), null, "★ 首屏附近的图片绝不能被改成 lazy(该立刻出现的必须立刻出现)");
751
+ assert.equal(near.getAttribute("decoding"), null, "首屏附近的图连 decoding 也不动(保持官方原样)");
752
+
753
+ const perf = env.win.__dshMaDiag.perf;
754
+ assert.ok(perf.imgLazy >= 1, "诊断要能量化「省下的图片请求数」(imgLazy)");
755
+ assert.ok(perf.imgKept >= 1, "首屏附近未动的图片也要计数(imgKept)");
756
+ });
757
+
758
+ test("输入体验:只给官方输入框补属性(enterkeyhint/spellcheck),不动 DOM 结构", () => {
759
+ const env = harness({ width: 390, withComposer: true });
760
+ runAdapter(env);
761
+ flushTimers(env, 600);
762
+ const c = env.composer;
763
+ assert.equal(c.getAttribute("enterkeyhint"), "enter",
764
+ "回车键标注「换行」:与适配层「手机 Enter 只换行、发送交给发送按钮」的策略一致");
765
+ assert.equal(c.getAttribute("spellcheck"), "false", "中文会话里别画拼写红线");
766
+ assert.equal(c.getAttribute("contenteditable"), "true", "★ 只打属性:官方原有属性/结构一个都不能改");
767
+ assert.equal(c.getAttribute("data-composer-input"), "true", "★ 官方锚点属性必须原样保留");
768
+ });
769
+
770
+ test("首屏诊断:__dshMaDiag.perf 有首屏里程碑 + 适配层自身耗时,且不新增遥测事件名", () => {
771
+ const env = harness({ width: 390 });
772
+ runAdapter(env);
773
+ flushTimers(env, 3100);
774
+ const perf = env.win.__dshMaDiag && env.win.__dshMaDiag.perf;
775
+ assert.ok(perf, "必须在 __dshMaDiag 里暴露 perf(用户上报现场时能读)");
776
+ for (const k of ["startAt", "bootCalls", "bootMs", "hostishCalls", "hostishSerializations",
777
+ "fcp", "ttfb", "dcl", "loadEnd", "uiReadyMs", "imgLazy", "imgScanPaused", "tipShown", "vvRepositions"]) {
778
+ assert.ok(k in perf, "perf 缺字段: " + k);
779
+ }
780
+ assert.ok(perf.bootCalls >= 1, "boot 至少跑过一次 —— bootMs 才是「适配层自身耗时」");
781
+ assert.ok(perf.hostishSerializations === 0,
782
+ "★ 有官方结构信号时一次全量序列化都不该做(旧实现每次 boot 都先 outerHTML.slice(0,200000))");
783
+ assert.equal(typeof perf.uiReadyMs, "number", "首屏可用时刻(= mirror_load 的 ms,导航开始 → 可用)");
784
+
785
+ // 纪律:不新增遥测事件名(服务端只认白名单,新名字会被丢弃)→ 唯一允许的端点仍是 mirror-load
786
+ const SRC = readFileSync(new URL("../mobile-adapter.mjs", import.meta.url), "utf8");
787
+ const endpoints = [...new Set([...SRC.matchAll(/\/dsh-remote\/telemetry\/[a-z-]+/g)].map((m) => m[0]))];
788
+ assert.deepEqual(endpoints, ["/dsh-remote/telemetry/mirror-load"],
789
+ "★ 不得新增遥测事件名(白名单只有 mirror_load;新指标只进本地 __dshMaDiag.perf)");
790
+ });
791
+
792
+ test("键盘弹出:visualViewport 变化时悬浮按钮重新贴边(按钮不能被键盘顶到看不见的地方)", async () => {
793
+ const env = harness({ width: 390 });
794
+ // 把按钮记忆在右下角:它的 top 与"可视区高度"直接相关
795
+ env.win.localStorage.setItem("dsh-ma-fab-pos", JSON.stringify({ h: "left", v: "bottom", dx: 10, dy: 10 }));
796
+ const vvListeners = new Map();
797
+ env.win.visualViewport = {
798
+ height: 800,
799
+ addEventListener(t, fn) { (vvListeners.get(t) || vvListeners.set(t, []).get(t)).push(fn); },
800
+ };
801
+ runAdapter(env);
802
+ const fab = findFab(env);
803
+ assert.equal(fab.style.top, String(800 - 46 - 10) + "px", "前提:先按可视区高度贴底");
804
+
805
+ env.win.visualViewport.height = 500; // 键盘弹出:可视区矮了 300px
806
+ for (const fn of vvListeners.get("resize") || []) fn();
807
+ await new Promise((r) => setImmediate(r)); // 重算被合到一帧里(rAF)
808
+ assert.equal(fab.style.top, String(500 - 46 - 10) + "px",
809
+ "★ 键盘弹出后必须按 visualViewport 重新贴边(旧实现用 innerHeight,按钮会留在键盘后面点不到)");
810
+ assert.ok(env.win.__dshMaDiag.perf.vvRepositions >= 1, "重定位次数要进诊断");
811
+ });