@mrrisega/dsh-remote 0.7.9 → 0.7.10-beta.10
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/README.md +9 -2
- package/clients/dsh-remote/agent/README.md +412 -0
- package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
- package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
- package/clients/dsh-remote/agent/codex.mjs +736 -0
- package/clients/dsh-remote/agent/detect.mjs +399 -0
- package/clients/dsh-remote/agent/events.mjs +511 -0
- package/clients/dsh-remote/agent/gemini.mjs +503 -0
- package/clients/dsh-remote/agent/index.mjs +339 -0
- package/clients/dsh-remote/agent/opencode.mjs +628 -0
- package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
- package/clients/dsh-remote/agent/proc.mjs +179 -0
- package/clients/dsh-remote/agent/version.mjs +148 -0
- package/clients/dsh-remote/approval-coordinator.mjs +150 -0
- package/clients/dsh-remote/decision-poller.mjs +235 -0
- package/clients/dsh-remote/dsh-bridge.mjs +208 -1
- package/clients/dsh-remote/mobile-adapter.mjs +775 -11
- package/clients/dsh-remote/push-reporter.mjs +533 -0
- package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
- package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
- package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
- package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
- package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
- package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
- package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
- package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
- package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
- package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
- package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
- package/clients/dsh-remote/test/bridge-e2ee-reject-observable.test.mjs +90 -0
- package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +172 -0
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +188 -4
- package/clients/dsh-remote/test/push-reporter.test.mjs +380 -0
- package/dsh-setup.mjs +5 -0
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +350 -8
- package/packages/dsh-remote-web/lib/index.js +26 -0
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +208 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +775 -11
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +5 -0
- package/packages/dsh-remote-web/test/activation-entry.test.mjs +407 -0
- package/packages/dsh-remote-web/test/e2ee-reject-diag.test.mjs +70 -0
- package/packages/dsh-remote-web/test/setup-output.test.mjs +16 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { createDecisionPoller, toDshOutcome, MAX_ITEMS_PER_TICK } from "../decision-poller.mjs";
|
|
5
|
+
|
|
6
|
+
/** 按脚本返回的假 fetch;记录每次调用。 */
|
|
7
|
+
function makeFetch(script) {
|
|
8
|
+
const calls = [];
|
|
9
|
+
const fn = async (url, init) => {
|
|
10
|
+
calls.push({ url, init });
|
|
11
|
+
const step = script.shift();
|
|
12
|
+
if (!step) throw new Error("fetch script exhausted: " + url);
|
|
13
|
+
if (step instanceof Error) throw step;
|
|
14
|
+
return {
|
|
15
|
+
status: step.status ?? 200,
|
|
16
|
+
async json() { return step.body ?? { ok: true }; }
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
fn.calls = calls;
|
|
20
|
+
return fn;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function mk(opts) {
|
|
24
|
+
return createDecisionPoller({
|
|
25
|
+
apiBase: "https://api.example.com",
|
|
26
|
+
deviceId: "dev-x",
|
|
27
|
+
tokenProvider: () => "tok",
|
|
28
|
+
apply: async () => ({ ok: true }),
|
|
29
|
+
fetchImpl: makeFetch([]),
|
|
30
|
+
...opts
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("① 手机 decision → DSH outcome:allow 只能是 allowed-once,未知一律拒绝", () => {
|
|
35
|
+
assert.equal(toDshOutcome("allow"), "allowed-once");
|
|
36
|
+
assert.equal(toDshOutcome("deny"), "rejected");
|
|
37
|
+
assert.equal(toDshOutcome("weird"), "rejected");
|
|
38
|
+
assert.equal(toDshOutcome(""), "rejected");
|
|
39
|
+
assert.equal(toDshOutcome(undefined), "rejected");
|
|
40
|
+
// 绝不把 allow 映射成永久放行
|
|
41
|
+
assert.notEqual(toDshOutcome("allow"), "allowed-always");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("② start 门禁:缺 apiBase / deviceId / apply 都不启动", () => {
|
|
45
|
+
assert.equal(mk({ apiBase: "" }).start().lastError, "no_api_base");
|
|
46
|
+
assert.equal(mk({ deviceId: "" }).start().lastError, "no_device");
|
|
47
|
+
assert.equal(mk({ apply: null }).start().lastError, "no_apply");
|
|
48
|
+
assert.equal(mk().start().started, true);
|
|
49
|
+
mk().stop();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("③ happy path:拉取 → 应用 → ack,参数正确", async () => {
|
|
53
|
+
const applied = [];
|
|
54
|
+
const fetchImpl = makeFetch([
|
|
55
|
+
{ status: 200, body: { ok: true, items: [
|
|
56
|
+
{ id: "d1", event_id: "dev-x:approval:evt_1", request_id: "evt_1", decision: "allow", decided_at: 1, expires_at: Date.now() + 60000 }
|
|
57
|
+
] } },
|
|
58
|
+
{ status: 200, body: { ok: true, acked: 1 } }
|
|
59
|
+
]);
|
|
60
|
+
const p = createDecisionPoller({
|
|
61
|
+
apiBase: "https://api.example.com",
|
|
62
|
+
deviceId: "dev-x",
|
|
63
|
+
tokenProvider: () => "tok",
|
|
64
|
+
apply: async (item) => { applied.push(item); },
|
|
65
|
+
fetchImpl,
|
|
66
|
+
intervalMs: 60_000
|
|
67
|
+
});
|
|
68
|
+
await p._tick();
|
|
69
|
+
assert.equal(applied.length, 1);
|
|
70
|
+
assert.equal(applied[0].request_id, "evt_1");
|
|
71
|
+
assert.equal(applied[0].decision, "allow");
|
|
72
|
+
assert.equal(applied[0].outcome, "allowed-once");
|
|
73
|
+
// 第一个请求是 pending(带 device_id),第二个是 ack
|
|
74
|
+
assert.match(fetchImpl.calls[0].url, /\/api\/notify\/pending\?device_id=dev-x/);
|
|
75
|
+
assert.equal(fetchImpl.calls[0].init.headers.authorization, "Bearer tok");
|
|
76
|
+
assert.match(fetchImpl.calls[1].url, /\/api\/notify\/ack$/);
|
|
77
|
+
assert.deepEqual(JSON.parse(fetchImpl.calls[1].init.body).ids, ["d1"]);
|
|
78
|
+
assert.equal(p.status().applied, 1);
|
|
79
|
+
assert.equal(p.status().acked, 1);
|
|
80
|
+
p.stop();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("④ apply 抛错也必须 ack(否则每 3s 重放同一条)", async () => {
|
|
84
|
+
const fetchImpl = makeFetch([
|
|
85
|
+
{ status: 200, body: { ok: true, items: [
|
|
86
|
+
{ id: "d1", event_id: "e1", request_id: "evt_1", decision: "deny", expires_at: Date.now() + 60000 }
|
|
87
|
+
] } },
|
|
88
|
+
{ status: 200, body: { ok: true } }
|
|
89
|
+
]);
|
|
90
|
+
const p = createDecisionPoller({
|
|
91
|
+
apiBase: "https://api.example.com",
|
|
92
|
+
deviceId: "dev-x",
|
|
93
|
+
tokenProvider: () => "tok",
|
|
94
|
+
apply: async () => { throw new Error("session gone"); },
|
|
95
|
+
fetchImpl
|
|
96
|
+
});
|
|
97
|
+
await p._tick();
|
|
98
|
+
assert.equal(p.status().applied, 0);
|
|
99
|
+
assert.equal(p.status().failed, 1);
|
|
100
|
+
assert.match(fetchImpl.calls[1].url, /\/api\/notify\/ack$/);
|
|
101
|
+
assert.deepEqual(JSON.parse(fetchImpl.calls[1].init.body).ids, ["d1"]);
|
|
102
|
+
p.stop();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("⑤ 已过期条目:不应用,但要 ack 掉避免反复拉", async () => {
|
|
106
|
+
const applied = [];
|
|
107
|
+
const fetchImpl = makeFetch([
|
|
108
|
+
{ status: 200, body: { ok: true, items: [
|
|
109
|
+
{ id: "old", event_id: "e", request_id: "evt_old", decision: "allow", expires_at: Date.now() - 1000 }
|
|
110
|
+
] } },
|
|
111
|
+
{ status: 200, body: { ok: true } }
|
|
112
|
+
]);
|
|
113
|
+
const p = createDecisionPoller({
|
|
114
|
+
apiBase: "https://api.example.com",
|
|
115
|
+
deviceId: "dev-x",
|
|
116
|
+
tokenProvider: () => "tok",
|
|
117
|
+
apply: async (i) => { applied.push(i); },
|
|
118
|
+
fetchImpl
|
|
119
|
+
});
|
|
120
|
+
await p._tick();
|
|
121
|
+
assert.equal(applied.length, 0, "过期决策绝不能应用");
|
|
122
|
+
assert.deepEqual(JSON.parse(fetchImpl.calls[1].init.body).ids, ["old"]);
|
|
123
|
+
p.stop();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("⑥ 401:不应用、不 ack、不抛", async () => {
|
|
127
|
+
const applied = [];
|
|
128
|
+
const fetchImpl = makeFetch([{ status: 401 }]);
|
|
129
|
+
const p = createDecisionPoller({
|
|
130
|
+
apiBase: "https://api.example.com",
|
|
131
|
+
deviceId: "dev-x",
|
|
132
|
+
tokenProvider: () => "expired",
|
|
133
|
+
apply: async (i) => { applied.push(i); },
|
|
134
|
+
fetchImpl
|
|
135
|
+
});
|
|
136
|
+
await p._tick();
|
|
137
|
+
assert.equal(applied.length, 0);
|
|
138
|
+
assert.equal(p.status().lastError, "auth_failed");
|
|
139
|
+
assert.equal(fetchImpl.calls.length, 1, "401 后不应再发 ack");
|
|
140
|
+
p.stop();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("⑦ 没有 token:一个请求都不发", async () => {
|
|
144
|
+
const fetchImpl = makeFetch([]);
|
|
145
|
+
const p = createDecisionPoller({
|
|
146
|
+
apiBase: "https://api.example.com",
|
|
147
|
+
deviceId: "dev-x",
|
|
148
|
+
tokenProvider: () => "",
|
|
149
|
+
apply: async () => {},
|
|
150
|
+
fetchImpl
|
|
151
|
+
});
|
|
152
|
+
await p._tick();
|
|
153
|
+
assert.equal(fetchImpl.calls.length, 0);
|
|
154
|
+
assert.equal(p.status().lastError, "no_token");
|
|
155
|
+
p.stop();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("⑧ ack 失败不影响主流程(幂等,下轮重复拉也没副作用)", async () => {
|
|
159
|
+
const applied = [];
|
|
160
|
+
const fetchImpl = makeFetch([
|
|
161
|
+
{ status: 200, body: { ok: true, items: [
|
|
162
|
+
{ id: "d1", event_id: "e", request_id: "evt_1", decision: "allow", expires_at: Date.now() + 60000 }
|
|
163
|
+
] } },
|
|
164
|
+
{ status: 500, body: { ok: false } }
|
|
165
|
+
]);
|
|
166
|
+
const p = createDecisionPoller({
|
|
167
|
+
apiBase: "https://api.example.com",
|
|
168
|
+
deviceId: "dev-x",
|
|
169
|
+
tokenProvider: () => "tok",
|
|
170
|
+
apply: async (i) => { applied.push(i); },
|
|
171
|
+
fetchImpl
|
|
172
|
+
});
|
|
173
|
+
await p._tick();
|
|
174
|
+
assert.equal(applied.length, 1);
|
|
175
|
+
assert.equal(p.status().acked, 0);
|
|
176
|
+
p.stop();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("⑨ 单次最多处理 20 条(有界,避免并发打爆 dsh)", async () => {
|
|
180
|
+
const items = Array.from({ length: 35 }, (_, i) => ({
|
|
181
|
+
id: "d" + i, event_id: "e" + i, request_id: "evt_" + i, decision: "deny", expires_at: Date.now() + 60000
|
|
182
|
+
}));
|
|
183
|
+
const applied = [];
|
|
184
|
+
const fetchImpl = makeFetch([
|
|
185
|
+
{ status: 200, body: { ok: true, items } },
|
|
186
|
+
{ status: 200, body: { ok: true } }
|
|
187
|
+
]);
|
|
188
|
+
const p = createDecisionPoller({
|
|
189
|
+
apiBase: "https://api.example.com",
|
|
190
|
+
deviceId: "dev-x",
|
|
191
|
+
tokenProvider: () => "tok",
|
|
192
|
+
apply: async (i) => { applied.push(i); },
|
|
193
|
+
fetchImpl
|
|
194
|
+
});
|
|
195
|
+
await p._tick();
|
|
196
|
+
assert.equal(applied.length, MAX_ITEMS_PER_TICK);
|
|
197
|
+
assert.equal(JSON.parse(fetchImpl.calls[1].init.body).ids.length, MAX_ITEMS_PER_TICK);
|
|
198
|
+
p.stop();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("⑩ 未知 decision 值按 deny 处理(fail-closed)", async () => {
|
|
202
|
+
const applied = [];
|
|
203
|
+
const fetchImpl = makeFetch([
|
|
204
|
+
{ status: 200, body: { ok: true, items: [
|
|
205
|
+
{ id: "d1", event_id: "e", request_id: "evt_1", decision: "maybe", expires_at: Date.now() + 60000 }
|
|
206
|
+
] } },
|
|
207
|
+
{ status: 200, body: { ok: true } }
|
|
208
|
+
]);
|
|
209
|
+
const p = createDecisionPoller({
|
|
210
|
+
apiBase: "https://api.example.com",
|
|
211
|
+
deviceId: "dev-x",
|
|
212
|
+
tokenProvider: () => "tok",
|
|
213
|
+
apply: async (i) => { applied.push(i); },
|
|
214
|
+
fetchImpl
|
|
215
|
+
});
|
|
216
|
+
await p._tick();
|
|
217
|
+
assert.equal(applied[0].decision, "deny");
|
|
218
|
+
assert.equal(applied[0].outcome, "rejected");
|
|
219
|
+
p.stop();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("⑪ 网络异常吞掉,不抛、不崩", async () => {
|
|
223
|
+
const fetchImpl = makeFetch([new Error("ECONNRESET")]);
|
|
224
|
+
const p = createDecisionPoller({
|
|
225
|
+
apiBase: "https://api.example.com",
|
|
226
|
+
deviceId: "dev-x",
|
|
227
|
+
tokenProvider: () => "tok",
|
|
228
|
+
apply: async () => {},
|
|
229
|
+
fetchImpl
|
|
230
|
+
});
|
|
231
|
+
await p._tick();
|
|
232
|
+
assert.match(p.status().lastError, /ECONNRESET/);
|
|
233
|
+
p.stop();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("⑫ stop() 后不再轮询", async () => {
|
|
237
|
+
const fetchImpl = makeFetch([]);
|
|
238
|
+
const p = createDecisionPoller({
|
|
239
|
+
apiBase: "https://api.example.com",
|
|
240
|
+
deviceId: "dev-x",
|
|
241
|
+
tokenProvider: () => "tok",
|
|
242
|
+
apply: async () => {},
|
|
243
|
+
fetchImpl,
|
|
244
|
+
intervalMs: 10
|
|
245
|
+
});
|
|
246
|
+
p.start();
|
|
247
|
+
p.stop();
|
|
248
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
249
|
+
assert.equal(fetchImpl.calls.length, 0, "stop 后不应再发任何请求");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("⑬ 403 device_mismatch / 400 invalid_device_id:停止轮询(重试永远不会成功)", async () => {
|
|
253
|
+
const f1 = makeFetch([{ status: 403 }]);
|
|
254
|
+
const p1 = createDecisionPoller({
|
|
255
|
+
apiBase: "https://api.example.com", deviceId: "dev-x", tokenProvider: () => "tok",
|
|
256
|
+
apply: async () => {}, fetchImpl: f1, intervalMs: 10
|
|
257
|
+
});
|
|
258
|
+
p1.start();
|
|
259
|
+
await p1._tick();
|
|
260
|
+
assert.equal(p1.status().lastError, "device_mismatch");
|
|
261
|
+
const before = f1.calls.length;
|
|
262
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
263
|
+
assert.equal(f1.calls.length, before, "403 之后不应再轮询");
|
|
264
|
+
p1.stop();
|
|
265
|
+
|
|
266
|
+
const f2 = makeFetch([{ status: 400 }]);
|
|
267
|
+
const p2 = createDecisionPoller({
|
|
268
|
+
apiBase: "https://api.example.com", deviceId: "dev-x", tokenProvider: () => "tok",
|
|
269
|
+
apply: async () => {}, fetchImpl: f2, intervalMs: 10
|
|
270
|
+
});
|
|
271
|
+
await p2._tick();
|
|
272
|
+
assert.equal(p2.status().lastError, "invalid_device_id");
|
|
273
|
+
p2.stop();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("⑭ 401 之后仍会继续轮询(token 刷新后要能恢复)", async () => {
|
|
277
|
+
const f = makeFetch([{ status: 401 }]);
|
|
278
|
+
const p = createDecisionPoller({
|
|
279
|
+
apiBase: "https://api.example.com", deviceId: "dev-x", tokenProvider: () => "expired",
|
|
280
|
+
apply: async () => {}, fetchImpl: f, intervalMs: 10
|
|
281
|
+
});
|
|
282
|
+
p.start();
|
|
283
|
+
await p._tick();
|
|
284
|
+
assert.equal(p.status().lastError, "auth_failed");
|
|
285
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
286
|
+
assert.ok(f.calls.length >= 2, "401 不应停止轮询(等 token 刷新)");
|
|
287
|
+
p.stop();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("⑮ 至少一次语义:同一条 id 被重投时只 apply 一次、但仍 ack(幂等去重)", async () => {
|
|
291
|
+
const applied = [];
|
|
292
|
+
const item = { id: "dec_same", event_id: "e", request_id: "evt_same", decision: "allow", expires_at: Date.now() + 60000, redeliver_count: 0 };
|
|
293
|
+
const fetchImpl = makeFetch([
|
|
294
|
+
{ status: 200, body: { ok: true, items: [item] } },
|
|
295
|
+
{ status: 200, body: { ok: true } },
|
|
296
|
+
// 服务端重投同一条(redeliver_count=1)
|
|
297
|
+
{ status: 200, body: { ok: true, items: [{ ...item, redeliver_count: 1 }] } },
|
|
298
|
+
{ status: 200, body: { ok: true } }
|
|
299
|
+
]);
|
|
300
|
+
const p = createDecisionPoller({
|
|
301
|
+
apiBase: "https://api.example.com", deviceId: "dev-x", tokenProvider: () => "tok",
|
|
302
|
+
apply: async (i) => { applied.push(i); }, fetchImpl
|
|
303
|
+
});
|
|
304
|
+
await p._tick();
|
|
305
|
+
await p._tick();
|
|
306
|
+
assert.equal(applied.length, 1, "重投不应重复 apply");
|
|
307
|
+
assert.equal(p.status().applied, 1);
|
|
308
|
+
assert.equal(p.status().skipped, 1);
|
|
309
|
+
assert.equal(p.status().acked, 2, "两次都应 ack(否则服务端会一直重投)");
|
|
310
|
+
p.stop();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("⑯ 重投回执会写 warn 日志留痕(便于排查重复审批)", async () => {
|
|
314
|
+
const logs = [];
|
|
315
|
+
const item = { id: "dec_r", event_id: "e", request_id: "evt_r", decision: "deny", expires_at: Date.now() + 60000, redeliver_count: 2 };
|
|
316
|
+
const fetchImpl = makeFetch([
|
|
317
|
+
{ status: 200, body: { ok: true, items: [item] } },
|
|
318
|
+
{ status: 200, body: { ok: true } }
|
|
319
|
+
]);
|
|
320
|
+
const p = createDecisionPoller({
|
|
321
|
+
apiBase: "https://api.example.com", deviceId: "dev-x", tokenProvider: () => "tok",
|
|
322
|
+
apply: async () => {}, fetchImpl,
|
|
323
|
+
log: (lvl, msg) => logs.push(lvl + ":" + msg)
|
|
324
|
+
});
|
|
325
|
+
await p._tick();
|
|
326
|
+
assert.ok(logs.some((l) => l.startsWith("warn:") && /重投/.test(l)));
|
|
327
|
+
p.stop();
|
|
328
|
+
});
|
|
@@ -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
|
+
});
|