@mrrisega/dsh-remote 0.6.14 → 0.6.16
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 +64 -13
- package/clients/dsh-remote/dsh-events.mjs +22 -2
- package/clients/dsh-remote/e2ee-client.mjs +97 -13
- package/clients/dsh-remote/e2ee-shim-script.js +31 -3
- package/clients/dsh-remote/mobile-adapter.mjs +190 -108
- package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
- package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
- package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +45 -1
- package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
- package/clients/dsh-remote/upstream-discovery.mjs +186 -30
- package/clients/dsh-remote/wechat-channel.mjs +86 -3
- package/clients/dsh-remote/wechat-runtime.mjs +253 -10
- package/dsh-setup.mjs +161 -7
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +31 -1
- package/packages/dsh-remote-web/lib/index.js +392 -49
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +64 -13
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +190 -108
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +186 -30
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +161 -7
- package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
- package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +80 -27
- package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
- package/clients/dsh-remote/test/boot-tip.test.mjs +0 -158
|
@@ -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
|
-
|
|
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,43 @@ 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
|
+
});
|
|
850
|
+
|
|
851
|
+
test("★ 护栏:解析期绝不许往页面塞东西(0.6.14 首屏提示层就是这么做、然后永久盖住 UI 的)", () => {
|
|
852
|
+
// 事故 3(2026-09-25,业主实测):「界面都已经加载完了…但这个遮罩一直不消失」,
|
|
853
|
+
// 截图里官方 UI 就在 88% 不透明的全屏层下面透出来,秒表走到 187 秒 —— 刷新也一样。
|
|
854
|
+
//
|
|
855
|
+
// 根因是**设计错误**:那个提示层是 `position:fixed; inset:0; z-index:2147483000` 的全屏层,
|
|
856
|
+
// 退出条件却是 DOM 启发式(官方 data-* 属性 / 类名前缀 / textarea…)。官方一改版,
|
|
857
|
+
// 两组信号全不命中 → 层永远留着。当时我写的"pointer-events:none 所以不会挡点击"是自我安慰:
|
|
858
|
+
// **盖住视线和进不去对用户没区别**。
|
|
859
|
+
//
|
|
860
|
+
// 业主决定:直接删掉。这条护栏守两件事:
|
|
861
|
+
// ① `dsh-ma-boot` 不许回来;
|
|
862
|
+
// ② **解析期**(主机门之前)不许有任何 `appendChild` —— 页面还没确认是官方 dsh web,
|
|
863
|
+
// 更不该有任何覆盖层;所有注入节点都必须在 boot() 里、过了主机门之后才做。
|
|
864
|
+
const { js: script, css } = injected();
|
|
865
|
+
const gate = script.indexOf("/* —— 主机门解除");
|
|
866
|
+
assert.ok(gate > 0, "找不到主机门锚点(脚本结构变了,请同步更新本用例)");
|
|
867
|
+
const preGate = script.slice(script.indexOf('"use strict";'), gate);
|
|
868
|
+
assert.ok(!/appendChild/.test(preGate),
|
|
869
|
+
"★ 解析期不得往页面 appendChild 任何节点(首屏提示层就是在这里挂上去、然后永远不消失的)");
|
|
870
|
+
assert.ok(!/dsh-ma-boot/.test(script), "首屏提示层必须已彻底删除");
|
|
871
|
+
|
|
872
|
+
// 全屏 + 超高 z-index 的组合本身就是"一旦判断失误就永远进不去",不许出现
|
|
873
|
+
assert.ok(!/z-index:\s*[0-9]{7,}/.test(css),
|
|
874
|
+
"不得出现 1000000 以上的 z-index(压过一切的全屏层 = 判断错一次就锁死用户)");
|
|
875
|
+
assert.ok(!/dsh-ma-boot/.test(css), "首屏提示层的样式必须已彻底删除");
|
|
876
|
+
});
|
|
@@ -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
|
-
|
|
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() {} },
|