@mrrisega/dsh-remote 0.6.17 → 0.6.18-beta.2
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/e2ee-shim-script.js +203 -11
- package/clients/dsh-remote/mobile-adapter.mjs +404 -29
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +43 -1
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +145 -4
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +95 -6
- package/clients/dsh-remote/wechat-runtime.mjs +25 -2
- package/docs/telemetry.md +7 -1
- package/dsh-setup.mjs +152 -3
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +754 -39
- package/packages/dsh-remote-web/lib/index.js +279 -6
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +203 -11
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +404 -29
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +25 -2
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +152 -3
- package/packages/dsh-remote-web/test/account-switch-device.test.mjs +20 -2
- package/packages/dsh-remote-web/test/announcements-proxy.test.mjs +310 -0
- package/packages/dsh-remote-web/test/announcements-ui.test.mjs +524 -0
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +4 -1
- package/packages/dsh-remote-web/test/diag-report.test.mjs +4 -1
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +5 -1
- package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +26 -0
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +32 -2
- package/packages/dsh-remote-web/test/plugin-link.test.mjs +3 -1
- package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
- package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +4 -1
- package/packages/dsh-remote-web/test/setup-output.test.mjs +22 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +29 -10
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +3 -3
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +8 -3
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +7 -4
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
// 📣 公告(插件端)回归:徽标 / 面板入口 / 电脑端弹窗 / 已读上报 / 一条铁律(**不轮询**)。
|
|
2
|
+
//
|
|
3
|
+
// 契约:dsh-relay-enterprise/docs/announcements-contract.md(冻结 v1)+ 926 迭代 §1(client=desktop)。
|
|
4
|
+
// GET /dsh-remote/announcements?client=desktop → { bubble_enabled, items[], unread_count, popup|null }
|
|
5
|
+
// POST /dsh-remote/announcements/click { id } → 记 clicked_at(**现契约只有 /click,没有 /read**)
|
|
6
|
+
//
|
|
7
|
+
// 这个文件锁的是**客户端纪律**(服务端行为另有 enterprise 侧的用例):
|
|
8
|
+
// ① 有未读 → 侧栏入口出现数字徽标 + 面板出现「📣 公告」卡;
|
|
9
|
+
// ② bubble_enabled:false / 字段缺失 → 一个入口都不出现(fail closed);items 缺失当空数组;
|
|
10
|
+
// ③ 接口 404 或不可达 → 不显示任何入口、**且不抛**(客户端插件抛异常 = 整个 web app 起不来);
|
|
11
|
+
// ④ 点一条公告 → 发 POST /dsh-remote/announcements/click;
|
|
12
|
+
// ⑤ 弹窗 close/✕/「已读」任意入口 = 关闭 + 上报(同一个收尾函数);
|
|
13
|
+
// ⑥ popup 的本地防抖 = 「同 id + 60s」:60s 内不再弹、61s 后可以再弹;
|
|
14
|
+
// ⑦ **绝不轮询**:`GET /api/announcements` 是"判定即记账"(命中弹窗候选就写 shown_at),
|
|
15
|
+
// 多拉一次就可能把该弹的公告静默消耗掉 —— 每次页面加载只能自动拉一次。
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
import vm from "node:vm";
|
|
20
|
+
|
|
21
|
+
const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
22
|
+
|
|
23
|
+
function walk(node, visit) {
|
|
24
|
+
if (node == null) return;
|
|
25
|
+
if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
|
|
26
|
+
if (typeof node !== "object") return;
|
|
27
|
+
visit(node);
|
|
28
|
+
for (const child of node.children || []) walk(child, visit);
|
|
29
|
+
}
|
|
30
|
+
function find(tree, predicate) {
|
|
31
|
+
let match;
|
|
32
|
+
walk(tree, (node) => { if (!match && predicate(node)) match = node; });
|
|
33
|
+
return match;
|
|
34
|
+
}
|
|
35
|
+
function textHas(tree, substr) {
|
|
36
|
+
return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
37
|
+
}
|
|
38
|
+
/** 找到"文字是 substr 且可点"的那颗按钮(按钮文字可能被包在 span 里,所以两处都看)。 */
|
|
39
|
+
function nodeWithText(tree, substr) {
|
|
40
|
+
return find(tree, (node) => typeof node.props?.onClick === "function" &&
|
|
41
|
+
((node.children || []).some((c) => typeof c === "string" && c.includes(substr)) ||
|
|
42
|
+
(node.children || []).some((c) => c && typeof c === "object" && (c.children || []).some((s) => typeof s === "string" && s.includes(substr)))));
|
|
43
|
+
}
|
|
44
|
+
/** 同上,但**深度**遍历子树里的所有文本(公告条目把标题放在 span > span 里)。 */
|
|
45
|
+
function clickableWithDeepText(tree, substr) {
|
|
46
|
+
return find(tree, (node) => {
|
|
47
|
+
if (typeof node.props?.onClick !== "function") return false;
|
|
48
|
+
let hit = false;
|
|
49
|
+
walk(node, (n) => {
|
|
50
|
+
if (!hit && (n.children || []).some((c) => typeof c === "string" && c.includes(substr))) hit = true;
|
|
51
|
+
});
|
|
52
|
+
return hit;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 迷你 React(真实 useEffect + 假定时器 + 可控时钟)+ 假 fetch。
|
|
59
|
+
* @param {object} opts
|
|
60
|
+
* - announcements:GET /dsh-remote/announcements 的响应(可以是函数,按第 N 次调用返回不同结果)
|
|
61
|
+
* - storage:跨"页面加载"共享的 localStorage(模拟同一个 origin 反复开页面)
|
|
62
|
+
* - clock:可推进的时钟对象(测试里用来验证 60s 防抖窗口)
|
|
63
|
+
*/
|
|
64
|
+
function loadPlugin(opts = {}) {
|
|
65
|
+
let moduleFactory;
|
|
66
|
+
const registered = new Map();
|
|
67
|
+
const requests = [];
|
|
68
|
+
|
|
69
|
+
let clock = 0;
|
|
70
|
+
let timerSeq = 1;
|
|
71
|
+
const timers = new Map();
|
|
72
|
+
const job = (fn) => { try { fn(); } catch (e) { /* 断言会暴露问题 */ } };
|
|
73
|
+
const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
|
|
74
|
+
const fakeSetInterval = (fn, ms) => { const id = timerSeq++; const every = Math.max(1, Number(ms) || 1); timers.set(id, { at: clock + every, fn, every }); return id; };
|
|
75
|
+
const fakeClear = (id) => { timers.delete(id); };
|
|
76
|
+
|
|
77
|
+
const scopes = new Map();
|
|
78
|
+
const scopeStack = [];
|
|
79
|
+
const currentScope = () => scopeStack[scopeStack.length - 1];
|
|
80
|
+
|
|
81
|
+
const react = {
|
|
82
|
+
createElement(type, props, ...children) { return { type, props: props || {}, children }; },
|
|
83
|
+
useState(initial) {
|
|
84
|
+
const sc = currentScope();
|
|
85
|
+
const index = sc.hook++;
|
|
86
|
+
if (!(index in sc.state)) sc.state[index] = typeof initial === "function" ? initial() : initial;
|
|
87
|
+
return [sc.state[index], (value) => { sc.state[index] = typeof value === "function" ? value(sc.state[index]) : value; }];
|
|
88
|
+
},
|
|
89
|
+
useEffect(fn, deps) {
|
|
90
|
+
const sc = currentScope();
|
|
91
|
+
const index = sc.effectCursor++;
|
|
92
|
+
const prev = sc.effects[index];
|
|
93
|
+
const changed = !prev || !deps || deps.length !== prev.deps.length || deps.some((d, i) => !Object.is(d, prev.deps[i]));
|
|
94
|
+
if (changed) sc.pending.push({ index, fn, deps: deps ? [...deps] : deps });
|
|
95
|
+
},
|
|
96
|
+
useCallback(fn) { return fn; },
|
|
97
|
+
useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function renderNode(node, path) {
|
|
101
|
+
if (node == null) return node;
|
|
102
|
+
if (Array.isArray(node)) return node.map((n, i) => renderNode(n, path + "/" + i));
|
|
103
|
+
if (typeof node !== "object") return node;
|
|
104
|
+
if (typeof node.type === "function") {
|
|
105
|
+
const name = node.type.name || "anon";
|
|
106
|
+
const key = path + ":" + name;
|
|
107
|
+
const sc = scopes.get(key) || { hook: 0, effectCursor: 0, state: [], effects: [], pending: [] };
|
|
108
|
+
sc.hook = 0; sc.effectCursor = 0; sc.pending = [];
|
|
109
|
+
scopes.set(key, sc);
|
|
110
|
+
scopeStack.push(sc);
|
|
111
|
+
let out;
|
|
112
|
+
try {
|
|
113
|
+
out = node.type(node.props || {});
|
|
114
|
+
} finally {
|
|
115
|
+
scopeStack.pop();
|
|
116
|
+
}
|
|
117
|
+
const expanded = renderNode(out, key);
|
|
118
|
+
const todos = sc.pending;
|
|
119
|
+
sc.pending = [];
|
|
120
|
+
for (const t of todos) {
|
|
121
|
+
const prev = sc.effects[t.index];
|
|
122
|
+
if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
|
|
123
|
+
const cleanup = t.fn();
|
|
124
|
+
sc.effects[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
|
|
125
|
+
}
|
|
126
|
+
return expanded;
|
|
127
|
+
}
|
|
128
|
+
return { ...node, children: (node.children || []).map((c, i) => renderNode(c, path + "/" + i)) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const status = opts.status || {
|
|
132
|
+
ok: true,
|
|
133
|
+
config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" },
|
|
134
|
+
service: { running: true },
|
|
135
|
+
};
|
|
136
|
+
const response = (s, body) => Promise.resolve({ ok: s >= 200 && s < 300, status: s, text: async () => JSON.stringify(body) });
|
|
137
|
+
// announcements 可以是数组(按第 N 次 GET 依次取,越界用最后一项)或固定对象
|
|
138
|
+
const annSeq = Array.isArray(opts.announcements) ? opts.announcements.slice() : null;
|
|
139
|
+
const annFixed = annSeq ? null : (opts.announcements !== undefined
|
|
140
|
+
? opts.announcements
|
|
141
|
+
: { ok: true, bubble_enabled: true, unread_count: 2, items: [
|
|
142
|
+
{ id: 3, title: "新功能上线", body: "扫码登录更快了", image: "", pinned: 1, created_at: 1758500000000, unread: true },
|
|
143
|
+
{ id: 4, title: "维护通知", body: "周日凌晨维护", image: "", pinned: 0, created_at: 1758400000000, unread: true },
|
|
144
|
+
], popup: null });
|
|
145
|
+
let annCalls = 0;
|
|
146
|
+
|
|
147
|
+
const makeEl = () => ({
|
|
148
|
+
tag: "div", children: [], style: {}, className: "", attributes: {}, textContent: "",
|
|
149
|
+
setAttribute(k, v) { this.attributes[k] = v; },
|
|
150
|
+
getAttribute(k) { return this.attributes[k] ?? null; },
|
|
151
|
+
appendChild(c) { this.children.push(c); return c; },
|
|
152
|
+
removeChild(c) { this.children = this.children.filter((x) => x !== c); return c; },
|
|
153
|
+
querySelector(sel) {
|
|
154
|
+
const cls = String(sel || "").replace(/^\./, "");
|
|
155
|
+
return this.children.find((c) => String(c.className || "").split(/\s+/).includes(cls)) || null;
|
|
156
|
+
},
|
|
157
|
+
querySelectorAll() { return []; },
|
|
158
|
+
addEventListener() {}, removeEventListener() {},
|
|
159
|
+
});
|
|
160
|
+
// 侧栏「📱 远程访问」入口:本插件的徽标逻辑要求它**存在**(否则 annRenderNavBadge 会静默跳过)。
|
|
161
|
+
// 这里先备好这个节点,document.getElementById 对它的 id 返回它 —— 模拟"官方设置按钮已渲染、入口已注入"。
|
|
162
|
+
const navBtn = makeEl();
|
|
163
|
+
navBtn.id = "dru-nav-remote";
|
|
164
|
+
const doc = {
|
|
165
|
+
hidden: false,
|
|
166
|
+
createElement: makeEl,
|
|
167
|
+
head: makeEl(),
|
|
168
|
+
body: makeEl(),
|
|
169
|
+
querySelector() { return null; },
|
|
170
|
+
querySelectorAll() { return []; },
|
|
171
|
+
getElementById(id) { return id === "dru-nav-remote" ? navBtn : null; },
|
|
172
|
+
addEventListener() {}, removeEventListener() {},
|
|
173
|
+
};
|
|
174
|
+
class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
|
|
175
|
+
|
|
176
|
+
const store = opts.storage || new Map();
|
|
177
|
+
const localStorage = {
|
|
178
|
+
get length() { return store.size; },
|
|
179
|
+
key(i) { return Array.from(store.keys())[i] ?? null; },
|
|
180
|
+
getItem(k) { return store.has(k) ? store.get(k) : null; },
|
|
181
|
+
setItem(k, v) { store.set(k, String(v)); },
|
|
182
|
+
removeItem(k) { store.delete(k); },
|
|
183
|
+
};
|
|
184
|
+
const sessionStore = new Map();
|
|
185
|
+
const sessionStorage = {
|
|
186
|
+
getItem(k) { return sessionStore.has(k) ? sessionStore.get(k) : null; },
|
|
187
|
+
setItem(k, v) { sessionStore.set(k, String(v)); },
|
|
188
|
+
removeItem(k) { sessionStore.delete(k); },
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// 可推进时钟:Date.now() 与 new Date() 都跟着 opts.clock.now 走(fmtDate 需要它)
|
|
192
|
+
const clockRef = opts.clock || { now: 1758500000000 };
|
|
193
|
+
class FakeDate extends Date {
|
|
194
|
+
constructor(...args) { if (args.length === 0) super(clockRef.now); else super(...args); }
|
|
195
|
+
static now() { return clockRef.now; }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const sandbox = {
|
|
199
|
+
window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
|
|
200
|
+
document: doc,
|
|
201
|
+
localStorage,
|
|
202
|
+
sessionStorage,
|
|
203
|
+
MutationObserver: MutationObserverMock,
|
|
204
|
+
navigator: { clipboard: { writeText: async () => {} } },
|
|
205
|
+
setTimeout: fakeSetTimeout,
|
|
206
|
+
clearTimeout: fakeClear,
|
|
207
|
+
setInterval: fakeSetInterval,
|
|
208
|
+
clearInterval: fakeClear,
|
|
209
|
+
fetch(path, options = {}) {
|
|
210
|
+
requests.push({ path, method: options.method || "GET", body: options.body || "" });
|
|
211
|
+
if (path === "/dsh-remote/status") return response(200, status);
|
|
212
|
+
if (path === "/dsh-remote/community") return response(200, { ok: true, qrcode: "", wechat: "" });
|
|
213
|
+
if (path.startsWith("/dsh-remote/announcements/click")) return response(200, { ok: true });
|
|
214
|
+
if (path.startsWith("/dsh-remote/announcements")) {
|
|
215
|
+
annCalls += 1;
|
|
216
|
+
const entry = annSeq ? (annSeq[Math.min(annCalls - 1, annSeq.length - 1)]) : annFixed;
|
|
217
|
+
if (entry && entry.__status) return response(entry.__status, entry.body || { ok: false });
|
|
218
|
+
return response(200, entry);
|
|
219
|
+
}
|
|
220
|
+
return response(200, { ok: true });
|
|
221
|
+
},
|
|
222
|
+
Set, Symbol, Date: FakeDate, JSON, Math, Number, String, Object, Array, console,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
vm.runInNewContext(SOURCE, sandbox);
|
|
226
|
+
const plugin = moduleFactory((name) => {
|
|
227
|
+
assert.equal(name, "react");
|
|
228
|
+
return react;
|
|
229
|
+
});
|
|
230
|
+
const applied = { threw: null };
|
|
231
|
+
try {
|
|
232
|
+
plugin.apply({
|
|
233
|
+
slots: {
|
|
234
|
+
inject(name, cb) { cb(); },
|
|
235
|
+
register(meta, component) { registered.set(meta.id, component); return () => {}; },
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
} catch (e) {
|
|
239
|
+
applied.threw = e;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
requests,
|
|
244
|
+
registered,
|
|
245
|
+
navBtn,
|
|
246
|
+
applied,
|
|
247
|
+
annCalls: () => annCalls,
|
|
248
|
+
setClock(ms) { clockRef.now = ms; },
|
|
249
|
+
/** 渲染 → 让在途请求落地 → 再渲染(返回最新树)。 */
|
|
250
|
+
async settle(times = 2) {
|
|
251
|
+
let tree = null;
|
|
252
|
+
for (let i = 0; i < times; i++) { tree = this.render(); await flush(); await flush(); }
|
|
253
|
+
return this.render();
|
|
254
|
+
},
|
|
255
|
+
render() {
|
|
256
|
+
const root = { type: registered.get("dsh-remote"), props: { close() {} }, children: [] };
|
|
257
|
+
return renderNode(root, "root");
|
|
258
|
+
},
|
|
259
|
+
/** 单独渲染 shell.overlay 上的公告弹窗(它在真实环境里与面板是两棵树)。 */
|
|
260
|
+
renderPopup() {
|
|
261
|
+
const comp = registered.get("dsh-announcement-popup");
|
|
262
|
+
if (!comp) return null;
|
|
263
|
+
return renderNode({ type: comp, props: {}, children: [] }, "popup");
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ───────────────────────────── ① 有未读 → 徽标 + 面板入口 ─────────────────────────────
|
|
269
|
+
|
|
270
|
+
test("未读公告 → 侧栏入口出现数字徽标;设置面板出现「📣 公告」卡(含条目与未读标签)", async () => {
|
|
271
|
+
const plugin = loadPlugin();
|
|
272
|
+
const tree = await plugin.settle();
|
|
273
|
+
|
|
274
|
+
// 侧栏徽标(DOM,不走 React):annEmit 之后挂到 #dru-nav-remote 上
|
|
275
|
+
const badge = plugin.navBtn.children.find((c) => c.className === "dru-nav-remote-badge");
|
|
276
|
+
assert.ok(badge, "侧栏「📱 远程访问」入口上应出现未读徽标");
|
|
277
|
+
assert.equal(badge.textContent, "2", "徽标显示未读条数");
|
|
278
|
+
|
|
279
|
+
// 面板卡片 + 两个条目 + 标题
|
|
280
|
+
assert.ok(textHas(tree, "📣 公告"), "面板应出现公告卡");
|
|
281
|
+
assert.ok(textHas(tree, "新功能上线"), "应列出第一条公告标题");
|
|
282
|
+
assert.ok(textHas(tree, "维护通知"), "应列出第二条公告标题");
|
|
283
|
+
assert.ok(textHas(tree, "未读"), "未读条目应有「未读」文字标签(不靠颜色单独表意)");
|
|
284
|
+
assert.ok(textHas(tree, "置顶"), "pinned 条目应有「置顶」标签");
|
|
285
|
+
assert.ok(textHas(tree, "2 条未读"), "卡片标题应带未读数");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("未读数 > 9 → 徽标显示 9+(不能把 12 挤成一个宽数字)", async () => {
|
|
289
|
+
const plugin = loadPlugin({
|
|
290
|
+
announcements: {
|
|
291
|
+
ok: true, bubble_enabled: true, unread_count: 12,
|
|
292
|
+
items: [{ id: 1, title: "A", body: "", image: "", pinned: 0, created_at: 0, unread: true }],
|
|
293
|
+
popup: null,
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
await plugin.settle();
|
|
297
|
+
const badge = plugin.navBtn.children.find((c) => c.className === "dru-nav-remote-badge");
|
|
298
|
+
assert.ok(badge, "应有徽标");
|
|
299
|
+
assert.equal(badge.textContent, "9+");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ───────────────────────── ② bubble_enabled:false / 字段缺失 → 无入口 ─────────────────────────
|
|
303
|
+
|
|
304
|
+
test("bubble_enabled:false → 面板无入口、侧栏无徽标(后台把入口关掉就是不显示)", async () => {
|
|
305
|
+
const plugin = loadPlugin({
|
|
306
|
+
announcements: {
|
|
307
|
+
ok: true, bubble_enabled: false, unread_count: 3,
|
|
308
|
+
items: [{ id: 1, title: "不该出现", body: "", image: "", pinned: 0, created_at: 0, unread: true }],
|
|
309
|
+
popup: null,
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
const tree = await plugin.settle();
|
|
313
|
+
assert.ok(!textHas(tree, "不该出现"), "入口关闭时不得渲染任何公告条目");
|
|
314
|
+
assert.ok(!textHas(tree, "📣 公告"), "入口关闭时不得渲染公告卡");
|
|
315
|
+
assert.equal(plugin.navBtn.children.filter((c) => c.className === "dru-nav-remote-badge").length, 0, "入口关闭时不得有徽标");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("字段缺失(无 bubble_enabled / 无 items / 无 unread_count)→ 一律 fail closed,且不抛", async () => {
|
|
319
|
+
const plugin = loadPlugin({ announcements: { ok: true } }); // 极端的老/坏响应
|
|
320
|
+
const tree = await plugin.settle();
|
|
321
|
+
assert.equal(plugin.applied.threw, null, "客户端插件绝不能抛(会拖垮整个 web app)");
|
|
322
|
+
assert.ok(!textHas(tree, "📣 公告"), "bubble_enabled 缺失 = false → 不显示入口");
|
|
323
|
+
assert.ok(textHas(tree, "远程访问"), "面板自身照常渲染");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ───────────────────────────── ③ 404 / 不可达 → 无入口且不抛 ─────────────────────────────
|
|
327
|
+
|
|
328
|
+
test("接口 404(老服务端)→ 无入口、无弹窗、不抛", async () => {
|
|
329
|
+
const plugin = loadPlugin({ announcements: { __status: 404, body: { error: { code: "not_found" } } } });
|
|
330
|
+
const tree = await plugin.settle();
|
|
331
|
+
assert.equal(plugin.applied.threw, null, "404 时插件装载不得抛异常");
|
|
332
|
+
assert.ok(!textHas(tree, "📣 公告"), "拿不到公告时不得显示空入口");
|
|
333
|
+
assert.equal(plugin.renderPopup(), null, "拿不到公告时不得弹窗");
|
|
334
|
+
assert.ok(textHas(tree, "远程访问"), "面板其它部分照常可用");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("接口不可达(fetch reject)→ 无入口且不抛", async () => {
|
|
338
|
+
// 用 __status: 0 + 让 text() 抛异常,模拟"网络直接断了"
|
|
339
|
+
const plugin = loadPlugin({ announcements: { __status: 500, body: { ok: false } } });
|
|
340
|
+
const tree = await plugin.settle();
|
|
341
|
+
assert.equal(plugin.applied.threw, null, "上游 5xx 时插件装载不得抛异常");
|
|
342
|
+
assert.ok(!textHas(tree, "📣 公告"), "拉取失败不得显示入口");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ───────────────────────────── ④ 点公告 → 上报 click ─────────────────────────────
|
|
346
|
+
|
|
347
|
+
test("点一条未读公告 → 立刻 POST /dsh-remote/announcements/click(带 id),并展开详情", async () => {
|
|
348
|
+
const plugin = loadPlugin();
|
|
349
|
+
let tree = await plugin.settle();
|
|
350
|
+
const item = clickableWithDeepText(tree, "新功能上线");
|
|
351
|
+
assert.ok(item, "应能点到公告条目");
|
|
352
|
+
|
|
353
|
+
item.props.onClick();
|
|
354
|
+
tree = await plugin.settle();
|
|
355
|
+
|
|
356
|
+
const click = plugin.requests.find((r) => r.path === "/dsh-remote/announcements/click");
|
|
357
|
+
assert.ok(click, "点公告必须上报点击(现契约只有 /click,没有 /read)");
|
|
358
|
+
assert.equal(click.method, "POST", "点击上报必须是 POST");
|
|
359
|
+
assert.equal(JSON.parse(click.body).id, 3, "请求体必须带该条公告的 id");
|
|
360
|
+
assert.ok(textHas(tree, "已读状态已同步到后台"), "点击后应展开详情弹窗");
|
|
361
|
+
assert.ok(textHas(tree, "扫码登录更快了"), "详情应展示公告正文");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ───────────────────── ⑤ / ⑦ 弹窗:任意入口都关闭 + 上报 ─────────────────────
|
|
365
|
+
|
|
366
|
+
test("电脑端弹窗:点「✕」即关闭并上报(任意按钮/遮罩都走同一个已读收尾)", async () => {
|
|
367
|
+
const plugin = loadPlugin({
|
|
368
|
+
announcements: {
|
|
369
|
+
ok: true, bubble_enabled: true, unread_count: 1,
|
|
370
|
+
items: [{ id: 7, title: "弹窗公告", body: "正文", image: "", pinned: 0, created_at: 0, unread: true }],
|
|
371
|
+
popup: { id: 7, title: "弹窗公告", body: "正文", image: "" },
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
await plugin.settle();
|
|
375
|
+
let popup = plugin.renderPopup();
|
|
376
|
+
assert.ok(popup, "有 popup 时应出现电脑端弹窗");
|
|
377
|
+
assert.ok(textHas(popup, "弹窗公告"), "弹窗应展示公告标题");
|
|
378
|
+
|
|
379
|
+
const x = find(popup, (n) => n.props?.className === "dru-popup-x");
|
|
380
|
+
assert.ok(x, "弹窗应有 ✕");
|
|
381
|
+
x.props.onClick();
|
|
382
|
+
await flush();
|
|
383
|
+
await flush();
|
|
384
|
+
|
|
385
|
+
const click = plugin.requests.find((r) => r.path === "/dsh-remote/announcements/click");
|
|
386
|
+
assert.ok(click, "关闭弹窗必须上报已读(POST /click)");
|
|
387
|
+
assert.equal(JSON.parse(click.body).id, 7);
|
|
388
|
+
assert.equal(plugin.renderPopup(), null, "关闭后弹窗必须消失");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("电脑端弹窗:点底部「已读」按钮同样关闭并上报(两个按钮同一个收尾)", async () => {
|
|
392
|
+
const plugin = loadPlugin({
|
|
393
|
+
announcements: {
|
|
394
|
+
ok: true, bubble_enabled: true, unread_count: 1,
|
|
395
|
+
items: [{ id: 8, title: "T", body: "", image: "", pinned: 0, created_at: 0, unread: true }],
|
|
396
|
+
popup: { id: 8, title: "T", body: "", image: "" },
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
await plugin.settle();
|
|
400
|
+
const popup = plugin.renderPopup();
|
|
401
|
+
const btn = nodeWithText(popup, "已读");
|
|
402
|
+
assert.ok(btn, "弹窗应有「已读」按钮");
|
|
403
|
+
btn.props.onClick();
|
|
404
|
+
await flush();
|
|
405
|
+
await flush();
|
|
406
|
+
assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/announcements/click"));
|
|
407
|
+
assert.equal(plugin.renderPopup(), null);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ───────────────────── ⑥ popup 本地防抖:同 id + 60s 时间窗 ─────────────────────
|
|
411
|
+
|
|
412
|
+
test("弹窗防抖:同一条公告 60s 内不再弹、61s 后可以再弹(不把服务端的 always 压成 once)", async () => {
|
|
413
|
+
const clock = { now: 1758500000000 };
|
|
414
|
+
const storage = new Map(); // 跨"页面加载"共享(模拟同一个 origin 反复打开)
|
|
415
|
+
const popupResp = {
|
|
416
|
+
ok: true, bubble_enabled: true, unread_count: 0, items: [],
|
|
417
|
+
popup: { id: 42, title: "重复弹", body: "", image: "" },
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// 第 1 次页面加载:弹出来 → 关掉(记为已读,落本地防抖记忆)
|
|
421
|
+
const p1 = loadPlugin({ announcements: popupResp, storage, clock });
|
|
422
|
+
await p1.settle();
|
|
423
|
+
const popup1 = p1.renderPopup();
|
|
424
|
+
assert.ok(popup1, "第一次应弹出");
|
|
425
|
+
nodeWithText(popup1, "已读").props.onClick();
|
|
426
|
+
await flush(); await flush();
|
|
427
|
+
|
|
428
|
+
// 第 2 次页面加载(+30s):服务端**仍然**返回同一条(= always 模式的真实形态)→ 本地必须挡住
|
|
429
|
+
clock.now += 30 * 1000;
|
|
430
|
+
const p2 = loadPlugin({ announcements: popupResp, storage, clock });
|
|
431
|
+
await p2.settle();
|
|
432
|
+
assert.equal(p2.renderPopup(), null, "60s 内同一条不得重复弹(防打扰)");
|
|
433
|
+
|
|
434
|
+
// 第 3 次页面加载(+61s):窗口过了 → 允许再弹(否则等于把服务端的 always 静默压成 once)
|
|
435
|
+
clock.now += 31 * 1000;
|
|
436
|
+
const p3 = loadPlugin({ announcements: popupResp, storage, clock });
|
|
437
|
+
await p3.settle();
|
|
438
|
+
assert.ok(p3.renderPopup(), "超过 60s 后同一条应可再次弹出");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("once 模式:服务端不再返回该条 → 后续页面加载都不弹(本地无需永久记忆也等价)", async () => {
|
|
442
|
+
const clock = { now: 1758500000000 };
|
|
443
|
+
const storage = new Map();
|
|
444
|
+
const first = {
|
|
445
|
+
ok: true, bubble_enabled: true, unread_count: 0, items: [],
|
|
446
|
+
popup: { id: 5, title: "只此一次", body: "", image: "" },
|
|
447
|
+
};
|
|
448
|
+
const later = { ok: true, bubble_enabled: true, unread_count: 0, items: [], popup: null };
|
|
449
|
+
|
|
450
|
+
const p1 = loadPlugin({ announcements: first, storage, clock });
|
|
451
|
+
await p1.settle();
|
|
452
|
+
assert.ok(p1.renderPopup(), "第一次应弹出");
|
|
453
|
+
nodeWithText(p1.renderPopup(), "关闭").props.onClick();
|
|
454
|
+
await flush(); await flush();
|
|
455
|
+
|
|
456
|
+
clock.now += 10 * 60 * 1000; // 十分钟后重开页面
|
|
457
|
+
const p2 = loadPlugin({ announcements: later, storage, clock });
|
|
458
|
+
await p2.settle();
|
|
459
|
+
assert.equal(p2.renderPopup(), null, "服务端不再给 → 永远不再弹");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// ───────────────────────────── ⑧ 绝不轮询 ─────────────────────────────
|
|
463
|
+
|
|
464
|
+
test("绝不轮询:每次页面加载只自动拉一次公告(重复拉会吃掉该弹的公告)", async () => {
|
|
465
|
+
const plugin = loadPlugin();
|
|
466
|
+
await plugin.settle(4); // 多渲染几轮,任何"render 里顺手拉一次"的实现都会在这里暴露
|
|
467
|
+
const gets = plugin.requests.filter((r) => r.path === "/dsh-remote/announcements?client=desktop");
|
|
468
|
+
assert.equal(gets.length, 1, "自动拉取必须只有一次(服务端 GET 是判定即记账)");
|
|
469
|
+
assert.equal(plugin.annCalls(), 1);
|
|
470
|
+
// 而且必须带 client=desktop(926 迭代 §1:桌面端只拿 target=desktop/both 的公告)
|
|
471
|
+
assert.ok(gets[0].path.includes("client=desktop"), "必须显式声明 client=desktop");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("源码级:面板卡片里的「刷新公告」是唯一的手动重拉入口,且**不**用于弹窗", () => {
|
|
475
|
+
assert.match(SOURCE, /annPullOnce/, "必须有 sessionStorage 一次性拉取标记");
|
|
476
|
+
assert.match(SOURCE, /openPopup:\s*false/, "手动刷新必须显式关闭弹窗(openPopup:false)");
|
|
477
|
+
// 一次性标记必须写在 sessionStorage(localStorage 的 origin 会随端口变,挡不住同页重复拉)
|
|
478
|
+
const fn = SOURCE.slice(SOURCE.indexOf("function annPullOnce("), SOURCE.indexOf("function annReportClick("));
|
|
479
|
+
assert.match(fn, /sessionStorage/, "一次性标记必须在 sessionStorage 上");
|
|
480
|
+
assert.match(fn, /ANN_PULLED_KEY/);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// ───────────────────────── 契约细节:客户端身份维度与上报路径 ─────────────────────────
|
|
484
|
+
|
|
485
|
+
test("源码级:已读上报走 /dsh-remote/announcements/click(不是 /read)", () => {
|
|
486
|
+
assert.match(SOURCE, /\/dsh-remote\/announcements\/click/);
|
|
487
|
+
assert.doesNotMatch(SOURCE, /announcements\/read/, "现契约没有 /read —— 不许自造接口");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("微信机器人引导:两步走引导块在能力清单之上(DOM 顺序)+ 命令帮助与 HELP_TEXT 对账", async () => {
|
|
491
|
+
const plugin = loadPlugin();
|
|
492
|
+
let tree = await plugin.settle();
|
|
493
|
+
// 进入微信机器人 tab(未登录时不渲染这个 tab,所以先确认它在)
|
|
494
|
+
const tab = nodeWithText(tree, "微信机器人通道");
|
|
495
|
+
assert.ok(tab, "应有「微信机器人通道」tab");
|
|
496
|
+
tab.props.onClick();
|
|
497
|
+
tree = await plugin.settle();
|
|
498
|
+
|
|
499
|
+
const embed = find(tree, (n) => n.props?.className === "dru-wx-embed");
|
|
500
|
+
assert.ok(embed, "前提:已进入微信 tab");
|
|
501
|
+
// ① DOM 顺序:引导块(干什么用 + 两步怎么做)必须排在能力清单(.dru-wx-intro)**之上** ——
|
|
502
|
+
// 先讲"这是什么、我要做哪两步",再讲"我能用哪些能力"。CSS 里的先后不算数,看渲染树。
|
|
503
|
+
assert.equal(embed.children[0]?.props?.className, "dru-wx-guide",
|
|
504
|
+
"「两步走」引导必须是内容体第一个孩子(在能力清单之前)");
|
|
505
|
+
assert.equal(embed.children[1]?.props?.className, "dru-wx-intro",
|
|
506
|
+
"能力清单必须紧随引导块之后");
|
|
507
|
+
const guide = embed.children[0];
|
|
508
|
+
for (const s of ["微信机器人是做什么的", "第一步", "第二步", "ClawBot", "24 小时", "连接新的 OpenClaw"]) {
|
|
509
|
+
assert.ok(textHas(guide, s), "引导块必须包含:" + s);
|
|
510
|
+
}
|
|
511
|
+
// ② 命令帮助**复用** wechat-channel.mjs 的 HELP_TEXT(跨 bundle 没有 require 通道 → 镜像 + 逐条对账)
|
|
512
|
+
const helpSrc = readFileSync(new URL("../../../clients/dsh-remote/wechat-channel.mjs", import.meta.url), "utf8");
|
|
513
|
+
const m = helpSrc.match(/export const HELP_TEXT = \[([\s\S]*?)\]\.join\("\\n"\)/);
|
|
514
|
+
assert.ok(m, "必须能从 wechat-channel.mjs 抠出 HELP_TEXT");
|
|
515
|
+
const lines = [...m[1].matchAll(/"([^"]*)"/g)].map((x) => x[1]);
|
|
516
|
+
const cmds = lines.filter((line) => line.trim().startsWith("/"));
|
|
517
|
+
assert.ok(cmds.length >= 8, "HELP_TEXT 里应当有一批 /指令 行,实际 " + cmds.length);
|
|
518
|
+
for (const line of cmds) {
|
|
519
|
+
const cmd = line.split(/\s+/)[0];
|
|
520
|
+
assert.ok(SOURCE.includes(cmd), "面板引导必须覆盖 HELP_TEXT 里的指令:" + cmd);
|
|
521
|
+
}
|
|
522
|
+
// ③ 引导块在**未绑定**态就可见(它是绑定的理由),且不依赖任何图片资源(可缺省)
|
|
523
|
+
assert.ok(textHas(guide, "/help"), "命令清单应直接列在引导块里");
|
|
524
|
+
});
|
|
@@ -29,10 +29,13 @@ import { chmodSync, existsSync, readFileSync, statSync, utimesSync, writeFileSyn
|
|
|
29
29
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
30
30
|
import os from "node:os";
|
|
31
31
|
import path from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
32
33
|
import test from "node:test";
|
|
33
34
|
import { apply } from "../lib/index.js";
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
// ⚠️ 必须用 fileURLToPath:Windows 上 `new URL(...).pathname` 得到的是 "/D:/a/..."(带前导斜杠),
|
|
37
|
+
// path.join 之后会拼成 "D:\\D:\\a\\..." → ENOENT。CI 的 Windows 专项就死在这条上。
|
|
38
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
36
39
|
const SETUP_SRC = readFileSync(path.join(HERE, "..", "..", "..", "dsh-setup.mjs"), "utf8");
|
|
37
40
|
|
|
38
41
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -13,10 +13,13 @@ import { readFileSync, rmSync } from "node:fs";
|
|
|
13
13
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
14
14
|
import os from "node:os";
|
|
15
15
|
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
16
17
|
import test from "node:test";
|
|
17
18
|
import { apply } from "../lib/index.js";
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
// ⚠️ 必须用 fileURLToPath:Windows 上 `new URL(...).pathname` 得到的是 "/D:/a/..."(带前导斜杠),
|
|
21
|
+
// path.join 之后会拼成 "D:\\D:\\a\\..." → ENOENT。CI 的 Windows 专项就死在这条上。
|
|
22
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
20
23
|
const readOrEmpty = async (p) => { try { return await readFile(p, "utf8"); } catch { return ""; } };
|
|
21
24
|
|
|
22
25
|
/** 假反馈服务:只收 /api/feedback 与 /api/feedback/:id/replies(与企业端契约同构)。 */
|
|
@@ -247,7 +247,11 @@ test("重启实现:被 launchd 托管 → 交回监管者(kickstart -k,pid
|
|
|
247
247
|
const { host, base } = await serve(routes);
|
|
248
248
|
try {
|
|
249
249
|
const body = await (await fetch(`${base}/dsh-remote/harness/restart`, { method: "POST" })).json();
|
|
250
|
-
|
|
250
|
+
// ⚠️ 判据必须与**被测代码**同源:插件用 osPlatform() = `DSH_RELAY_PLATFORM || process.platform`
|
|
251
|
+
// (CI 里显式声明了 darwin,好让结果不随 runner 变),而本用例以前只看 process.platform
|
|
252
|
+
// → Linux runner 上「代码走 launchd、用例却断言 relaunch」→ 红。两处判据一致才不会打架。
|
|
253
|
+
const effectivePlatform = process.env.DSH_RELAY_PLATFORM || process.platform;
|
|
254
|
+
if (effectivePlatform !== "darwin") {
|
|
251
255
|
assert.equal(body.mode, "relaunch", "非 macOS 不检测 launchd");
|
|
252
256
|
} else {
|
|
253
257
|
assert.equal(body.mode, "launchd");
|
|
@@ -71,7 +71,7 @@ test("参数拼错:明确报错、退出码 1、也不落盘", async () => {
|
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
test("源码级:用法文本只有一处实现(避免提前退出与末尾分派两处漂移)", () => {
|
|
74
|
-
const src = fs.readFileSync(SETUP, "utf8");
|
|
74
|
+
const src = fs.readFileSync(SETUP, "utf8").replace(/\r\n/g, "\n"); // CRLF 检出时源码锚点失配
|
|
75
75
|
const hits = src.match(/dsh-remote — 手机远程控制 dsh web/g) || [];
|
|
76
76
|
assert.equal(hits.length, 1, "用法文案应只出现一次(共用 printHelp())");
|
|
77
77
|
assert.match(src, /function printHelp\(\)/, "应有 printHelp()");
|
|
@@ -178,3 +178,29 @@ test("★ watcher 退出前必须确保子进程真的死了(只『请』不
|
|
|
178
178
|
assert.match(tail, /bridgeProc\.exitCode === null/, "判活要用 exitCode(已退出就别再杀)");
|
|
179
179
|
assert.match(src, /function looksLikeBridge\(pid\)/, "应有 looksLikeBridge 供回收前校验命令行");
|
|
180
180
|
});
|
|
181
|
+
|
|
182
|
+
test("★ 守护惊群:启动锁必须排在去重读取**之前**,且先写 pid 再释放锁", () => {
|
|
183
|
+
// 2026-09-26 真机事故:一次重启窗口里同时活下来 6 个 `dsh-setup.mjs run` + 6 个 bridge,
|
|
184
|
+
// 全部用同一 deviceId 抢 router 注册(日志刷满 `replaced by new connection`),隧道无限抖动。
|
|
185
|
+
// 成因:去重是"先读 pid 文件、后写 pid 文件",N 个实例同时启动时每个都读到"没有别人"。
|
|
186
|
+
const rb = src.slice(src.indexOf("async function runBridge()"), src.indexOf("// ---------- setup"));
|
|
187
|
+
const iLock = rb.indexOf("tryAcquireSpawnLock()");
|
|
188
|
+
const iRead = rb.indexOf("const other = readPidFile(WATCHER_PID_FILE)");
|
|
189
|
+
assert.ok(iLock > 0, "runBridge 必须有启动锁");
|
|
190
|
+
assert.ok(iRead > 0, "找不到去重读取点");
|
|
191
|
+
assert.ok(iLock < iRead, "🔴 抢锁必须在读 pid 文件之前,否则惊群照旧");
|
|
192
|
+
assert.match(rb, /writePidFile\(WATCHER_PID_FILE, process\.pid\);\s*\n\s*releaseSpawnLock\(\);/,
|
|
193
|
+
"🔴 必须先把自己的 pid 落盘再释放锁 —— 否则'释放 → 1373 行才写'之间又是一个空窗");
|
|
194
|
+
assert.match(rb, /另一个守护正在启动(启动锁被占用)/, "抢不到锁的实例必须让位并留下可诊断的日志");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("★ 启动锁:必须是排他创建 + 带陈旧可抢占(绝不能让一把残留锁把 bridge 永久挡在门外)", () => {
|
|
198
|
+
const fn = src.slice(src.indexOf("function tryAcquireSpawnLock()"), src.indexOf("function releaseSpawnLock()"));
|
|
199
|
+
assert.match(fn, /openSync\(f, "wx"\)/, "必须用 wx 排他创建(原子的'要么拿到要么没有')");
|
|
200
|
+
assert.match(fn, /SPAWN_LOCK_STALE_MS/, "必须有陈旧阈值");
|
|
201
|
+
assert.match(fn, /!pidAlive\(pid\)/, "持锁进程已死 → 视为陈旧,可抢占");
|
|
202
|
+
assert.match(fn, /fs\.rmSync\(f, \{ force: true \}\)/, "陈旧锁必须被清掉再抢");
|
|
203
|
+
const rel = src.slice(src.indexOf("function releaseSpawnLock()"), src.indexOf("// ---------- run:"));
|
|
204
|
+
assert.match(rel, /owner === process\.pid/, "只删自己那把锁(别把接管者的锁删了)");
|
|
205
|
+
assert.match(src, /XPC_SERVICE_NAME/, "被监管者要能等到锁(它必须能接管)");
|
|
206
|
+
});
|
|
@@ -30,6 +30,7 @@ import { fileURLToPath } from "node:url";
|
|
|
30
30
|
|
|
31
31
|
import {
|
|
32
32
|
__pinBrowseDirectoryPicker,
|
|
33
|
+
__schedulePickerPin,
|
|
33
34
|
__pickerPinState,
|
|
34
35
|
__PICKER_AUTO_ENTRY_ID,
|
|
35
36
|
__PICKER_AUTO_PACKAGE,
|
|
@@ -188,8 +189,37 @@ test("诊断面板必须显示目录选择器状态(这类'按钮没反应'才
|
|
|
188
189
|
assert.match(SRC, /系统原生(远程时对话框弹在电脑那台机器上/, "原生时要给出可操作的解释");
|
|
189
190
|
});
|
|
190
191
|
|
|
191
|
-
test("接线:apply
|
|
192
|
-
|
|
192
|
+
test("接线:apply 挂的是**延后**接线(schedulePickerPin),绝不在启动期直接切 Loader", () => {
|
|
193
|
+
// 🔴 2026-09-26 事故锁:旧实现是 apply() 里直接 `pinBrowseDirectoryPicker(ctx)`,
|
|
194
|
+
// 于是 dsh 还在装配插件图时就去 loader.remove/create → dsh-client-modules 反复 compose
|
|
195
|
+
// 整张前端图(buildCombo/newlineCount/identitySectionMap 热点)→ dsh web 3 分钟不监听、
|
|
196
|
+
// 表现为假死。DSH_REMOTE_NATIVE_PICKER=1 能立刻恢复,正是这条链的证据。
|
|
197
|
+
assert.match(SRC, /ctx\.effect\(\(\) => schedulePickerPin\(ctx\), "dsh-remote-web: pin browser directory picker"\);/,
|
|
198
|
+
"apply 必须走延后接线");
|
|
199
|
+
assert.ok(!/ctx\.effect\(\(\) => \{[\s\S]{0,200}?pinBrowseDirectoryPicker\(ctx\)/.test(SRC),
|
|
200
|
+
"apply() 里不得再直接调用 pinBrowseDirectoryPicker —— 那正是启动假死的成因");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("延后闸门:必须等宿主 listen(boundWebPort)并静置,且只在 native 时换一次", () => {
|
|
204
|
+
const fn = SRC.slice(SRC.indexOf("function schedulePickerPin"), SRC.indexOf("export function apply"));
|
|
205
|
+
assert.ok(fn.length > 400, "找不到 schedulePickerPin 的实现");
|
|
206
|
+
assert.match(fn, /boundWebPort\(\) > 0/, "必须先等宿主 HTTP 服务 listen 起来(‘宿主已启动’最可靠的信号)");
|
|
207
|
+
assert.match(fn, /PICKER_PIN_SETTLE_MS/, "listen 之后必须静置,避开首轮 client-module compose");
|
|
208
|
+
assert.match(fn, /kind !== "native"/, "只有认得出是 native 才换");
|
|
209
|
+
assert.match(fn, /attempts: 1, gapMs: 0/, "只换一次,不再轮询(避免反复触发 compose)");
|
|
210
|
+
// 认不出/超时一律保留原生,绝不动 Loader
|
|
211
|
+
assert.match(fn, /action: "skipped"/, "认不出时必须优雅跳过");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("延后接线可取消(ctx.effect 清理):返回取消函数且取消后不再动作", async () => {
|
|
215
|
+
const loader = { store: {}, removed: [], created: [], async remove(id) { this.removed.push(id); }, async create(o) { this.created.push(o); return "id"; }, ctx: {} };
|
|
216
|
+
const ctx = { get: (n) => (n === "loader" ? loader : null), logger: {} };
|
|
217
|
+
const cancel = __schedulePickerPin(ctx);
|
|
218
|
+
assert.equal(typeof cancel, "function", "必须返回可取消的清理函数(宿主随时可能关掉我们)");
|
|
219
|
+
cancel();
|
|
220
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
221
|
+
assert.deepEqual(loader.removed, [], "取消后不得动 Loader");
|
|
222
|
+
assert.deepEqual(loader.created, [], "取消后不得动 Loader");
|
|
193
223
|
});
|
|
194
224
|
|
|
195
225
|
test("回归锁:源码里不得再出现「先建 browse、后摘 auto」的顺序(0.6.14 的坏版本)", () => {
|