@mrrisega/dsh-remote 0.6.14 → 0.6.15

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.
Files changed (34) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +64 -13
  2. package/clients/dsh-remote/dsh-events.mjs +22 -2
  3. package/clients/dsh-remote/e2ee-client.mjs +97 -13
  4. package/clients/dsh-remote/e2ee-shim-script.js +31 -3
  5. package/clients/dsh-remote/mobile-adapter.mjs +172 -0
  6. package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
  7. package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
  8. package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
  9. package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
  10. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +18 -1
  11. package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
  12. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
  13. package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
  14. package/clients/dsh-remote/upstream-discovery.mjs +186 -30
  15. package/clients/dsh-remote/wechat-channel.mjs +86 -3
  16. package/clients/dsh-remote/wechat-runtime.mjs +253 -10
  17. package/dsh-setup.mjs +161 -7
  18. package/package.json +1 -1
  19. package/packages/dsh-remote-web/lib/client.js +31 -1
  20. package/packages/dsh-remote-web/lib/index.js +392 -49
  21. package/packages/dsh-remote-web/package.json +1 -1
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +64 -13
  23. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
  24. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
  25. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
  26. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +172 -0
  27. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +186 -30
  28. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
  29. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
  30. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +161 -7
  31. package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
  32. package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
  33. package/packages/dsh-remote-web/test/picker-pin.test.mjs +80 -27
  34. package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
@@ -105,9 +105,12 @@ const OFFICIAL_BODY = `<!doctype html>
105
105
  <body><div id="root"></div></body></html>`;
106
106
 
107
107
  const upstream = http.createServer((req, res) => {
108
- let body = "";
109
- req.on("data", (c) => { body += c; });
108
+ // ⚠️ 必须按 Buffer 攒再一次性 toString:`body += chunk` 会对**每个分片**单独 toString,
109
+ // 一个多字节 UTF-8 字符正好跨在 64KB 分片边界上就会被拆坏(0.6.15 加了大正文用例才暴露)。
110
+ const chunks = [];
111
+ req.on("data", (c) => chunks.push(c));
110
112
  req.on("end", () => {
113
+ const body = Buffer.concat(chunks).toString("utf8");
111
114
  // 官方 dsh web 特征 html(Phase-4:验证 e2ee 启用的桥端注入镜像 shim)
112
115
  if (req.url === "/official" || req.url.startsWith("/api/official")) {
113
116
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
@@ -115,7 +118,7 @@ const upstream = http.createServer((req, res) => {
115
118
  return;
116
119
  }
117
120
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
118
- res.end(JSON.stringify({ method: req.method, url: req.url, ct: req.headers["content-type"] || "", auth: req.headers["x-dsh-test-auth"] || "", body }));
121
+ res.end(JSON.stringify({ method: req.method, url: req.url, ct: req.headers["content-type"] || "", auth: req.headers["x-dsh-test-auth"] || "", want: req.headers["x-dsh-e2ee-want"] || "", body }));
119
122
  });
120
123
  });
121
124
  let upstreamWss = null;
@@ -375,6 +378,73 @@ test("E2EE HTTP:信封请求解封→上游→封回往返一致;篡改密文
375
378
  assert.match(badText, /auth_failed|无法解密/);
376
379
  });
377
380
 
381
+ test("★ 0.6.15 三层 base64 降为两层:声明过的客户端收到二进制明文,协商头不转发上游", async () => {
382
+ // 业主实测(dev-1014f947f8ab,Ubuntu 上的 dsh + Windows 访问,首屏要等几分钟):
383
+ // 同一条响应被 base64 编了三层(1.333³≈2.37×)。第一层是"gzip 正文进 JSON",
384
+ // 现在由客户端声明 x-dsh-e2ee-want: bin 换成二进制框架;老客户端不声明 → 形态不变。
385
+ const u = globalThis.__unlocked;
386
+ assert.ok(u, "需要先解锁");
387
+ const { client } = u;
388
+ const payload = "首屏聚合包内容-".repeat(4000); // ~ 100KB:足够看出编码层差异
389
+
390
+ // ① 新客户端:声明二进制明文
391
+ const plainBin = Buffer.from(JSON.stringify({
392
+ m: "POST",
393
+ p: "/api/echo?bin=1",
394
+ h: { "content-type": "application/json; charset=utf-8", "x-dsh-e2ee-want": "bin" },
395
+ b: Buffer.from(payload, "utf8").toString("base64")
396
+ }), "utf8");
397
+ const envBin = client.seal({ kind: "http", dir: "p2b", counter: 21, data: plainBin });
398
+ const resBin = await fetch(`${routerBase}/remote/${DEV_ON}/api/echo?bin=1`, {
399
+ method: "POST",
400
+ headers: { ...envelopeRequestHeaders(client.sessId, "http"), cookie: COOKIE },
401
+ body: JSON.stringify(envBin)
402
+ });
403
+ assert.equal(resBin.status, 200);
404
+ const envRespBin = JSON.parse(await resBin.text());
405
+ const openedBin = client.open({ kind: "http-resp", dir: "b2p", env: envRespBin, counter: envRespBin.c, reqNonceB64: envBin.n });
406
+ assert.equal(openedBin.data[0], 0x02, "★ 声明过的客户端必须收到二进制明文框架(首字节 0x02)");
407
+ const respBin = decodeHttpResponsePlain(openedBin.data);
408
+ const upBin = JSON.parse(respBin.bodyBuffer.toString("utf8"));
409
+ assert.equal(upBin.want, "", "★ 内部协商头绝不能转发给上游 dsh web(它不是业务头)");
410
+ assert.equal(upBin.body, payload, "正文必须逐字节还原");
411
+
412
+ // ② 老客户端(不声明)→ 仍是旧 JSON 形态,解码照样成功(兼容性)
413
+ const plainOld = Buffer.from(JSON.stringify({
414
+ m: "POST",
415
+ p: "/api/echo?bin=0",
416
+ h: { "content-type": "application/json; charset=utf-8" },
417
+ b: Buffer.from(payload, "utf8").toString("base64")
418
+ }), "utf8");
419
+ const envOld = client.seal({ kind: "http", dir: "p2b", counter: 22, data: plainOld });
420
+ const resOld = await fetch(`${routerBase}/remote/${DEV_ON}/api/echo?bin=0`, {
421
+ method: "POST",
422
+ headers: { ...envelopeRequestHeaders(client.sessId, "http"), cookie: COOKIE },
423
+ body: JSON.stringify(envOld)
424
+ });
425
+ const envRespOld = JSON.parse(await resOld.text());
426
+ const openedOld = client.open({ kind: "http-resp", dir: "b2p", env: envRespOld, counter: envRespOld.c, reqNonceB64: envOld.n });
427
+ assert.equal(openedOld.data[0], 0x7b, "老客户端必须仍收到旧 JSON 明文(首字节 '{')");
428
+ const respOld = decodeHttpResponsePlain(openedOld.data);
429
+ assert.equal(JSON.parse(respOld.bodyBuffer.toString("utf8")).body, payload);
430
+ // 同一份正文:新形态必须明显更小(省掉的就是那一层 33%)
431
+ assert.ok(openedOld.data.length > openedBin.data.length * 1.25,
432
+ `旧明文形态必须明显更大(实测 ${openedOld.data.length} vs ${openedBin.data.length})`);
433
+ });
434
+
435
+ test("★ 0.6.15 隧道帧不再给信封 JSON 套第三层 base64(bridge→中继 是计量那条腿)", async () => {
436
+ const { e2eeHttpResponseFrame } = await import("../dsh-bridge.mjs");
437
+ const env = { v: 2, k: "http-resp", s: "s".repeat(32), c: 1, n: "n".repeat(16), t: 0, d: "A".repeat(3 * 1024 * 1024) };
438
+ const frame = e2eeHttpResponseFrame(7, env.s, env);
439
+ assert.equal(frame.bodyBase64, false, "★ 不得再标记 bodyBase64(那一层纯属白花 33% 流量)");
440
+ assert.equal(frame.status, 200);
441
+ assert.match(frame.headers["x-dsh-e2ee"], /k=http-resp$/);
442
+ assert.deepEqual(JSON.parse(frame.body), env, "中继解出来必须与原信封逐字段一致");
443
+ const oldBody = Buffer.from(JSON.stringify(env)).toString("base64");
444
+ assert.ok(oldBody.length > frame.body.length * 1.3,
445
+ `旧写法会把这条帧撑大 33%(实测 ${oldBody.length} vs ${frame.body.length})`);
446
+ });
447
+
378
448
  test("E2EE WS 数据流:&e2ee=&w= 标记 → 桥剥除标记连上游,逐消息加解密 echo 一致", async () => {
379
449
  const u = globalThis.__unlocked;
380
450
  assert.ok(u, "需要先解锁");
@@ -32,7 +32,9 @@ import {
32
32
  normalizeKdf,
33
33
  parseEnvelopeMarker,
34
34
  parseWsE2eeParams,
35
- randomB64
35
+ randomB64,
36
+ stripPlainWantHeader,
37
+ wantsBinaryResponsePlain
36
38
  } from "../e2ee-client.mjs";
37
39
 
38
40
  // 固定口令/盐的 MK 期望值:由 enterprise e2ee.js deriveE2eeMasterKey 计算得到
@@ -213,6 +215,45 @@ function flipB64(b64) {
213
215
  return buf.toString("base64url");
214
216
  }
215
217
 
218
+ test("★ 0.6.15 二进制明文框架:正文不再 base64(省掉三层里的第一层),且旧 JSON 形态仍能解", () => {
219
+ // 业主实测"打开慢几分钟"的直接原因之一:同一条响应被 base64 编了三层(1.333³≈2.37×)。
220
+ // 第一层就是"gzip 正文塞进 JSON 明文"。二进制框架把它去掉,且必须**两种形态都能解**
221
+ // (老客户端只发旧形态,新客户端声明后收新形态)。
222
+ const body = Buffer.from("首屏聚合包".repeat(500), "utf8");
223
+ const headers = { "content-type": "application/javascript", "content-encoding": "gzip" };
224
+
225
+ const bin = encodeHttpResponsePlain({ status: 200, headers, bodyBuffer: body, binary: true });
226
+ const legacy = encodeHttpResponsePlain({ status: 200, headers, bodyBuffer: body });
227
+ assert.equal(bin[0], 0x02, "二进制框架必须以 0x02 开头(JSON 明文以 `{`=0x7B 开头,不可能冲突)");
228
+ // 旧形态:正文 base64 后是 1.333 倍(这就是被省掉的那一层)
229
+ assert.ok(legacy.length > bin.length * 1.3,
230
+ `旧 JSON 形态必须明显更大(实测 ${legacy.length} vs ${bin.length})`);
231
+ assert.ok(bin.length < body.length + 256, `二进制框架只应多出一点头(实测 ${bin.length} vs 正文 ${body.length})`);
232
+
233
+ const back = decodeHttpResponsePlain(bin);
234
+ assert.equal(back.status, 200);
235
+ assert.equal(back.enc, "gzip");
236
+ assert.equal(back.headers["content-type"], "application/javascript");
237
+ assert.deepEqual(Buffer.from(back.bodyBuffer), body, "正文必须逐字节还原");
238
+ const backLegacy = decodeHttpResponsePlain(legacy);
239
+ assert.deepEqual(Buffer.from(backLegacy.bodyBuffer), body, "旧形态必须照样能解(老客户端不受影响)");
240
+
241
+ // 空正文 / 只有头的响应
242
+ const empty = decodeHttpResponsePlain(encodeHttpResponsePlain({ status: 204, headers: {}, bodyBuffer: Buffer.alloc(0), binary: true }));
243
+ assert.equal(empty.status, 204);
244
+ assert.equal(empty.bodyBuffer.length, 0);
245
+ });
246
+
247
+ test("★ 0.6.15 协商头:只有客户端声明才用二进制框架,且声明头绝不转发给上游", () => {
248
+ assert.equal(wantsBinaryResponsePlain({ "x-dsh-e2ee-want": "bin" }), true);
249
+ assert.equal(wantsBinaryResponsePlain({ "X-DSH-E2EE-WANT": "BIN" }), true, "头名/值大小写不敏感");
250
+ assert.equal(wantsBinaryResponsePlain({ "x-dsh-e2ee-want": "json" }), false);
251
+ assert.equal(wantsBinaryResponsePlain({ "content-type": "application/json" }), false, "老客户端不发这个头 → 保持旧形态");
252
+
253
+ const stripped = stripPlainWantHeader({ "Content-Type": "application/json", "X-Dsh-E2ee-Want": "bin", "x-t": "v" });
254
+ assert.deepEqual(stripped, { "Content-Type": "application/json", "x-t": "v" }, "内部协商头绝不能出现在转发给上游的头里");
255
+ });
256
+
216
257
  test("http 响应信封:封包→开包,enc=gzip 被提取、实体头剥离,正文还原", () => {
217
258
  const s = makeSession();
218
259
  const body = Buffer.from(JSON.stringify({ ok: true }), "utf8");
@@ -230,7 +230,16 @@ test("信封逐字段一致:http/http-resp/w(文本·二进制)/ctrl(shim ↔ no
230
230
  test("请求信封明文编解码与信封标记头同构(node 交叉)", () => {
231
231
  const plainShim = shim.seEncodeHttpReqPlain({ method: "POST", path: "/api/echo?b=2", headers: { "content-type": "application/json", "x-t": "v" }, bodyBytes: new Uint8Array(Buffer.from("载荷", "utf8")) });
232
232
  const plainNode = encodeHttpRequestPlain({ method: "POST", path: "/api/echo?b=2", headers: { "content-type": "application/json", "x-t": "v" }, bodyB64: Buffer.from("载荷", "utf8").toString("base64") });
233
- assert.deepEqual(Buffer.from(plainShim), Buffer.from(plainNode));
233
+ const decShim = decodeHttpRequestPlain(plainShim);
234
+ const decNode = decodeHttpRequestPlain(plainNode);
235
+ // ★ 0.6.15:shim **额外**声明「响应明文请用二进制框架」(省掉正文那一层 base64)。
236
+ // 这是本版唯一一处**有意为之**的差异:老客户端(native.html / 企业版内置那份)不发这个头,
237
+ // 桥端也就永远不给他们发二进制明文 —— 兼容性靠"客户端声明"而不是靠版本号猜。
238
+ assert.equal(decShim.headers["x-dsh-e2ee-want"], "bin", "镜像页 shim 必须声明二进制明文框架");
239
+ assert.equal(decNode.headers["x-dsh-e2ee-want"], undefined, "桥端自己的编码器不得替客户端声明(否则老客户端会收到看不懂的字节)");
240
+ delete decShim.headers["x-dsh-e2ee-want"];
241
+ assert.deepEqual(decShim, decNode, "除该声明头外,请求明文逐字段同构");
242
+ assert.deepEqual(Buffer.from(decShim.bodyB64, "base64"), Buffer.from("载荷", "utf8"));
234
243
  const sessId = newSessId();
235
244
  assert.deepEqual(shim.seEnvelopeHeaders(sessId), envelopeRequestHeaders(sessId, "http"));
236
245
  });
@@ -32,6 +32,7 @@
32
32
  * 浏览器里的真实结果另在 headless Chromium + 官方 GUI 上验证过(见报告)。
33
33
  */
34
34
  import assert from "node:assert/strict";
35
+ import { readFileSync } from "node:fs";
35
36
  import test from "node:test";
36
37
  import vm from "node:vm";
37
38
  import { injectMobileAdapter } from "../mobile-adapter.mjs";
@@ -349,7 +350,10 @@ function harness({
349
350
  MutationObserver: MutationObserverStub,
350
351
  ResizeObserver: class { observe() {} disconnect() {} },
351
352
  requestAnimationFrame: (fn) => setImmediate(fn),
352
- setTimeout: (fn, ms) => setTimeout(fn, ms),
353
+ // unref:适配层自带 8s/30s/**4 分钟**的兜底计时器(首屏提示),不 unref 的话
354
+ // 测试进程会一直等到最后一个定时器到期才退出 —— 实测这个文件因此**空等 4 分钟**,
355
+ // 单次全量测试的 94% 时间都花在这里(2026-09-25 实测:240s vs 其余 20 个文件合计 30s)。
356
+ setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); try { id.unref?.(); } catch { /* 忽略 */ } return id; },
353
357
  clearTimeout: (id) => clearTimeout(id),
354
358
  setInterval: (fn) => { intervals.push(fn); return intervals.length; },
355
359
  clearInterval: (id) => { if (id) intervals[id - 1] = null; },
@@ -830,3 +834,16 @@ test("自动收抽屉:桌面宽屏(>820)不收起", async () => {
830
834
  await waitTick();
831
835
  assert.equal(clicks(), 0, "宽屏没有离屏抽屉这回事 —— 窄屏门必须继续有效");
832
836
  });
837
+
838
+ test("★ 迭代速度护栏:适配层测试的假定时器**必须 unref**(否则每个文件空等 4 分钟)", () => {
839
+ // 2026-09-25 实测:适配层会挂 8s / 30s / **4 分钟** 的首屏提示兜底计时器,
840
+ // 而两个适配层测试文件的假 setTimeout 直接转给 Node 的真定时器 → 测试全跑完后
841
+ // 进程还要等最后一个定时器到期才退出:单文件 **240 秒**,而其余 20 个文件合计只有 30 秒。
842
+ // 也就是说"每轮迭代全量测试 8.5 分钟"里 **94% 是纯空等**。
843
+ // 这条护栏把 unref 钉住 —— 它一丢,迭代速度立刻退回 8 分钟级。
844
+ const files = ["mobile-adapter-guards.test.mjs", "mobile-adapter-runtime.test.mjs", "mobile-adapter-image.test.mjs"];
845
+ for (const f of files) {
846
+ const src = readFileSync(new URL(`./${f}`, import.meta.url), "utf8");
847
+ assert.match(src, /id\.unref\?\.\(\)/, `${f} 的假 setTimeout 必须 unref(否则该文件会空等数分钟)`);
848
+ }
849
+ });
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mobile-adapter **图片上传兼容**契约测试(假 DOM + vm 执行真实注入脚本)。
4
+ *
5
+ * 业主反馈(2026-09):「手机端无法识别图片」——选了照片发出去,模型看不到图 / 被当成普通文件。
6
+ *
7
+ * 根因(官方 dsh web 客户端 `dsh-client-ui-conversation` 的 createDrafts):
8
+ * 只有 `file.type ∈ {image/png,image/jpeg,image/webp,image/gif}` 才走**图片**通道
9
+ * (本地编码随 prompt 发出 → 模型真能"看");其余一律当**普通文件**(后台上传 + 一个文件引用)。
10
+ * 手机恰恰最容易给出非白名单类型:iPhone「高效」格式 = `image/heic`;部分 Android = 空 type。
11
+ *
12
+ * 本用例拿 injectMobileAdapter() 注入的**真实脚本**在假 DOM 里跑,断言运行时行为:
13
+ * ① HEIC → 转成 JPEG 后再交给官方(官方收到的是 image/jpeg,而不是 heic);
14
+ * ② 空 type + `.png` 扩展名 → **无损**改 MIME(不重编码);
15
+ * ③ 官方认得的类型 → 一根手指都不动(零行为);
16
+ * ④ 任何失败(解码失败 / DataTransfer 不可用)→ **原样重新派发**,文件绝不丢。
17
+ */
18
+ import assert from "node:assert/strict";
19
+ import test from "node:test";
20
+ import vm from "node:vm";
21
+ import { injectMobileAdapter } from "../mobile-adapter.mjs";
22
+
23
+ function adapterScript() {
24
+ const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
25
+ const m = /<script id="[^"]*-js"[^>]*>([\s\S]*?)<\/script>/.exec(html);
26
+ assert.ok(m, "应能取出注入脚本");
27
+ return m[1];
28
+ }
29
+
30
+ // ── 极简假 DOM:只实现图片兼容这条路径真正用到的东西 ──────────────────────────
31
+ class FakeFile {
32
+ constructor(parts, name, opts = {}) {
33
+ this._blob = { parts: parts.map((p) => String(p)) };
34
+ this.name = name;
35
+ this.type = opts.type || "";
36
+ this.size = this._blob.parts.join("").length;
37
+ this.lastModified = opts.lastModified || 1;
38
+ }
39
+ slice(_a, _b, mime) {
40
+ const f = new FakeBlob(this._blob.parts, mime || this.type);
41
+ f.name = this.name;
42
+ return f;
43
+ }
44
+ }
45
+ class FakeBlob {
46
+ constructor(parts, type) { this._parts = parts; this.type = type || ""; this.size = parts.join("").length; this.name = ""; }
47
+ slice(_a, _b, mime) { const b = new FakeBlob(this._parts, mime || this.type); b.name = this.name; return b; }
48
+ }
49
+ class FakeInput {
50
+ constructor() {
51
+ this.tagName = "INPUT";
52
+ this.type = "file";
53
+ this.files = [];
54
+ this._listeners = new Map();
55
+ this._attrs = {};
56
+ }
57
+ getAttribute(n) { return n in this._attrs ? this._attrs[n] : null; }
58
+ setAttribute(n, v) { this._attrs[n] = String(v); }
59
+ addEventListener(t, fn) { if (!this._listeners.has(t)) this._listeners.set(t, []); this._listeners.get(t).push(fn); }
60
+ removeEventListener() {}
61
+ // 真实 DOM 里 dispatchEvent 会再走一遍 document 捕获监听 —— 那次会被 MA_REENTRY 提前返回,
62
+ // 所以这里只跑本元素上的监听即可(语义等价)。
63
+ dispatchEvent(ev) {
64
+ ev.target = this;
65
+ for (const fn of this._listeners.get(ev.type) || []) fn(ev);
66
+ return true;
67
+ }
68
+ }
69
+ class FakeEvent {
70
+ constructor(type, init = {}) {
71
+ this.type = type;
72
+ this.bubbles = init.bubbles !== false;
73
+ this._stopped = false;
74
+ this._prevented = false;
75
+ this.target = null;
76
+ }
77
+ stopPropagation() { this._stopped = true; }
78
+ preventDefault() { this._prevented = true; }
79
+ }
80
+
81
+ /**
82
+ * 最小事件派发模型:document(捕获)→ target(冒泡)。
83
+ * capture 阶段 stopPropagation 后,**target 上的监听不再收到** —— 这正是 React 根容器监听
84
+ * (挂在 document 之下的容器上)会因此被拦住的机制,也是本方案能生效的前提。
85
+ */
86
+ function fireChange(env, input) {
87
+ const ev = new FakeEvent("change", { bubbles: true });
88
+ ev.target = input;
89
+ for (const fn of env.docListeners.capture) fn(ev);
90
+ if (ev._stopped) return ev;
91
+ for (const fn of input._listeners.get("change") || []) fn(ev);
92
+ return ev;
93
+ }
94
+
95
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
96
+
97
+ function makeEnv({ decodeHeic = true, hasDataTransfer = true, frame = null } = {}) {
98
+ const docListeners = { capture: [], bubble: [] };
99
+ const input = new FakeInput();
100
+ const created = { canvases: 0, blobs: 0, objectUrls: 0, revoked: 0 };
101
+
102
+ const doc = {
103
+ documentElement: { outerHTML: "<html><head><title>DeepSeek Harness</title><script>window.__ModuleLoader__=1</script></head><body></body></html>" },
104
+ body: { appendChild() {}, querySelector: () => null, classList: { add() {}, remove() {} } },
105
+ readyState: "complete",
106
+ addEventListener(type, fn, opts) {
107
+ const capture = typeof opts === "object" ? !!opts.capture : opts === true;
108
+ (capture ? docListeners.capture : docListeners.bubble).push(fn);
109
+ },
110
+ removeEventListener() {},
111
+ querySelector: () => frame,
112
+ querySelectorAll: () => [],
113
+ getElementById: () => null,
114
+ createElement(tag) {
115
+ if (String(tag).toLowerCase() !== "canvas") return { style: {}, setAttribute() {}, appendChild() {}, classList: { add() {}, remove() {} } };
116
+ created.canvases += 1;
117
+ const cv = {
118
+ width: 0,
119
+ height: 0,
120
+ style: {},
121
+ getContext: () => ({ drawImage() {} }),
122
+ toBlob(cb, mime) {
123
+ created.blobs += 1;
124
+ cb(new FakeBlob(["jpeg-bytes"], mime || "image/jpeg"));
125
+ }
126
+ };
127
+ return cv;
128
+ },
129
+ createTextNode: (t) => ({ nodeValue: t })
130
+ };
131
+
132
+ class FakeImage {
133
+ constructor() { this.naturalWidth = 0; this.naturalHeight = 0; this.width = 0; this.height = 0; }
134
+ set src(v) {
135
+ this._src = v;
136
+ // HEIC 在部分浏览器上解不了;这里用开关模拟"解码成功/失败"
137
+ setTimeout(() => {
138
+ if (decodeHeic) { this.naturalWidth = 4032; this.naturalHeight = 3024; this.onload && this.onload(); }
139
+ else { this.onerror && this.onerror(); }
140
+ }, 0);
141
+ }
142
+ get src() { return this._src; }
143
+ }
144
+
145
+ const timers = new Set();
146
+ const win = {
147
+ innerWidth: 390,
148
+ innerHeight: 800,
149
+ devicePixelRatio: 2,
150
+ addEventListener() {},
151
+ removeEventListener() {},
152
+ getComputedStyle: () => ({ getPropertyValue: () => "" }),
153
+ requestAnimationFrame: (fn) => setTimeout(fn, 0),
154
+ // unref:适配层自带 8s/30s/4min 的兜底计时器(首屏提示、看门狗),不 unref 会把测试进程吊住。
155
+ // 用例里的 await wait(...) 用的是宿主定时器(未 unref),足够让这些 vm 内定时器在该跑的时候跑到。
156
+ setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); try { id.unref?.(); } catch (e) { /* 忽略 */ } timers.add(id); return id; },
157
+ clearTimeout: (id) => { timers.delete(id); clearTimeout(id); },
158
+ MutationObserver: class { observe() {} disconnect() {} },
159
+ ResizeObserver: class { observe() {} disconnect() {} },
160
+ location: { href: "https://n.risegao.cn:13443/remote/dev-x/", host: "n.risegao.cn", pathname: "/remote/dev-x/", origin: "https://n.risegao.cn:13443" },
161
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} }
162
+ };
163
+
164
+ const ctx = vm.createContext({
165
+ window: win,
166
+ document: doc,
167
+ console,
168
+ setTimeout: win.setTimeout,
169
+ clearTimeout: win.clearTimeout,
170
+ setInterval: () => 0,
171
+ clearInterval() {},
172
+ MutationObserver: win.MutationObserver,
173
+ ResizeObserver: win.ResizeObserver,
174
+ requestAnimationFrame: win.requestAnimationFrame,
175
+ navigator: { userAgent: "iPhone Safari", maxTouchPoints: 5, platform: "iPhone" },
176
+ location: win.location,
177
+ localStorage: win.localStorage,
178
+ URL: {
179
+ createObjectURL() { created.objectUrls += 1; return "blob:fake/" + created.objectUrls; },
180
+ revokeObjectURL() { created.revoked += 1; }
181
+ },
182
+ Image: FakeImage,
183
+ File: FakeFile,
184
+ Blob: FakeBlob,
185
+ Event: FakeEvent,
186
+ Date, Math, JSON, Object, Array, String, Number, RegExp, Boolean, Promise, Error,
187
+ isNaN, parseInt, parseFloat
188
+ });
189
+ if (hasDataTransfer) {
190
+ ctx.DataTransfer = class {
191
+ constructor() { this.items = { _f: [], add(f) { this._f.push(f); } }; }
192
+ get files() { return this.items._f; }
193
+ };
194
+ }
195
+ ctx.globalThis = ctx;
196
+ return { ctx, input, docListeners, created };
197
+ }
198
+
199
+ function runAdapter(env) {
200
+ vm.runInContext(adapterScript(), env.ctx, { filename: "mobile-adapter.js" });
201
+ }
202
+
203
+ // ─────────────────────────── 用例 ───────────────────────────
204
+
205
+ test("★ iPhone HEIC 照片 → 转成 JPEG 后再交给官方(否则会被当成普通文件,模型看不到)", async () => {
206
+ const env = makeEnv();
207
+ runAdapter(env);
208
+
209
+ // 官方监听(模拟 React 根容器/输入框上的 change 处理)
210
+ const official = [];
211
+ env.input.addEventListener("change", (e) => { official.push(Array.prototype.slice.call(e.target.files)); });
212
+
213
+ const heic = new FakeFile(["heic-bytes"], "IMG_0001.HEIC", { type: "image/heic" });
214
+ env.input.files = [heic];
215
+ fireChange(env, env.input); // 第一次:被适配层拦下(官方收不到)
216
+ assert.equal(official.length, 0, "★ 必须先把这次事件拦下来,不能让官方按普通文件先收走");
217
+
218
+ await wait(30); // 等 canvas 转码完成 + 重新派发
219
+
220
+ assert.equal(official.length, 1, "转码完成后必须重新派发一次 change");
221
+ const got = official[0];
222
+ assert.equal(got.length, 1);
223
+ assert.equal(got[0].type, "image/jpeg", "★ 交给官方的必须是白名单里的 image/jpeg");
224
+ assert.equal(got[0].name, "IMG_0001.jpg", "文件名也要跟着换成 .jpg(面板/模型看到的名字才一致)");
225
+ assert.equal(env.created.canvases, 1, "HEIC 必须走 canvas 转码");
226
+ });
227
+
228
+ test("★ Android 空 type + .png → **无损**改 MIME(不重编码,不丢透明通道)", async () => {
229
+ // Android 图库经文件提供器给出的 file.type 常常是空串;字节本身是标准 PNG。
230
+ // 这种情况走 canvas 重编码纯属浪费(还会把透明通道压成黑底),只改 MIME 就够了。
231
+ const env = makeEnv();
232
+ runAdapter(env);
233
+ const official = [];
234
+ env.input.addEventListener("change", (e) => { official.push(Array.prototype.slice.call(e.target.files)); });
235
+
236
+ const png = new FakeFile(["png-bytes"], "screenshot.png", { type: "" });
237
+ env.input.files = [png];
238
+ fireChange(env, env.input);
239
+ await wait(30);
240
+
241
+ assert.equal(official.length, 1);
242
+ assert.equal(official[0][0].type, "image/png", "★ 空 type 的 PNG 必须被改写成 image/png");
243
+ assert.equal(env.created.canvases, 0, "★ 不得重编码(无损路径不该碰 canvas)");
244
+ assert.equal(env.created.blobs, 0);
245
+ });
246
+
247
+ test("★ 官方认得的类型(image/jpeg)→ 零行为:不拦、不转、不重派发", async () => {
248
+ const env = makeEnv();
249
+ runAdapter(env);
250
+ const official = [];
251
+ env.input.addEventListener("change", (e) => { official.push(Array.prototype.slice.call(e.target.files)); });
252
+
253
+ const jpg = new FakeFile(["jpg-bytes"], "photo.jpg", { type: "image/jpeg" });
254
+ env.input.files = [jpg];
255
+ fireChange(env, env.input);
256
+ await wait(20);
257
+
258
+ assert.equal(official.length, 1, "官方应当**当场**收到事件(我们没有拦)");
259
+ assert.equal(official[0][0], jpg, "原文件对象必须是同一个(一个字节都没动)");
260
+ assert.equal(env.created.canvases, 0);
261
+ assert.equal(env.created.objectUrls, 0);
262
+ });
263
+
264
+ test("★ 解码失败(浏览器读不了 HEIC)→ 原样重新派发,文件绝不丢", async () => {
265
+ const env = makeEnv({ decodeHeic: false });
266
+ runAdapter(env);
267
+ const official = [];
268
+ env.input.addEventListener("change", (e) => { official.push(Array.prototype.slice.call(e.target.files)); });
269
+
270
+ const heic = new FakeFile(["heic-bytes"], "IMG_2.HEIC", { type: "image/heic" });
271
+ env.input.files = [heic];
272
+ fireChange(env, env.input);
273
+ await wait(40);
274
+
275
+ assert.equal(official.length, 1, "转不了也必须把这次选择交回官方(否则用户会觉得「选了没反应」)");
276
+ assert.equal(official[0][0].name, "IMG_2.HEIC", "交回去的是**原文件**(宁可当普通文件,也不能丢)");
277
+ });
278
+
279
+ test("★ DataTransfer 不可用(老浏览器)→ 原样重新派发,不吞事件", async () => {
280
+ const env = makeEnv({ hasDataTransfer: false });
281
+ runAdapter(env);
282
+ const official = [];
283
+ env.input.addEventListener("change", (e) => { official.push(Array.prototype.slice.call(e.target.files)); });
284
+
285
+ const heic = new FakeFile(["heic-bytes"], "IMG_3.HEIC", { type: "image/heic" });
286
+ env.input.files = [heic];
287
+ fireChange(env, env.input);
288
+ await wait(40);
289
+
290
+ assert.equal(official.length, 1, "塞不回 input.files 时必须原样重发");
291
+ assert.equal(official[0][0].name, "IMG_3.HEIC");
292
+ });
293
+
294
+ test("★ 混合选择:只动需要动的那个,其余原样(顺序也不许乱)", async () => {
295
+ const env = makeEnv();
296
+ runAdapter(env);
297
+ const official = [];
298
+ env.input.addEventListener("change", (e) => { official.push(Array.prototype.slice.call(e.target.files)); });
299
+
300
+ const jpg = new FakeFile(["a"], "a.jpg", { type: "image/jpeg" });
301
+ const heic = new FakeFile(["b"], "b.heic", { type: "image/heic" });
302
+ const pdf = new FakeFile(["c"], "c.pdf", { type: "application/pdf" });
303
+ env.input.files = [jpg, heic, pdf];
304
+ fireChange(env, env.input);
305
+ await wait(40);
306
+
307
+ assert.equal(official.length, 1);
308
+ const got = official[0];
309
+ assert.equal(got.length, 3, "文件数量必须保持 3");
310
+ assert.equal(got[0], jpg, "官方认得的原样保留(同一对象)");
311
+ assert.equal(got[1].type, "image/jpeg", "HEIC 被转成 JPEG");
312
+ assert.equal(got[1].name, "b.jpg");
313
+ assert.equal(got[2], pdf, "非图片原样保留");
314
+ });
315
+
316
+ test("接线守卫:图片兼容必须真的被装上,且走**捕获阶段**(否则拦不住 React 根容器)", () => {
317
+ const src = adapterScript();
318
+ assert.match(src, /maInstallImageCompat\(\);/, "必须在主流程里调用(定义了不调用等于没做)");
319
+ const i = src.indexOf('document.addEventListener("change"');
320
+ assert.ok(i > 0, "找不到 change 监听");
321
+ const tail = src.slice(i, i + 2000);
322
+ assert.ok(/\},\s*true\s*\)\s*;/.test(tail),
323
+ "★ 必须注册在捕获阶段(第三个参数 true):React 根容器的 change 监听在 document 之下,冒泡阶段拦不住它");
324
+ assert.match(src, /MA_REENTRY/, "必须有重入标记(否则自己派发的 change 会再被自己拦一次,死循环)");
325
+ });
@@ -168,7 +168,9 @@ function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRe
168
168
  removeEventListener() {},
169
169
  getComputedStyle: () => ({ getPropertyValue: () => "" }),
170
170
  requestAnimationFrame: (fn) => { const id = setImmediate(fn); return id; },
171
- setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); timers.set(id, fn); return id; },
171
+ // unref:同上 —— 适配层的 4 分钟兜底计时器会让测试进程空等到期(本来 240s,只剩 0.2s)。
172
+ // 用例里的 `_pendingTimers` 仍会被 runAdapter() 主动跑一轮,行为不变。
173
+ setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); try { id.unref?.(); } catch { /* 忽略 */ } timers.set(id, fn); return id; },
172
174
  clearTimeout: (id) => clearTimeout(id),
173
175
  MutationObserver: class { observe() {} disconnect() {} },
174
176
  ResizeObserver: class { observe() {} disconnect() {} },