@mrrisega/dsh-remote 0.6.12 → 0.6.14
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.
- package/clients/dsh-remote/dsh-bridge.mjs +183 -3
- package/clients/dsh-remote/mobile-adapter.mjs +219 -5
- package/clients/dsh-remote/test/boot-tip.test.mjs +158 -0
- package/clients/dsh-remote/test/bridge-gzip.test.mjs +68 -1
- package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +388 -0
- package/clients/dsh-remote/test/upstream-discovery.test.mjs +196 -0
- package/clients/dsh-remote/upstream-discovery.mjs +292 -0
- package/dsh-setup.mjs +64 -16
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/index.js +106 -0
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +183 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +219 -5
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +292 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +64 -16
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +16 -1
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +148 -0
- package/packages/dsh-remote-web/test/watcher-upstream-port.test.mjs +170 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 镜像页首屏加载提示的契约与行为测试(0.6.14)。
|
|
4
|
+
*
|
|
5
|
+
* 用户反馈:「普通用户加载进入镜像页面时等待时间较长,目前等待过程中一直在转圈。
|
|
6
|
+
* 建议给普通用户增加一个友好的提示(例如提示"正在排队中")。」
|
|
7
|
+
*
|
|
8
|
+
* 首屏为什么慢:镜像页要拉 dsh web 的全部客户端插件(实测 ~14 MB 的一个**不可拆分**聚合请求),
|
|
9
|
+
* 免费档限速下几十秒起步。官方那个转圈不告诉用户任何事,用户只能反复刷新 —— 反而更慢。
|
|
10
|
+
*
|
|
11
|
+
* 这里守四件事:
|
|
12
|
+
* ① 提示必须在**页面解析的第一时间**出现(早于所有主机判定 —— 首屏慢的正是判定等不到的时候);
|
|
13
|
+
* ② 分阶段补充说明(8s / 30s)与秒表,让"到底等了多久"可见;
|
|
14
|
+
* ③ 官方 UI 就绪后自动撤下;且有 4 分钟兜底 + pagehide/unload 清理,绝不留悬挂定时器;
|
|
15
|
+
* ④ 只做提示、不做拦截(pointer-events:none),提示失效也绝不挡住用户操作。
|
|
16
|
+
*
|
|
17
|
+
* 用法: node --test test/boot-tip.test.mjs
|
|
18
|
+
*/
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import test from "node:test";
|
|
23
|
+
import vm from "node:vm";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const SRC = readFileSync(path.join(HERE, "..", "mobile-adapter.mjs"), "utf8");
|
|
28
|
+
const STYLE = SRC.slice(SRC.indexOf("const STYLE = `") + "const STYLE = `".length, SRC.indexOf("`;\n\n/**", SRC.indexOf("const STYLE = `")));
|
|
29
|
+
const SCRIPT_START = SRC.indexOf("const SCRIPT = `(() => {") + "const SCRIPT = `".length;
|
|
30
|
+
const SCRIPT = SRC.slice(SCRIPT_START, SRC.indexOf("`;\n\n/**", SCRIPT_START));
|
|
31
|
+
/**
|
|
32
|
+
* 只取「首屏提示」这一段来跑行为。
|
|
33
|
+
* 为什么不像别的用例那样跑整个适配层:整段要一整套 DOM 桩(抽屉/遮罩/设置面板…),
|
|
34
|
+
* 而这里要验的是**提示的生命周期**,隔离出来既稳又准(改动范围也正好是这一段)。
|
|
35
|
+
*/
|
|
36
|
+
const BOOT = SCRIPT.slice(SCRIPT.indexOf("var maBootTip"), SCRIPT.indexOf("/* —— 主机门解除"));
|
|
37
|
+
|
|
38
|
+
/** 极简 DOM 桩:只支撑"提示出现/撤下"这条路径。 */
|
|
39
|
+
function makeDom({ body = true, ready = false, frame = false } = {}) {
|
|
40
|
+
const nodes = [];
|
|
41
|
+
const el = (tag) => {
|
|
42
|
+
const n = {
|
|
43
|
+
tagName: String(tag).toUpperCase(), className: "", _text: "", children: [], attrs: {}, parentNode: null,
|
|
44
|
+
set innerHTML(v) { this._html = v; }, get innerHTML() { return this._html || ""; },
|
|
45
|
+
set textContent(v) { this._text = String(v); }, get textContent() { return this._text; },
|
|
46
|
+
setAttribute(k, v) { this.attrs[k] = String(v); },
|
|
47
|
+
appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
|
|
48
|
+
removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parentNode = null; return c; },
|
|
49
|
+
querySelector(sel) {
|
|
50
|
+
const cls = String(sel).replace(/^\./, "");
|
|
51
|
+
const hit = (x) => (x.className || "").split(/\s+/).includes(cls);
|
|
52
|
+
const walk = (x) => { for (const c of x.children) { if (hit(c)) return c; const d = walk(c); if (d) return d; } return null; };
|
|
53
|
+
return walk(this);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
return n;
|
|
57
|
+
};
|
|
58
|
+
const doc = {
|
|
59
|
+
readyState: ready ? "complete" : "loading",
|
|
60
|
+
listeners: {},
|
|
61
|
+
createElement: el,
|
|
62
|
+
querySelector(sel) {
|
|
63
|
+
if (sel === ".dsh-ma-boot") return nodes.find((n) => (n.className || "").includes("dsh-ma-boot")) || null;
|
|
64
|
+
if (frame && /frame|textarea|contenteditable|textbox|sidebar-collapsed/.test(sel)) return el("div");
|
|
65
|
+
return null;
|
|
66
|
+
},
|
|
67
|
+
addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
|
|
68
|
+
dispatch(type) { (this.listeners[type] || []).forEach((fn) => fn({})); }
|
|
69
|
+
};
|
|
70
|
+
if (body) { doc.body = el("body"); doc.body.className = ""; }
|
|
71
|
+
else { doc.body = null; doc.bodyHolder = el("body"); }
|
|
72
|
+
return { doc, nodes, el, attachBody() { doc.body = doc.bodyHolder; } };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function run(env) {
|
|
76
|
+
const timers = [];
|
|
77
|
+
const intervals = [];
|
|
78
|
+
const win = {
|
|
79
|
+
listeners: {},
|
|
80
|
+
addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
|
|
81
|
+
dispatch(type) { (this.listeners[type] || []).forEach((fn) => fn({})); },
|
|
82
|
+
innerWidth: 390, innerHeight: 844,
|
|
83
|
+
location: { href: "https://example.test/remote/dev-x/", origin: "https://example.test" },
|
|
84
|
+
MutationObserver: class { observe() {} disconnect() {} },
|
|
85
|
+
requestAnimationFrame: (fn) => { timers.push({ fn, ms: 0 }); return timers.length; },
|
|
86
|
+
cancelAnimationFrame() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} })
|
|
87
|
+
};
|
|
88
|
+
const ctx = {
|
|
89
|
+
window: win,
|
|
90
|
+
document: env.doc,
|
|
91
|
+
location: win.location,
|
|
92
|
+
navigator: { userAgent: "node-test", language: "zh-CN" },
|
|
93
|
+
console: { warn() {}, log() {}, error() {} },
|
|
94
|
+
setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; },
|
|
95
|
+
clearTimeout: () => {},
|
|
96
|
+
setInterval: (fn, ms) => { intervals.push({ fn, ms }); return intervals.length; },
|
|
97
|
+
clearInterval: () => {},
|
|
98
|
+
Date, Math, JSON, Object, Array, String, Number, Boolean, Error, RegExp, Promise, Map, Set
|
|
99
|
+
};
|
|
100
|
+
vm.createContext(ctx);
|
|
101
|
+
vm.runInContext(BOOT, ctx, { timeout: 5000 });
|
|
102
|
+
return { win, timers, intervals };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test("样式:提示层不挡点击(pointer-events:none)、覆盖所有宽度、有转圈与秒表样式", () => {
|
|
106
|
+
assert.match(STYLE, /\.dsh-ma-boot\s*\{[\s\S]*?pointer-events:\s*none/, "提示层绝不能拦截点击");
|
|
107
|
+
assert.ok(!/@media \(max-width: 820px\) \{\s*\.dsh-ma-boot/.test(STYLE), "提示不受窄屏限定(Windows/桌面浏览器同样要等)");
|
|
108
|
+
assert.match(STYLE, /\.dsh-ma-boot-spin/, "要有加载动画");
|
|
109
|
+
assert.match(STYLE, /\.dsh-ma-boot-elapsed/, "要有秒表");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("★ 行为:页面还在解析(readyState=loading)也要立刻挂出提示", () => {
|
|
113
|
+
const env = makeDom({ ready: false, frame: false });
|
|
114
|
+
const { win, timers } = run(env);
|
|
115
|
+
// 提示挂载挂在 DOMContentLoaded 上(此时 body 可能还没出来)
|
|
116
|
+
assert.ok((env.doc.listeners.DOMContentLoaded || []).length > 0, "必须监听 DOMContentLoaded 再挂提示");
|
|
117
|
+
env.doc.dispatch("DOMContentLoaded");
|
|
118
|
+
const tip = env.doc.body.children.find((c) => (c.className || "").includes("dsh-ma-boot"));
|
|
119
|
+
assert.ok(tip, "解析阶段就要出现提示(这正是首屏最慢的时候)");
|
|
120
|
+
assert.match(tip.innerHTML, /正在加载远程桌面/);
|
|
121
|
+
assert.match(tip.innerHTML, /首次打开需要下载/, "要说明为什么慢");
|
|
122
|
+
assert.ok(timers.some((t) => t.ms === 8000) && timers.some((t) => t.ms === 30000), "要有 8s/30s 两段补充说明");
|
|
123
|
+
assert.ok(timers.some((t) => t.ms === 240000), "要有 4 分钟兜底撤下");
|
|
124
|
+
assert.ok((win.listeners.pagehide || []).length > 0, "pagehide 要清理(不留悬挂定时器)");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("★ 行为:官方 UI 就绪后自动撤下提示", () => {
|
|
128
|
+
const env = makeDom({ ready: true, frame: true }); // ready=complete → 立即挂;frame=true → 已就绪
|
|
129
|
+
const { intervals } = run(env);
|
|
130
|
+
const tip = env.doc.body.children.find((c) => (c.className || "").includes("dsh-ma-boot"));
|
|
131
|
+
assert.ok(tip, "先挂上");
|
|
132
|
+
assert.ok(intervals.length >= 2, "要有秒表 + 就绪轮询两个定时器");
|
|
133
|
+
// 两个 interval 都是 1000ms(秒表 / 就绪轮询),顺序不保证 —— 全部推一次,模拟"1 秒过去"
|
|
134
|
+
intervals.forEach((t) => t.fn());
|
|
135
|
+
assert.equal(tip.attrs["data-fading"], "1", "官方 UI 就绪后应进入淡出");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("行为:脚本在 <head> 里先跑(readyState=loading,body 还没出来)也绝不能抛,body 就位后补挂", () => {
|
|
139
|
+
// 真实时序就是这条:适配层被内联在 <head>,执行时 body 可能还不存在
|
|
140
|
+
const env = makeDom({ body: false, ready: false });
|
|
141
|
+
assert.doesNotThrow(() => run(env), "body 缺失时绝不能抛(适配层其他逻辑还要跑)");
|
|
142
|
+
env.attachBody();
|
|
143
|
+
env.doc.dispatch("DOMContentLoaded");
|
|
144
|
+
const tip = env.doc.body.children.find((c) => (c.className || "").includes("dsh-ma-boot"));
|
|
145
|
+
assert.ok(tip, "body 就位后补挂提示");
|
|
146
|
+
assert.match(tip.innerHTML, /正在加载远程桌面/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("源码契约:提示必须早于主机判定(HOSTISH/NARROW),否则首屏最慢那段没有提示", () => {
|
|
150
|
+
// ⚠️ 基准要用 **HOSTISH 的定义**(const HOSTISH =),不能用 indexOf("HOSTISH"):
|
|
151
|
+
// 提示那段自己的注释里就提到了 HOSTISH,拿注释当基准会把顺序判反(第一版就这么红的)。
|
|
152
|
+
const iTip = SCRIPT.indexOf("var maBootTip");
|
|
153
|
+
const iHost = SCRIPT.indexOf("const HOSTISH");
|
|
154
|
+
assert.ok(iTip > 0 && iHost > 0, "找不到提示段或主机判定");
|
|
155
|
+
assert.ok(iTip < iHost, "提示段必须在主机判定之前");
|
|
156
|
+
assert.ok(BOOT.includes("pointer-events: none") || STYLE.includes("pointer-events: none"), "必须写明不拦截点击");
|
|
157
|
+
assert.ok(!/dsh-ma-boot[\s\S]{0,400}?pointer-events:\s*auto/.test(STYLE), "提示层不得改成可点击拦截");
|
|
158
|
+
});
|
|
@@ -15,7 +15,7 @@ import http from "node:http";
|
|
|
15
15
|
import os from "node:os";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import test, { after } from "node:test";
|
|
18
|
-
import { gunzipSync } from "node:zlib";
|
|
18
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
19
19
|
|
|
20
20
|
// ---------- 本地假上游(doHttp 全链路用;端口先占再 import,UPSTREAM 在模块加载时固定) ----------
|
|
21
21
|
|
|
@@ -31,6 +31,25 @@ const upstream = http.createServer((req, res) => {
|
|
|
31
31
|
res.end(BIG_JSON);
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
|
+
/* 模拟**真实 dsh web**:上游自己就把响应 gzip 掉并回 content-encoding: gzip
|
|
35
|
+
(线上 `/plugins/??` 那个 13.4MB 的聚合包正是这样发的)。
|
|
36
|
+
这条路由是 bridge「丢弃上游压缩」那个 bug 的回归哨兵 —— 见文件末尾的集成用例。 */
|
|
37
|
+
if (req.url === "/upstream-gzip") {
|
|
38
|
+
const body = Buffer.from(BIG_JSON, "utf8");
|
|
39
|
+
if (String(req.headers["accept-encoding"] || "").includes("gzip")) {
|
|
40
|
+
const z = gzipSync(body);
|
|
41
|
+
res.writeHead(200, {
|
|
42
|
+
"content-type": "application/json; charset=utf-8",
|
|
43
|
+
"content-encoding": "gzip",
|
|
44
|
+
"content-length": String(z.length)
|
|
45
|
+
});
|
|
46
|
+
res.end(z);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
50
|
+
res.end(body);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
34
53
|
if (req.url === "/tiny") {
|
|
35
54
|
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
36
55
|
res.end(TINY_TEXT);
|
|
@@ -213,3 +232,51 @@ test("集成:204 响应不压缩", async () => {
|
|
|
213
232
|
assert.equal(reply.status, 204);
|
|
214
233
|
assert.equal(reply.headers["content-encoding"], undefined);
|
|
215
234
|
});
|
|
235
|
+
|
|
236
|
+
test("★ 回归:上游自己 gzip 过(content-encoding: gzip),bridge 仍必须重压回传", async () => {
|
|
237
|
+
/* 事故原貌(2026-09-23):
|
|
238
|
+
* undici 的 fetch 会**自动解压** body,但**保留** `content-encoding: gzip` 响应头。
|
|
239
|
+
* 旧代码把该头当作"buf 已编码"传给 maybeCompressResponse → 函数保守 return null →
|
|
240
|
+
* **永不重压**。于是线上 dsh web 的 /plugins/?? 聚合包(上游 gzip 后 5.12MB)
|
|
241
|
+
* 被按 13.39MB 原样塞进隧道(实测 3 次,每次 bridge 日志都是 13707KB)。
|
|
242
|
+
* 免费档 1Mbps 下这一条就把首屏从 ~40 秒拖到 100+ 秒。
|
|
243
|
+
*
|
|
244
|
+
* 这条用例守的是:**上游压过 ≠ 我们手上是压缩数据**。bridge 手上永远是 undici 解压后的明文,
|
|
245
|
+
* 所以必须照常走压缩分支 —— 一旦有人把这个头又接回去,这里立刻红。
|
|
246
|
+
*/
|
|
247
|
+
const reply = await request({
|
|
248
|
+
id: "g5",
|
|
249
|
+
method: "GET",
|
|
250
|
+
path: "/upstream-gzip",
|
|
251
|
+
headers: { "accept-encoding": GZIP_ACCEPT, "user-agent": "phone-browser" }
|
|
252
|
+
});
|
|
253
|
+
assert.equal(reply.status, 200);
|
|
254
|
+
assert.equal(
|
|
255
|
+
reply.headers["content-encoding"],
|
|
256
|
+
"gzip",
|
|
257
|
+
"上游压过也照样要重压 —— 少了这个头,隧道里就是 2.6 倍的白流量"
|
|
258
|
+
);
|
|
259
|
+
const raw = Buffer.from(reply.body, "base64");
|
|
260
|
+
const plainLen = Buffer.byteLength(BIG_JSON);
|
|
261
|
+
assert.ok(raw.length < plainLen, `重压后必须小于明文 (${raw.length} < ${plainLen})`);
|
|
262
|
+
assert.ok(
|
|
263
|
+
raw.length < plainLen * 0.6,
|
|
264
|
+
`应当真正压下去(实测明文 ${plainLen} → ${raw.length});若接近明文说明压缩没生效`
|
|
265
|
+
);
|
|
266
|
+
// 只解压一次就应得到原文 —— 这一条同时排除"双重 gzip"这种更坏的结果
|
|
267
|
+
assert.equal(gunzipSync(raw).toString("utf8"), BIG_JSON, "只能有一层 gzip;解压一次必须就是原文");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("★ 回归:上游 gzip + 客户端没带 Accept-Encoding → 不压缩,但内容仍是明文原文", async () => {
|
|
271
|
+
// 上游按 accept-encoding 决定压不压;bridge 解压后原样回传(不带 content-encoding),
|
|
272
|
+
// 手机端按 identity 处理 → 内容正确,只是没省流量。绝不能出现"标了 gzip 其实是明文"。
|
|
273
|
+
const reply = await request({
|
|
274
|
+
id: "g6",
|
|
275
|
+
method: "GET",
|
|
276
|
+
path: "/upstream-gzip",
|
|
277
|
+
headers: { "user-agent": "phone-browser" }
|
|
278
|
+
});
|
|
279
|
+
assert.equal(reply.status, 200);
|
|
280
|
+
assert.equal(reply.headers["content-encoding"], undefined);
|
|
281
|
+
assert.equal(Buffer.from(reply.body, "base64").toString("utf8"), BIG_JSON);
|
|
282
|
+
});
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mobile-adapter 回归测试:**网络再慢,左侧抽屉按钮(汉堡)也必须最终出现**。
|
|
4
|
+
*
|
|
5
|
+
* 用户原话(2026-09-2x 手机实测):
|
|
6
|
+
* 「在网络比较慢的情况下,左边的抽屉按钮有时候出不来。你要考虑修复这个问题,
|
|
7
|
+
* 甭管网络有多慢,都要让那个抽屉能加载出来。」
|
|
8
|
+
*
|
|
9
|
+
* 根因(本文件针对的 bug):
|
|
10
|
+
* 注入脚本 boot() 的**执行时机**是写死的一组固定重试
|
|
11
|
+
* (DOMContentLoaded + 700 / 1600 / 3200 / 7000 / 12000 ms),
|
|
12
|
+
* 而汉堡按钮的创建排在 `if (!frame) return;` **之后**:
|
|
13
|
+
* · 官方 SPA bundle 有 13.4MB(实测未压缩),免费档被中继限速到 128KB/s 时冷启动要 1-2 分钟;
|
|
14
|
+
* · 官方 frame 在 12 秒内根本没出现 → 5 次重试全部在那一行 return
|
|
15
|
+
* → `done` 永远为 false → 汉堡按钮永远不创建 → 用户看到"左边抽屉按钮出不来"。
|
|
16
|
+
*
|
|
17
|
+
* 修法(本文件断言的契约):
|
|
18
|
+
* ① 不再依赖任何固定重试窗口 —— 改成「事件驱动(MutationObserver 观察 document/subtree)
|
|
19
|
+
* + 低频兜底(setInterval 1000ms)」的**无限期**等待:只要 done 还是 false 就继续 boot();
|
|
20
|
+
* ② done=true 之后仍有「存在性看门狗」:官方重渲染把 dsh-ma-hamburger / dsh-ma-scrim
|
|
21
|
+
* 从 DOM 里冲掉时,按引用补回(节点不变 = 事件处理与类名都还在);
|
|
22
|
+
* ③ 两条路都必须幂等、有节流(观察者回调走 rAF 合并,兜底表 1s 一跳),
|
|
23
|
+
* 且页面卸载(pagehide/unload)时断开观察者与表。
|
|
24
|
+
*
|
|
25
|
+
* 本文件用假 DOM + vm 跑**真实注入脚本**,并把时间轴完全握在手里(不依赖真实计时器):
|
|
26
|
+
* · flushTimeouts() —— 把旧的固定重试窗口一次性走完(正是"窗口耗尽"的现场);
|
|
27
|
+
* · tickIntervals() —— 模拟兜底 setInterval 每秒一跳,可以一直跳到第 15 秒、第 60 秒;
|
|
28
|
+
* · mountFrame() —— 模拟官方 DOM 迟到挂载,并按真实 MutationObserver 语义通知观察者。
|
|
29
|
+
*/
|
|
30
|
+
import assert from "node:assert/strict";
|
|
31
|
+
import test from "node:test";
|
|
32
|
+
import vm from "node:vm";
|
|
33
|
+
import { injectMobileAdapter } from "../mobile-adapter.mjs";
|
|
34
|
+
|
|
35
|
+
/** 取注入结果里的真实 <script> 内容(不加壳,原样执行)。 */
|
|
36
|
+
function adapterScript() {
|
|
37
|
+
const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
|
|
38
|
+
const m = /<script id="[^"]*-js"[^>]*>([\s\S]*?)<\/script>/.exec(html);
|
|
39
|
+
assert.ok(m, "应能取出注入脚本");
|
|
40
|
+
return m[1];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── 极简假 DOM(与 mobile-adapter-runtime.test.mjs 同风格,另加父子链/isConnected) ──────
|
|
44
|
+
|
|
45
|
+
class El {
|
|
46
|
+
constructor(tag = "div", attrs = {}) {
|
|
47
|
+
this.tagName = String(tag).toUpperCase();
|
|
48
|
+
this.attrs = { ...attrs };
|
|
49
|
+
this.children = [];
|
|
50
|
+
this.parent = null;
|
|
51
|
+
this._cls = new Set(String(attrs.class || "").split(/\s+/).filter(Boolean));
|
|
52
|
+
this._l = new Map();
|
|
53
|
+
this.style = {
|
|
54
|
+
setProperty: (k, v) => { this._cssVars[k] = v; },
|
|
55
|
+
removeProperty: (k) => { delete this._cssVars[k]; },
|
|
56
|
+
display: "",
|
|
57
|
+
};
|
|
58
|
+
this._cssVars = {};
|
|
59
|
+
this._rect = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 };
|
|
60
|
+
this.hidden = false;
|
|
61
|
+
this.classList = {
|
|
62
|
+
add: (...cs) => cs.forEach((c) => this._cls.add(c)),
|
|
63
|
+
remove: (...cs) => cs.forEach((c) => this._cls.delete(c)),
|
|
64
|
+
contains: (c) => this._cls.has(c),
|
|
65
|
+
toggle: (c, on) => {
|
|
66
|
+
const want = on === undefined ? !this._cls.has(c) : !!on;
|
|
67
|
+
if (want) this._cls.add(c); else this._cls.delete(c);
|
|
68
|
+
return want;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
set innerHTML(html) {
|
|
73
|
+
this._html = String(html);
|
|
74
|
+
this.children = [];
|
|
75
|
+
for (const m of String(html).matchAll(/<(\w+)([^>]*)>/g)) {
|
|
76
|
+
const el = new El(m[1]);
|
|
77
|
+
const cls = /class="([^"]*)"/.exec(m[2] || "");
|
|
78
|
+
if (cls) el.className = cls[1];
|
|
79
|
+
const type = /type="([^"]*)"/.exec(m[2] || "");
|
|
80
|
+
if (type) el.attrs.type = type[1];
|
|
81
|
+
this.appendChild(el);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
get innerHTML() { return this._html || ""; }
|
|
85
|
+
get className() { return [...this._cls].join(" "); }
|
|
86
|
+
set className(v) { this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); }
|
|
87
|
+
get textContent() { return this._text || ""; }
|
|
88
|
+
set textContent(v) { this._text = String(v); }
|
|
89
|
+
getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; }
|
|
90
|
+
setAttribute(n, v) { this.attrs[n] = String(v); }
|
|
91
|
+
removeAttribute(n) { delete this.attrs[n]; }
|
|
92
|
+
hasAttribute(n) { return n in this.attrs; }
|
|
93
|
+
appendChild(c) { c.parent = this; this.children.push(c); return c; }
|
|
94
|
+
removeChild(c) { this.children = this.children.filter((x) => x !== c); if (c.parent === this) c.parent = null; return c; }
|
|
95
|
+
remove() { if (this.parent) this.parent.removeChild(this); }
|
|
96
|
+
contains(el) { let n = el; while (n) { if (n === this) return true; n = n.parent; } return false; }
|
|
97
|
+
/* 真实 DOM 语义:节点是否还挂在文档里。本假 DOM 以"能沿 parent 链走到 doc"为准。 */
|
|
98
|
+
get isConnected() { let n = this; while (n) { if (n._inDoc) return true; n = n.parent; } return false; }
|
|
99
|
+
prepend() {} focus() {} blur() {} scrollIntoView() {} setPointerCapture() {}
|
|
100
|
+
matches() { return false; }
|
|
101
|
+
addEventListener(t, fn) { if (!this._l.has(t)) this._l.set(t, []); this._l.get(t).push(fn); }
|
|
102
|
+
removeEventListener() {}
|
|
103
|
+
dispatchEvent() { return true; }
|
|
104
|
+
dispatch(t, ev = {}) { for (const fn of this._l.get(t) || []) fn({ stopPropagation() {}, preventDefault() {}, ...ev }); }
|
|
105
|
+
getBoundingClientRect() { return this._rect; }
|
|
106
|
+
querySelector(sel) {
|
|
107
|
+
const s = String(sel);
|
|
108
|
+
const hit = (el) => {
|
|
109
|
+
if (s.includes('input[type="range"]')) return el.tagName === "INPUT" && el.attrs.type === "range";
|
|
110
|
+
if (s.startsWith(".")) return el._cls.has(s.slice(1));
|
|
111
|
+
if (s.startsWith("input")) return el.tagName === "INPUT";
|
|
112
|
+
return false;
|
|
113
|
+
};
|
|
114
|
+
return this.children.find(hit) || null;
|
|
115
|
+
}
|
|
116
|
+
querySelectorAll(sel) { const one = this.querySelector(sel); return one ? [one] : []; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 记录型 MutationObserver:可被手工触发,也可被 mountFrame 按真实语义叫醒。 */
|
|
120
|
+
class MutationObserverStub {
|
|
121
|
+
constructor(cb) { this.cb = cb; this.targets = []; this.disconnected = false; MutationObserverStub.instances.push(this); }
|
|
122
|
+
observe(t, opts) { this.targets.push({ t, opts }); }
|
|
123
|
+
disconnect() { this.disconnected = true; }
|
|
124
|
+
fire(records) { if (!this.disconnected) this.cb(records, this); }
|
|
125
|
+
static reset() { MutationObserverStub.instances = []; }
|
|
126
|
+
}
|
|
127
|
+
MutationObserverStub.instances = [];
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 搭一个"官方 index.html 已经回来、但 13.4MB 的 SPA bundle 还没渲染出 frame"的现场:
|
|
131
|
+
* · html.outerHTML 只有官方外壳特征(__ModuleLoader__ / DeepSeek Harness)——
|
|
132
|
+
* 与真实 index.html 一致,**不含** frame 的任何 data-* 标记;
|
|
133
|
+
* · document.querySelector(官方 frame 选择器) 在 mountFrame() 之前一律返回 null
|
|
134
|
+
* (= 官方 UI 还没挂载,正是用户网络慢时看到的状态)。
|
|
135
|
+
*/
|
|
136
|
+
function harness({ width = 390, height = 844 } = {}) {
|
|
137
|
+
MutationObserverStub.reset();
|
|
138
|
+
const intervals = [];
|
|
139
|
+
const timeouts = [];
|
|
140
|
+
let rafs = [];
|
|
141
|
+
const state = { mounted: false, pagehide: null };
|
|
142
|
+
|
|
143
|
+
const doc = new El("#document");
|
|
144
|
+
doc.tagName = "#DOCUMENT";
|
|
145
|
+
doc._inDoc = true; // 文档根:沿 parent 链能走到它就说明"在文档里"
|
|
146
|
+
const html = new El("html");
|
|
147
|
+
const body = new El("body");
|
|
148
|
+
doc.appendChild(html);
|
|
149
|
+
html.appendChild(body);
|
|
150
|
+
// 官方 index.html 外壳(真实 HTML 里就是这些字样;frame 是 bundle 跑起来之后才有的)
|
|
151
|
+
// outerHTML 做成计数器:boot() 第一行的 HOSTISH() 会整段序列化它,读数就是"整页判定"的次数
|
|
152
|
+
let outerHtmlReads = 0;
|
|
153
|
+
Object.defineProperty(html, "outerHTML", {
|
|
154
|
+
configurable: true,
|
|
155
|
+
get() { outerHtmlReads += 1; return '<html><head><script>window.__ModuleLoader__ = { mode: "queue" }</script>'
|
|
156
|
+
+ "<title>DeepSeek Harness</title></head><body></body></html>"; },
|
|
157
|
+
set() { /* 允许写入(本用例不依赖) */ },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const frame = new El("div", { class: "pI_x6G_frame" });
|
|
161
|
+
frame.setAttribute("data-sidebar-collapsed", "true");
|
|
162
|
+
frame.querySelector = () => null; // 本用例不涉及第三列/overlay 识别
|
|
163
|
+
|
|
164
|
+
doc.readyState = "complete";
|
|
165
|
+
doc.documentElement = html;
|
|
166
|
+
doc.body = body;
|
|
167
|
+
doc.createElement = (t) => new El(t);
|
|
168
|
+
doc.createTextNode = (t) => ({ nodeValue: t });
|
|
169
|
+
doc.getElementById = () => null;
|
|
170
|
+
doc.addEventListener = () => {};
|
|
171
|
+
doc.removeEventListener = () => {};
|
|
172
|
+
doc.querySelector = (sel) => {
|
|
173
|
+
const s = String(sel);
|
|
174
|
+
if (s.includes(".pI_x6G_frame") || /\[data-(sidebar|details|rightbar)-collapsed\]/.test(s) || s.includes("[data-shell-overlay]")) {
|
|
175
|
+
return state.mounted ? frame : null; // ⬅️ 官方 UI 迟到:挂载前一律查不到
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
};
|
|
179
|
+
doc.querySelectorAll = () => [];
|
|
180
|
+
|
|
181
|
+
const loc = { href: "https://n.risegao.cn:13443/remote/dev-x/", host: "n.risegao.cn", pathname: "/remote/dev-x/", origin: "https://n.risegao.cn:13443" };
|
|
182
|
+
const win = {
|
|
183
|
+
innerWidth: width,
|
|
184
|
+
innerHeight: height,
|
|
185
|
+
devicePixelRatio: 2,
|
|
186
|
+
localStorage: { _d: {}, getItem(k) { return k in this._d ? this._d[k] : null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } },
|
|
187
|
+
location: loc,
|
|
188
|
+
MutationObserver: MutationObserverStub,
|
|
189
|
+
ResizeObserver: class { observe() {} disconnect() {} },
|
|
190
|
+
getComputedStyle: () => ({ getPropertyValue: () => "", pointerEvents: "none" }),
|
|
191
|
+
addEventListener(t, fn) {
|
|
192
|
+
if (t === "pagehide" || t === "unload") state.pagehide = fn;
|
|
193
|
+
if (t === "pageshow") state.pageshow = fn;
|
|
194
|
+
},
|
|
195
|
+
removeEventListener() {},
|
|
196
|
+
/* 计时器全部"抓住不放",时间轴由用例自己推 —— 不依赖真实时钟,也不会有测试结束后的悬挂回调。 */
|
|
197
|
+
setTimeout: (fn) => timeouts.push(fn),
|
|
198
|
+
clearTimeout: () => {},
|
|
199
|
+
setInterval: (fn) => { intervals.push(fn); return intervals.length; },
|
|
200
|
+
clearInterval: (id) => { if (id) intervals[id - 1] = null; },
|
|
201
|
+
requestAnimationFrame: (fn) => { rafs.push(fn); return rafs.length; },
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const ctx = vm.createContext({
|
|
205
|
+
window: win, document: doc, console,
|
|
206
|
+
navigator: { userAgent: "iPhone Safari", maxTouchPoints: 5, platform: "iPhone" },
|
|
207
|
+
location: loc, localStorage: win.localStorage,
|
|
208
|
+
setTimeout: win.setTimeout, clearTimeout: win.clearTimeout,
|
|
209
|
+
setInterval: win.setInterval, clearInterval: win.clearInterval,
|
|
210
|
+
MutationObserver: MutationObserverStub, ResizeObserver: win.ResizeObserver,
|
|
211
|
+
requestAnimationFrame: win.requestAnimationFrame,
|
|
212
|
+
URL, Math, JSON, Object, Array, String, Number, RegExp, Boolean, Promise, Error, isNaN, parseInt, parseFloat,
|
|
213
|
+
HTMLElement: El,
|
|
214
|
+
});
|
|
215
|
+
ctx.globalThis = ctx;
|
|
216
|
+
ctx.self = win;
|
|
217
|
+
|
|
218
|
+
/** 官方 UI 迟到挂载:把 frame 挂进 body,并按真实 MutationObserver 语义通知观察祖先树的观察者。 */
|
|
219
|
+
const mountFrame = ({ notify = true } = {}) => {
|
|
220
|
+
state.mounted = true;
|
|
221
|
+
body.appendChild(frame);
|
|
222
|
+
if (!notify) return;
|
|
223
|
+
for (const ob of MutationObserverStub.instances.slice()) {
|
|
224
|
+
if (ob.disconnected) continue;
|
|
225
|
+
const targets = ob.targets.map((x) => x.t);
|
|
226
|
+
// 新节点加进 body → 观察 body / documentElement / document(subtree)的观察者都会收到 childList 记录
|
|
227
|
+
if (targets.includes(body) || targets.includes(html) || targets.includes(doc)) {
|
|
228
|
+
ob.fire([{ type: "childList", target: body, addedNodes: [frame] }]);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
/** 把旧的"固定重试窗口"(700/1600/3200/7000/12000ms 那批 setTimeout)一次性走完。 */
|
|
233
|
+
const flushTimeouts = () => { const q = timeouts.splice(0, timeouts.length); for (const fn of q) fn(); };
|
|
234
|
+
/** 模拟兜底 setInterval 每秒一跳(间隔由实现决定,用例只关心"它一直在跳")。 */
|
|
235
|
+
const tickIntervals = (seconds = 1) => {
|
|
236
|
+
for (let i = 0; i < seconds; i++) for (const fn of intervals.slice()) if (fn) fn();
|
|
237
|
+
};
|
|
238
|
+
const flushRaf = () => { const q = rafs; rafs = []; for (const fn of q) fn(); };
|
|
239
|
+
/** 模拟官方 SPA 的 DOM 变更风暴(流式输出时每帧都在改 DOM),并按真实语义通知观察者。 */
|
|
240
|
+
const churn = (n = 20) => {
|
|
241
|
+
for (let i = 0; i < n; i++) {
|
|
242
|
+
const d = new El("div");
|
|
243
|
+
body.appendChild(d);
|
|
244
|
+
for (const ob of MutationObserverStub.instances.slice()) {
|
|
245
|
+
if (ob.disconnected) continue;
|
|
246
|
+
const targets = ob.targets.map((x) => x.t);
|
|
247
|
+
if (targets.includes(body) || targets.includes(html) || targets.includes(doc)) {
|
|
248
|
+
ob.fire([{ type: "childList", target: body, addedNodes: [d] }]);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
flushRaf();
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
return { ctx, doc, html, body, frame, win, state, mountFrame, flushTimeouts, tickIntervals, flushRaf, churn,
|
|
256
|
+
intervals, outerHtmlReads: () => outerHtmlReads };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function run(env, js) {
|
|
260
|
+
vm.runInContext(js, env.ctx, { timeout: 5000 });
|
|
261
|
+
return env;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const findHamburger = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-hamburger"));
|
|
265
|
+
const findScrim = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-scrim"));
|
|
266
|
+
|
|
267
|
+
// ── 用例 ─────────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
test("慢网回归:官方 frame 第 15 秒才出现,汉堡按钮最终仍必须创建(旧的 12s 固定重试窗口会永久放弃)", () => {
|
|
270
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
271
|
+
|
|
272
|
+
assert.ok(!findHamburger(env), "前提:官方 frame 还没出现时,不该凭空造出汉堡按钮");
|
|
273
|
+
assert.ok(!findScrim(env), "前提:官方 frame 还没出现时,不该凭空造出遮罩");
|
|
274
|
+
|
|
275
|
+
// ① 先把旧的固定重试窗口整批走完(700…12000ms 全部到点)—— 这正是当年"窗口耗尽"的现场
|
|
276
|
+
env.flushTimeouts();
|
|
277
|
+
assert.ok(!findHamburger(env), "官方 frame 未出现时仍不得创建汉堡按钮(不能提前造出无锚点的入口)");
|
|
278
|
+
|
|
279
|
+
// ② 再把时间推过 15 秒(兜底表每秒一跳),官方 frame 依然没来
|
|
280
|
+
env.tickIntervals(15);
|
|
281
|
+
assert.ok(!findHamburger(env), "前提:frame 还没来");
|
|
282
|
+
|
|
283
|
+
// ③ 第 15 秒:13.4MB 的 bundle 终于在 128KB/s 的限制下加载完,官方 frame 挂上 DOM。
|
|
284
|
+
// 这里刻意不通知观察者(notify:false)—— 只留"低频兜底 setInterval"这一条路,
|
|
285
|
+
// 证明它不是"靠事件侥幸",而是真的无限期重试。
|
|
286
|
+
env.mountFrame({ notify: false });
|
|
287
|
+
assert.ok(!findHamburger(env), "前提:这一瞬间还没轮到兜底表跳");
|
|
288
|
+
|
|
289
|
+
env.tickIntervals(1);
|
|
290
|
+
assert.ok(findHamburger(env),
|
|
291
|
+
"网络再慢,官方 frame 一出现抽屉按钮也必须最终创建出来(本次 bug 的回归点:固定重试窗口耗尽就永久放弃)");
|
|
292
|
+
assert.ok(findScrim(env), "遮罩同样必须补上(它和汉堡按钮在同一段之后创建)");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("慢网回归:跳到第 60 秒(远超旧窗口 5 倍)官方 frame 才出现,汉堡按钮仍必须出现", () => {
|
|
296
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
297
|
+
env.flushTimeouts();
|
|
298
|
+
env.tickIntervals(60); // 60 秒过去,frame 还没来
|
|
299
|
+
assert.ok(!findHamburger(env), "前提:frame 仍然没来");
|
|
300
|
+
env.mountFrame({ notify: false }); // 第 60 秒才挂上
|
|
301
|
+
env.tickIntervals(1);
|
|
302
|
+
assert.ok(findHamburger(env), "「甭管网络有多慢」——60 秒后才出现官方 frame 也必须能拿到抽屉按钮");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("事件驱动:官方 frame 挂载的 DOM 变更直接叫醒适配层(观察者回调经 rAF 合并,不等兜底表)", () => {
|
|
306
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
307
|
+
assert.ok(!findHamburger(env), "前提:frame 未挂载");
|
|
308
|
+
env.mountFrame(); // 通知观察者
|
|
309
|
+
env.flushRaf(); // 合并后的那一帧
|
|
310
|
+
assert.ok(findHamburger(env), "官方 frame 一挂上就必须立刻跟上(事件驱动路径),不必等 1s 兜底表");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("存在性看门狗:done 之后官方重渲染把汉堡/遮罩冲掉,必须按引用补回来", () => {
|
|
314
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
315
|
+
env.mountFrame({ notify: false });
|
|
316
|
+
env.tickIntervals(1);
|
|
317
|
+
const ham = findHamburger(env);
|
|
318
|
+
const scrim = findScrim(env);
|
|
319
|
+
assert.ok(ham && scrim, "前提:抽屉入口已建好");
|
|
320
|
+
|
|
321
|
+
// 模拟官方 SPA 重渲染:整片换掉 body 子树(实测某些路由/会话切换会重建 DOM)
|
|
322
|
+
env.body.removeChild(ham);
|
|
323
|
+
env.body.removeChild(scrim);
|
|
324
|
+
assert.ok(!findHamburger(env), "前提:官方重渲染把我们的节点冲掉了");
|
|
325
|
+
assert.equal(ham.isConnected, false, "假 DOM 语义:被移除的节点应报 isConnected=false");
|
|
326
|
+
|
|
327
|
+
env.tickIntervals(2);
|
|
328
|
+
assert.equal(findHamburger(env), ham, "补回的必须是**同一个节点**(事件处理与类名原样保留),而不是重建一个");
|
|
329
|
+
assert.equal(findScrim(env), scrim, "遮罩同样必须补回");
|
|
330
|
+
assert.equal(ham.isConnected, true, "补回后应重新在文档里");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("清理:pagehide 必须断开观察者与兜底表(避免 bfcache/长驻页面泄漏)", () => {
|
|
334
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
335
|
+
assert.equal(typeof env.state.pagehide, "function", "应注册 pagehide 清理钩子");
|
|
336
|
+
const observers = MutationObserverStub.instances.slice();
|
|
337
|
+
env.state.pagehide();
|
|
338
|
+
assert.ok(observers.every((o) => o.disconnected), "pagehide 必须 disconnect 所有已挂观察者");
|
|
339
|
+
// 断开后时间轴上再跳也不再工作(表已清)
|
|
340
|
+
env.mountFrame({ notify: false });
|
|
341
|
+
env.tickIntervals(2);
|
|
342
|
+
assert.ok(!findHamburger(env), "卸载后不得再往页面里塞东西");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("bfcache:切走再切回来(pageshow persisted)必须重新武装看门狗,否则回来后就永久失灵", () => {
|
|
346
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
347
|
+
env.mountFrame({ notify: false });
|
|
348
|
+
env.tickIntervals(1);
|
|
349
|
+
const ham = findHamburger(env);
|
|
350
|
+
assert.ok(ham, "前提:抽屉入口已就位");
|
|
351
|
+
|
|
352
|
+
env.state.pagehide(); // 切走(进入 bfcache)
|
|
353
|
+
env.state.pageshow({ persisted: true }); // 切回来(从 bfcache 恢复)
|
|
354
|
+
env.body.removeChild(ham); // 官方此时重渲染冲掉我们的节点
|
|
355
|
+
env.tickIntervals(1);
|
|
356
|
+
assert.equal(findHamburger(env), ham, "bfcache 恢复后看门狗必须仍然有效(重新武装)");
|
|
357
|
+
env.state.pageshow({ persisted: false }); // 普通 pageshow 不得重复武装出第二套计时器
|
|
358
|
+
const live = env.intervals.filter(Boolean).length;
|
|
359
|
+
env.state.pageshow({ persisted: true });
|
|
360
|
+
assert.equal(env.intervals.filter(Boolean).length, live, "重新武装必须先清旧表(不能越切越多计时器)");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("幂等:兜底表长时间空转不得重复插入节点(每类节点在 body 里最多一个)", () => {
|
|
364
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
365
|
+
env.mountFrame({ notify: false });
|
|
366
|
+
env.tickIntervals(30); // 空转 30 秒
|
|
367
|
+
const count = (cls) => env.body.children.filter((c) => c._cls.has(cls)).length;
|
|
368
|
+
assert.equal(count("dsh-ma-hamburger"), 1, "汉堡按钮必须只有一个(幂等,不能每次 tick 都插一个)");
|
|
369
|
+
assert.equal(count("dsh-ma-scrim"), 1, "遮罩必须只有一个");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("开销:抽屉入口就位后,官方的 DOM 变更风暴不得再触发整页判定(HOSTISH 会序列化整个 outerHTML)", () => {
|
|
373
|
+
const env = run(harness({ width: 390 }), adapterScript());
|
|
374
|
+
env.mountFrame({ notify: false });
|
|
375
|
+
env.tickIntervals(1);
|
|
376
|
+
assert.ok(findHamburger(env), "前提:抽屉入口已就位");
|
|
377
|
+
|
|
378
|
+
// 官方流式输出时每帧都在改 DOM —— 这时适配层必须已经"静默",不能再做昂贵判定
|
|
379
|
+
const before = env.outerHtmlReads();
|
|
380
|
+
env.churn(30); // 30 帧 DOM 变更(每帧都通知观察者)
|
|
381
|
+
env.tickIntervals(3); // 兜底表再空转 3 秒
|
|
382
|
+
assert.equal(env.outerHtmlReads(), before,
|
|
383
|
+
"入口已经建好且都在文档里时,不得再为每次 DOM 变更/每秒 tick 重新序列化整页(那是每秒几十次全量 outerHTML)");
|
|
384
|
+
// 但"看门狗"必须还在:真被冲掉时依然能补回来(静默 ≠ 失灵)
|
|
385
|
+
env.body.removeChild(findHamburger(env));
|
|
386
|
+
env.churn(1);
|
|
387
|
+
assert.ok(findHamburger(env), "静默之后看门狗仍必须工作");
|
|
388
|
+
});
|