@mrrisega/dsh-remote 0.7.4 → 0.7.5-beta.1
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 +540 -49
- package/clients/dsh-remote/e2ee-client.mjs +251 -0
- package/clients/dsh-remote/mobile-adapter.mjs +261 -0
- package/clients/dsh-remote/test/bridge-control-priority.test.mjs +206 -0
- package/clients/dsh-remote/test/bridge-heartbeat-adaptive.test.mjs +110 -0
- package/clients/dsh-remote/test/bridge-upstream-refresh.test.mjs +26 -8
- package/clients/dsh-remote/test/bridge-ws-open-fast-ack.test.mjs +213 -0
- package/clients/dsh-remote/test/e2ee-session-store.test.mjs +254 -0
- package/clients/dsh-remote/test/mobile-adapter-single-page.test.mjs +328 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +195 -0
- package/packages/dsh-remote-web/lib/index.js +145 -0
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/community-invite-proxy.test.mjs +343 -0
- package/packages/dsh-remote-web/test/community-invite-ui.test.mjs +375 -0
- package/packages/dsh-remote-web/test/purchase-agreement-proxy.test.mjs +198 -0
- package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +323 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// 🎉 企微交流群引导(使用满 2 小时弹一次)—— **浏览器半**(lib/client.js 面板)行为回归。
|
|
2
|
+
//
|
|
3
|
+
// 背景(业主 2026-10-23):用户实际使用满 2 小时后要弹一次「加入企业微信群」引导,
|
|
4
|
+
// 每用户全局只弹一次;手机 Web 端已完成,桌面端插件面板复用同一套企业端接口
|
|
5
|
+
// (桌面端没有用户 token 直连,走 node 半 /dsh-remote/community-invite/* 代理,见 proxy 用例)。
|
|
6
|
+
//
|
|
7
|
+
// 本文件把几件容易写错的事钉死:
|
|
8
|
+
// ① 弹不弹**完全由服务端 show 决定**(前端绝不自己算时长);show=false 时绝不能弹;
|
|
9
|
+
// ② 每桌面会话只弹一次(sessionStorage + 内存),服务端 already_shown 是第二道保险;
|
|
10
|
+
// ③ 「看过即算」:弹层一显示就 POST shown(幂等);两个按钮关闭时再调一次同样幂等;
|
|
11
|
+
// ④ 任何失败(接口 500/网络断/字段缺失)静默降级:不弹、不报错、不阻塞面板;
|
|
12
|
+
// ⑤ qr_url 为空 / 二维码图片加载失败 → 隐藏图片但**保留文案与按钮**(不出现空白二维码区)。
|
|
13
|
+
//
|
|
14
|
+
// 做法与 community-qr-ui.test.mjs 一致:真脚本 + vm + 迷你 React(真实 useEffect)。
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import vm from "node:vm";
|
|
19
|
+
|
|
20
|
+
const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
21
|
+
const QR_ABS = "https://relay.test/relay-api/qr/group-qr.png";
|
|
22
|
+
|
|
23
|
+
/** 找到自动弹层(aria-label 精确锁定,避免和「加入交流群」按钮弹窗混淆)。 */
|
|
24
|
+
function inviteCard(tree) {
|
|
25
|
+
return find(tree, (n) => n.props && n.props["aria-label"] === "加入用户交流群");
|
|
26
|
+
}
|
|
27
|
+
/** 弹层按钮(按文案找 onClick)。 */
|
|
28
|
+
function modalButton(tree, label) {
|
|
29
|
+
const card = inviteCard(tree);
|
|
30
|
+
assert.ok(card, "弹层不存在");
|
|
31
|
+
const btn = find(card, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes(label)));
|
|
32
|
+
assert.ok(btn, `弹层里找不到「${label}」按钮`);
|
|
33
|
+
return btn;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function walk(node, visit) {
|
|
37
|
+
if (node == null) return;
|
|
38
|
+
if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
|
|
39
|
+
if (typeof node !== "object") return;
|
|
40
|
+
visit(node);
|
|
41
|
+
for (const child of node.children || []) walk(child, visit);
|
|
42
|
+
}
|
|
43
|
+
function find(tree, predicate) {
|
|
44
|
+
let match;
|
|
45
|
+
walk(tree, (node) => { if (!match && predicate(node)) match = node; });
|
|
46
|
+
return match;
|
|
47
|
+
}
|
|
48
|
+
function textHas(tree, substr) {
|
|
49
|
+
return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
50
|
+
}
|
|
51
|
+
function nodeWithText(tree, substr) {
|
|
52
|
+
return find(tree, (node) => typeof node.props?.onClick === "function" && (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
53
|
+
}
|
|
54
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 迷你 React(真实 useEffect)+ 假定时器;fetch 覆盖 /dsh-remote/* 面板接口。
|
|
58
|
+
* @param {object} opts - community:/dsh-remote/community 的返回值(默认已配置二维码)
|
|
59
|
+
*/
|
|
60
|
+
function loadPlugin(opts = {}) {
|
|
61
|
+
let moduleFactory;
|
|
62
|
+
const registered = new Map();
|
|
63
|
+
const injects = new Map();
|
|
64
|
+
const requests = [];
|
|
65
|
+
|
|
66
|
+
let clock = 0;
|
|
67
|
+
let timerSeq = 1;
|
|
68
|
+
const timers = new Map();
|
|
69
|
+
const job = (fn) => { try { fn(); } catch (e) { /* 断言会暴露问题 */ } };
|
|
70
|
+
const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
|
|
71
|
+
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; };
|
|
72
|
+
const fakeClear = (id) => { timers.delete(id); };
|
|
73
|
+
|
|
74
|
+
// ── 迷你 React:真实 useState/useEffect + 递归渲染函数组件(FeedbackCard 等嵌套组件) ──
|
|
75
|
+
// 每个组件实例按「树中位置路径」持有自己的 hook 槽位(近似 React 的位置化协调),故子组件状态
|
|
76
|
+
// 可跨渲染保持;effects 在对应子树展开后执行(cleanup 语义与 React 一致)。
|
|
77
|
+
const scopes = new Map(); // key -> { hook, effectCursor, slots, pending }
|
|
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
|
+
/** 渲染一棵树:函数组件递归展开(含 effects),普通节点保留并递归子节点。 */
|
|
101
|
+
function renderNode(node, path) {
|
|
102
|
+
if (node == null) return node;
|
|
103
|
+
if (Array.isArray(node)) return node.map((n, i) => renderNode(n, path + "/" + i));
|
|
104
|
+
if (typeof node !== "object") return node;
|
|
105
|
+
if (typeof node.type === "function") {
|
|
106
|
+
const name = node.type.name || "anon";
|
|
107
|
+
const key = path + ":" + name;
|
|
108
|
+
const sc = scopes.get(key) || { hook: 0, effectCursor: 0, state: [], effects: [], pending: [] };
|
|
109
|
+
sc.hook = 0; sc.effectCursor = 0; sc.pending = [];
|
|
110
|
+
scopes.set(key, sc);
|
|
111
|
+
scopeStack.push(sc);
|
|
112
|
+
let out;
|
|
113
|
+
try {
|
|
114
|
+
out = node.type(node.props || {});
|
|
115
|
+
} finally {
|
|
116
|
+
scopeStack.pop();
|
|
117
|
+
}
|
|
118
|
+
const expanded = renderNode(out, key);
|
|
119
|
+
const todos = sc.pending;
|
|
120
|
+
sc.pending = [];
|
|
121
|
+
for (const t of todos) {
|
|
122
|
+
const prev = sc.effects[t.index];
|
|
123
|
+
if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
|
|
124
|
+
const cleanup = t.fn();
|
|
125
|
+
sc.effects[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
|
|
126
|
+
}
|
|
127
|
+
return expanded;
|
|
128
|
+
}
|
|
129
|
+
return { ...node, children: (node.children || []).map((c, i) => renderNode(c, path + "/" + i)) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const community = opts.community !== undefined ? opts.community : { ok: true, qrcode: QR_ABS, wechat: "dsh-remote-support" };
|
|
133
|
+
const status = opts.status || {
|
|
134
|
+
ok: true,
|
|
135
|
+
config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" },
|
|
136
|
+
service: { running: true },
|
|
137
|
+
remoteUrl: "https://relay.test/app/",
|
|
138
|
+
};
|
|
139
|
+
const response = (s, body) => Promise.resolve({ ok: s >= 200 && s < 300, status: s, text: async () => JSON.stringify(body) });
|
|
140
|
+
|
|
141
|
+
const makeEl = () => ({
|
|
142
|
+
tag: "div", children: [], style: {}, className: "", attributes: {}, textContent: "",
|
|
143
|
+
setAttribute(k, v) { this.attributes[k] = v; },
|
|
144
|
+
getAttribute(k) { return this.attributes[k] ?? null; },
|
|
145
|
+
appendChild(c) { this.children.push(c); },
|
|
146
|
+
addEventListener() {}, removeEventListener() {},
|
|
147
|
+
});
|
|
148
|
+
const doc = {
|
|
149
|
+
hidden: false,
|
|
150
|
+
createElement: makeEl,
|
|
151
|
+
head: makeEl(),
|
|
152
|
+
body: makeEl(),
|
|
153
|
+
querySelector() { return null; },
|
|
154
|
+
querySelectorAll() { return []; },
|
|
155
|
+
getElementById() { return null; },
|
|
156
|
+
addEventListener() {}, removeEventListener() {},
|
|
157
|
+
};
|
|
158
|
+
class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
|
|
159
|
+
const localStorage = {
|
|
160
|
+
_s: new Map(),
|
|
161
|
+
getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
|
|
162
|
+
setItem(k, v) { this._s.set(k, String(v)); },
|
|
163
|
+
removeItem(k) { this._s.delete(k); },
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const sessionStorage = {
|
|
167
|
+
_s: new Map(),
|
|
168
|
+
getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
|
|
169
|
+
setItem(k, v) { this._s.set(k, String(v)); },
|
|
170
|
+
removeItem(k) { this._s.delete(k); },
|
|
171
|
+
};
|
|
172
|
+
const sandbox = {
|
|
173
|
+
window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
|
|
174
|
+
document: doc,
|
|
175
|
+
localStorage,
|
|
176
|
+
sessionStorage,
|
|
177
|
+
location: { origin: "https://127.0.0.1:3080", href: "https://127.0.0.1:3080/" },
|
|
178
|
+
MutationObserver: MutationObserverMock,
|
|
179
|
+
navigator: { clipboard: { writeText: async () => {} } },
|
|
180
|
+
setTimeout: fakeSetTimeout,
|
|
181
|
+
clearTimeout: fakeClear,
|
|
182
|
+
setInterval: fakeSetInterval,
|
|
183
|
+
clearInterval: fakeClear,
|
|
184
|
+
fetch(path, options = {}) {
|
|
185
|
+
requests.push({ path, method: options.method || "GET" });
|
|
186
|
+
if (path === "/dsh-remote/status") return response(200, status);
|
|
187
|
+
if (path === "/dsh-remote/community") return response(200, community);
|
|
188
|
+
if (path === "/dsh-remote/community-invite/status") {
|
|
189
|
+
if (opts.inviteReject) return Promise.reject(new Error("network down"));
|
|
190
|
+
if (opts.inviteStatus && opts.inviteStatus !== 200) return response(opts.inviteStatus, { ok: false, error: "boom" });
|
|
191
|
+
return response(200, opts.invite !== undefined ? opts.invite : {
|
|
192
|
+
ok: true, show: true, reason: "ok", used_seconds: 7300, required_seconds: 7200, qr_url: QR_ABS,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (path === "/dsh-remote/community-invite/shown") {
|
|
196
|
+
if (opts.inviteShownFail) return response(500, { ok: false, error: "report failed" });
|
|
197
|
+
return response(200, { ok: true });
|
|
198
|
+
}
|
|
199
|
+
return response(200, { ok: true });
|
|
200
|
+
},
|
|
201
|
+
Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
vm.runInNewContext(SOURCE, sandbox);
|
|
205
|
+
const plugin = moduleFactory((name) => {
|
|
206
|
+
assert.equal(name, "react");
|
|
207
|
+
return react;
|
|
208
|
+
});
|
|
209
|
+
plugin.apply({
|
|
210
|
+
slots: {
|
|
211
|
+
inject(name, cb) { injects.set(name, cb); cb(); },
|
|
212
|
+
register(meta, component) { registered.set(meta.id, component); return () => {}; },
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
requests,
|
|
218
|
+
sessionStorage,
|
|
219
|
+
statusRequests: () => requests.filter((r) => r.path === "/dsh-remote/community-invite/status"),
|
|
220
|
+
shownRequests: () => requests.filter((r) => r.path === "/dsh-remote/community-invite/shown"),
|
|
221
|
+
/** 渲染 → 让在途请求落地 → 再渲染(返回最新树)。 */
|
|
222
|
+
async settle(times = 2) {
|
|
223
|
+
let tree = null;
|
|
224
|
+
for (let i = 0; i < times; i++) { tree = this.render(); await flush(); await flush(); }
|
|
225
|
+
return this.render();
|
|
226
|
+
},
|
|
227
|
+
render() {
|
|
228
|
+
const root = { type: registered.get("dsh-remote"), props: { close() {} }, children: [] };
|
|
229
|
+
return renderNode(root, "root");
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
test("① 服务端 show=false(未满 2h/已弹过/没配二维码)→ 不弹、不上报、不留会话标记", async () => {
|
|
236
|
+
const plugin = loadPlugin({ invite: { ok: true, show: false, reason: "not_enough_usage", used_seconds: 3600, required_seconds: 7200, qr_url: QR_ABS } });
|
|
237
|
+
const tree = await plugin.settle(4);
|
|
238
|
+
assert.equal(plugin.statusRequests().length, 1, "应该问过一次服务端");
|
|
239
|
+
assert.equal(inviteCard(tree), undefined, "show=false 时绝不能弹");
|
|
240
|
+
assert.ok(!textHas(tree, "加入用户交流群"), "面板任何位置都不应该出现引导标题");
|
|
241
|
+
assert.equal(plugin.shownRequests().length, 0, "没展示就不该上报 shown");
|
|
242
|
+
assert.equal(plugin.sessionStorage.getItem("dsh-remote-community-invite-seen-v1"), null,
|
|
243
|
+
"不弹时不能留会话标记 —— 否则用户这次没看到,下次也永远不弹了");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("② show=true → 弹层内容精确 + 展示即上报 shown;「稍后再看」关闭并幂等再报;重渲染不重复弹", async () => {
|
|
247
|
+
const plugin = loadPlugin();
|
|
248
|
+
let tree = await plugin.settle(4);
|
|
249
|
+
const card = inviteCard(tree);
|
|
250
|
+
assert.ok(card, "服务端 show=true 必须弹出引导");
|
|
251
|
+
|
|
252
|
+
// 文案/二维码:标题、正文、按钮、图片都是业主定的
|
|
253
|
+
assert.ok(textHas(tree, "🎉 加入用户交流群"), "标题必须是「🎉 加入用户交流群」");
|
|
254
|
+
assert.ok(textHas(tree, "在群里可以交流使用心得、提想法和建议,遇到问题也更容易找到我们"), "文案必须原样出现");
|
|
255
|
+
assert.ok(find(tree, (n) => n.props?.className === "dru-community-qr"), "应有二维码图片");
|
|
256
|
+
const img = find(tree, (n) => n.props?.className === "dru-community-qr");
|
|
257
|
+
assert.equal(img.props.src, QR_ABS, "二维码用 node 半下发的绝对地址");
|
|
258
|
+
assert.equal(img.props.alt, "用户交流群二维码");
|
|
259
|
+
|
|
260
|
+
// 展示即算:一显示就 POST shown(用户直接关页面也不会被反复骚扰)
|
|
261
|
+
assert.equal(plugin.shownRequests().length, 1, "弹层显示时就应上报 shown");
|
|
262
|
+
assert.equal(plugin.shownRequests()[0].method, "POST");
|
|
263
|
+
// 本次桌面会话标记已写
|
|
264
|
+
assert.equal(plugin.sessionStorage.getItem("dsh-remote-community-invite-seen-v1"), "1");
|
|
265
|
+
|
|
266
|
+
// 点「稍后再看」→ 关闭(关闭逻辑**同样调用** shown 上报,只是本地幂等标记已置位 → 不重复打网络)
|
|
267
|
+
modalButton(tree, "稍后再看").props.onClick();
|
|
268
|
+
tree = plugin.render();
|
|
269
|
+
assert.equal(inviteCard(tree), undefined, "点「稍后再看」后弹层应关闭");
|
|
270
|
+
assert.equal(plugin.shownRequests().length, 1, "展示时已成功上报过 → 关闭不重复请求(本地幂等标记;上报失败时会补报,见用例⑨)");
|
|
271
|
+
tree = await plugin.settle(3);
|
|
272
|
+
assert.equal(inviteCard(tree), undefined, "同一桌面会话内不得再次弹出");
|
|
273
|
+
assert.equal(plugin.statusRequests().length, 1, "不得重复询问服务端");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("③ 点「加入交流群」同样只关闭 + 幂等上报(不跳转、不新增请求)", async () => {
|
|
277
|
+
const plugin = loadPlugin();
|
|
278
|
+
const tree = await plugin.settle(4);
|
|
279
|
+
assert.ok(inviteCard(tree));
|
|
280
|
+
const ciBefore = plugin.requests.filter((r) => String(r.path).startsWith("/dsh-remote/community-invite")).length;
|
|
281
|
+
modalButton(tree, "加入交流群").props.onClick();
|
|
282
|
+
const after = plugin.render();
|
|
283
|
+
assert.equal(inviteCard(after), undefined, "点「加入交流群」后弹层应关闭");
|
|
284
|
+
assert.equal(plugin.shownRequests().length, 1, "展示时已上报过 → 本地幂等标记使关闭时不重复请求");
|
|
285
|
+
// 关闭动作本身不产生任何 community-invite 请求(没有跳转、没有二次上报)
|
|
286
|
+
assert.equal(
|
|
287
|
+
plugin.requests.filter((r) => String(r.path).startsWith("/dsh-remote/community-invite")).length,
|
|
288
|
+
ciBefore,
|
|
289
|
+
"关闭不应产生额外上报/查询(幂等上报只在展示时/失败补报时发生)"
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("④ qr_url 为空 → 仍然弹(文案/按钮保留),但不出现空白二维码区", async () => {
|
|
294
|
+
const plugin = loadPlugin({ invite: { ok: true, show: true, reason: "ok", used_seconds: 7300, required_seconds: 7200, qr_url: "" } });
|
|
295
|
+
const tree = await plugin.settle(4);
|
|
296
|
+
assert.ok(inviteCard(tree), "二维码不可用不能连引导一起吞掉");
|
|
297
|
+
assert.ok(textHas(tree, "🎉 加入用户交流群"));
|
|
298
|
+
assert.ok(textHas(tree, "在群里可以交流使用心得、提想法和建议,遇到问题也更容易找到我们"));
|
|
299
|
+
assert.ok(!find(tree, (n) => n.props?.className === "dru-community-qr"), "qr 为空时不应渲染 <img>(否则是空白块)");
|
|
300
|
+
assert.ok(textHas(tree, "二维码暂不可用"), "应给出可读的降级说明");
|
|
301
|
+
assert.ok(modalButton(tree, "加入交流群") && modalButton(tree, "稍后再看"), "两个按钮都要在");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("⑤ 二维码图片加载失败 → 只隐藏图片,文案与按钮保留", async () => {
|
|
305
|
+
const plugin = loadPlugin();
|
|
306
|
+
const tree = await plugin.settle(4);
|
|
307
|
+
const img = find(tree, (n) => n.props?.className === "dru-community-qr");
|
|
308
|
+
assert.ok(img, "前提:有二维码图片");
|
|
309
|
+
assert.equal(typeof img.props.onError, "function", "图片必须有 onError 兜底");
|
|
310
|
+
const target = { style: {} };
|
|
311
|
+
img.props.onError({ target });
|
|
312
|
+
assert.equal(target.style.display, "none", "加载失败应隐藏图片(不能留个破图)");
|
|
313
|
+
assert.ok(textHas(tree, "🎉 加入用户交流群"), "文案仍在");
|
|
314
|
+
assert.ok(modalButton(tree, "稍后再看"), "按钮仍在");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("⑥ 接口失败(500 / 网络异常)→ 静默:不弹、不报错、面板正常渲染", async () => {
|
|
318
|
+
for (const opts of [{ inviteStatus: 500 }, { inviteReject: true }]) {
|
|
319
|
+
const plugin = loadPlugin(opts);
|
|
320
|
+
const tree = await plugin.settle(4);
|
|
321
|
+
assert.equal(plugin.statusRequests().length, 1, "仍然问过一次服务端");
|
|
322
|
+
assert.equal(inviteCard(tree), undefined, "失败时绝不弹");
|
|
323
|
+
assert.ok(textHas(tree, "远程访问"), "面板本身必须正常渲染(失败静默,不阻塞主流程)");
|
|
324
|
+
assert.equal(plugin.shownRequests().length, 0, "没展示就不该上报");
|
|
325
|
+
assert.equal(plugin.sessionStorage.getItem("dsh-remote-community-invite-seen-v1"), null,
|
|
326
|
+
"失败不能消耗'本次会话已弹'标记 —— 下次还有机会");
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("⑦ 本次桌面会话已弹过(sessionStorage 标记)→ 连 status 都不再请求", async () => {
|
|
331
|
+
const plugin = loadPlugin();
|
|
332
|
+
plugin.sessionStorage.setItem("dsh-remote-community-invite-seen-v1", "1");
|
|
333
|
+
const tree = await plugin.settle(4);
|
|
334
|
+
assert.equal(plugin.statusRequests().length, 0, "已弹过就不该再问服务端(每会话一次)");
|
|
335
|
+
assert.equal(inviteCard(tree), undefined);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("⑧ 自建/local 模式不弹,也不请求 community-invite(没有企业端账号体系)", async () => {
|
|
339
|
+
const plugin = loadPlugin({
|
|
340
|
+
status: {
|
|
341
|
+
ok: true,
|
|
342
|
+
config: { mode: "local", hasLocalKey: true, phone: "", hasPhone: false, deviceId: "dev-x" },
|
|
343
|
+
service: { running: true },
|
|
344
|
+
remoteUrl: "https://127.0.0.1:3080/",
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
const tree = await plugin.settle(4);
|
|
348
|
+
assert.equal(plugin.statusRequests().length, 0, "local 模式不该请求企业端接口");
|
|
349
|
+
assert.equal(inviteCard(tree), undefined);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("⑨ shown 首次上报失败 → 关闭时再补一次(幂等重试,不影响关闭)", async () => {
|
|
353
|
+
const plugin = loadPlugin({ inviteShownFail: true });
|
|
354
|
+
const tree = await plugin.settle(4);
|
|
355
|
+
assert.ok(inviteCard(tree), "上报失败不影响展示(看过即算的展示才是弹不弹的依据)");
|
|
356
|
+
assert.equal(plugin.shownRequests().length, 1);
|
|
357
|
+
modalButton(tree, "稍后再看").props.onClick();
|
|
358
|
+
const after = plugin.render();
|
|
359
|
+
assert.equal(inviteCard(after), undefined, "即使上报失败也必须能关掉");
|
|
360
|
+
assert.equal(plugin.shownRequests().length, 2, "关闭时应补报一次(服务端幂等)");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("⑩ 未登录 → 不请求 community-invite(面板登录后才问)", async () => {
|
|
364
|
+
const plugin = loadPlugin({
|
|
365
|
+
status: {
|
|
366
|
+
ok: true,
|
|
367
|
+
config: { phone: "", hasPhone: false, mode: "saas", deviceId: "dev-x" },
|
|
368
|
+
service: { running: false },
|
|
369
|
+
remoteUrl: "https://relay.test/app/",
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
const tree = await plugin.settle(4);
|
|
373
|
+
assert.equal(plugin.statusRequests().length, 0, "未登录不该调用(node 半也会静默拒绝,但前端先别打)");
|
|
374
|
+
assert.equal(inviteCard(tree), undefined);
|
|
375
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// 📄 会员服务购买协议 URL —— **宿主半边**(lib/index.js)契约回归。
|
|
2
|
+
//
|
|
3
|
+
// 背景(业主 2026-10-23):客户端 0.7.5-beta.1 要求购买入口带《会员服务购买协议》链接。
|
|
4
|
+
// 正式协议由企业端提供,URL 来源优先级:
|
|
5
|
+
// 1) 企业端 public-config 的 `agreement_url`(**新字段**;没上线时没有 → 忽略);
|
|
6
|
+
// 2) 固定兜底 DEFAULT_PURCHASE_AGREEMENT_URL。
|
|
7
|
+
//
|
|
8
|
+
// 本文件锁四件:
|
|
9
|
+
// ① 纯函数 `purchaseAgreementUrlFrom`:优先级 + 只认 http(s) 绝对地址(其它协议/相对路径回兜底);
|
|
10
|
+
// ② 路由 `GET /dsh-remote/purchase-agreement` 的端到端行为(假 relay 发 public-config);
|
|
11
|
+
// ③ 失败软着陆:public-config 不可达也返回 `ok:true` + 兜底 URL —— 协议链接不得成为购买流程的故障点;
|
|
12
|
+
// ④ 只接受 GET(错误方法 404)。
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
import { apply, DEFAULT_PURCHASE_AGREEMENT_URL, purchaseAgreementUrlFrom } from "../lib/index.js";
|
|
20
|
+
|
|
21
|
+
const FALLBACK = DEFAULT_PURCHASE_AGREEMENT_URL;
|
|
22
|
+
|
|
23
|
+
test("兜底常量就是业主约定的正式兜底地址(企业端 /api/purchase-agreement 的 302/200 入口)", () => {
|
|
24
|
+
assert.equal(FALLBACK, "https://n.risegao.cn:13443/relay-api/api/purchase-agreement");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("纯函数:public-config.agreement_url 优先;非 http(s)/相对/缺失一律回兜底", () => {
|
|
28
|
+
// ① 正常:绝对 https / http 都接受(企业端可能配 http 的内网地址)
|
|
29
|
+
assert.equal(purchaseAgreementUrlFrom({ agreement_url: "https://relay.test/agreement.html" }), "https://relay.test/agreement.html");
|
|
30
|
+
assert.equal(purchaseAgreementUrlFrom({ agreement_url: "http://127.0.0.1:8080/agreement" }), "http://127.0.0.1:8080/agreement");
|
|
31
|
+
assert.equal(purchaseAgreementUrlFrom({ agreement_url: " https://relay.test/a?b=1 " }), "https://relay.test/a?b=1", "两端空白应被裁掉");
|
|
32
|
+
|
|
33
|
+
// ② 没这个字段 / 空串 / 非对象 → 兜底
|
|
34
|
+
assert.equal(purchaseAgreementUrlFrom({}), FALLBACK);
|
|
35
|
+
assert.equal(purchaseAgreementUrlFrom({ agreement_url: "" }), FALLBACK);
|
|
36
|
+
assert.equal(purchaseAgreementUrlFrom({ agreement_url: " " }), FALLBACK);
|
|
37
|
+
assert.equal(purchaseAgreementUrlFrom(null), FALLBACK);
|
|
38
|
+
assert.equal(purchaseAgreementUrlFrom("https://relay.test/x"), FALLBACK, "非对象(被改坏的类型)→ 兜底");
|
|
39
|
+
assert.equal(purchaseAgreementUrlFrom({ agreement_url: 123 }), FALLBACK, "非字符串 → 兜底");
|
|
40
|
+
|
|
41
|
+
// ③ 安全性:这个值最终进 <a href>,绝不吃可执行协议/协议相对/相对路径
|
|
42
|
+
for (const bad of [
|
|
43
|
+
"javascript:alert(1)",
|
|
44
|
+
"JavaScript:alert(1)",
|
|
45
|
+
"data:text/html,<script>alert(1)</script>",
|
|
46
|
+
"vbscript:msgbox(1)",
|
|
47
|
+
"//evil.example/agreement",
|
|
48
|
+
"/relay-api/api/purchase-agreement",
|
|
49
|
+
"agreement.html",
|
|
50
|
+
"ftp://relay.test/agreement",
|
|
51
|
+
" https://relay.test/a b", // 含空白 → 不当作合法 URL
|
|
52
|
+
]) {
|
|
53
|
+
assert.equal(purchaseAgreementUrlFrom({ agreement_url: bad }), FALLBACK, `非法 agreement_url 必须回兜底: ${bad}`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** 起一个假 relay:只实现 /api/public-config(内容可配)。 */
|
|
58
|
+
async function startFakeRelay(handler) {
|
|
59
|
+
const seen = [];
|
|
60
|
+
const srv = http.createServer((req, res) => {
|
|
61
|
+
seen.push({ method: req.method, url: req.url });
|
|
62
|
+
handler(req, res);
|
|
63
|
+
});
|
|
64
|
+
await new Promise((r) => srv.listen(0, "127.0.0.1", r));
|
|
65
|
+
return { srv, seen, base: `http://127.0.0.1:${srv.address().port}` };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function boot(relayDir) {
|
|
69
|
+
const routes = new Map();
|
|
70
|
+
apply({
|
|
71
|
+
webServer: { register(rt) { routes.set(rt.path, rt.handler); return () => {}; } },
|
|
72
|
+
effect(reg) { return reg(); },
|
|
73
|
+
logger: { info() {}, warn() {} },
|
|
74
|
+
}, { relayDir });
|
|
75
|
+
const host = http.createServer((req, res) => {
|
|
76
|
+
const url = new URL(req.url, "http://x");
|
|
77
|
+
const handler = routes.get(url.pathname);
|
|
78
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
79
|
+
});
|
|
80
|
+
return host;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function withPlugin(fn) {
|
|
84
|
+
const relayDir = await mkdtemp(path.join(os.tmpdir(), "dsh-agr-"));
|
|
85
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 套件级隔离:绝不真的去后台装运行环境
|
|
86
|
+
let host = null;
|
|
87
|
+
try {
|
|
88
|
+
return await fn({ relayDir, startHost: () => { host = boot(relayDir); return host; } });
|
|
89
|
+
} finally {
|
|
90
|
+
try { if (host) host.close(); } catch { /* 忽略 */ }
|
|
91
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
92
|
+
await rm(relayDir, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const writeCfg = (relayDir, apiUrl) => writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
|
|
97
|
+
device_id: "dev-agr-1", api_url: apiUrl,
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
async function listen(host) {
|
|
101
|
+
await new Promise((r) => host.listen(0, "127.0.0.1", r));
|
|
102
|
+
return `http://127.0.0.1:${host.address().port}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const json = (res, body, code = 200) => {
|
|
106
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
107
|
+
res.end(JSON.stringify(body));
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
test("路由:public-config 配了 agreement_url → 原样下发(source=public_config)", async () => {
|
|
111
|
+
const up = await startFakeRelay((req, res) => {
|
|
112
|
+
const url = new URL(req.url, "http://x");
|
|
113
|
+
if (url.pathname === "/api/public-config") {
|
|
114
|
+
return json(res, { service: "dsh-remote", agreement_url: "https://relay.test/relay-api/agreement.html" });
|
|
115
|
+
}
|
|
116
|
+
json(res, { error: { code: "not_found" } }, 404);
|
|
117
|
+
});
|
|
118
|
+
try {
|
|
119
|
+
await withPlugin(async ({ relayDir, startHost }) => {
|
|
120
|
+
await writeCfg(relayDir, up.base);
|
|
121
|
+
const base = await listen(startHost());
|
|
122
|
+
const r = await (await fetch(`${base}/dsh-remote/purchase-agreement`)).json();
|
|
123
|
+
assert.equal(r.ok, true);
|
|
124
|
+
assert.equal(r.url, "https://relay.test/relay-api/agreement.html");
|
|
125
|
+
assert.equal(r.source, "public_config", "应能看出这个 URL 是服务端配的");
|
|
126
|
+
assert.ok(up.seen.some((e) => e.url === "/api/public-config"), "node 半应读企业端 public-config");
|
|
127
|
+
});
|
|
128
|
+
} finally {
|
|
129
|
+
up.srv.close();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("路由:public-config 没有 agreement_url(企业端未上线)→ 忽略该字段,回固定兜底", async () => {
|
|
134
|
+
const up = await startFakeRelay((req, res) => {
|
|
135
|
+
const url = new URL(req.url, "http://x");
|
|
136
|
+
if (url.pathname === "/api/public-config") return json(res, { service: "dsh-remote", plans: {} });
|
|
137
|
+
json(res, {}, 404);
|
|
138
|
+
});
|
|
139
|
+
try {
|
|
140
|
+
await withPlugin(async ({ relayDir, startHost }) => {
|
|
141
|
+
await writeCfg(relayDir, up.base);
|
|
142
|
+
const base = await listen(startHost());
|
|
143
|
+
const r = await (await fetch(`${base}/dsh-remote/purchase-agreement`)).json();
|
|
144
|
+
assert.equal(r.ok, true);
|
|
145
|
+
assert.equal(r.url, FALLBACK);
|
|
146
|
+
assert.equal(r.source, "fallback");
|
|
147
|
+
});
|
|
148
|
+
} finally {
|
|
149
|
+
up.srv.close();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("路由:字段给了但非法(javascript:/data:/相对)→ 回兜底,并标记 source=public_config_invalid", async () => {
|
|
154
|
+
for (const bad of ["javascript:alert(1)", "data:text/html,x", "//evil.example/a", "/relay-api/api/purchase-agreement"]) {
|
|
155
|
+
const up = await startFakeRelay((req, res) => {
|
|
156
|
+
const url = new URL(req.url, "http://x");
|
|
157
|
+
if (url.pathname === "/api/public-config") return json(res, { agreement_url: bad });
|
|
158
|
+
json(res, {}, 404);
|
|
159
|
+
});
|
|
160
|
+
try {
|
|
161
|
+
await withPlugin(async ({ relayDir, startHost }) => {
|
|
162
|
+
await writeCfg(relayDir, up.base);
|
|
163
|
+
const base = await listen(startHost());
|
|
164
|
+
const r = await (await fetch(`${base}/dsh-remote/purchase-agreement`)).json();
|
|
165
|
+
assert.equal(r.url, FALLBACK, `非法字段必须回兜底: ${bad}`);
|
|
166
|
+
assert.equal(r.source, "public_config_invalid", "排障需要能看出'配了但非法'(不是没配)");
|
|
167
|
+
});
|
|
168
|
+
} finally {
|
|
169
|
+
up.srv.close();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("路由:public-config 不可达 → 仍 ok:true + 兜底(协议链接不得拖挂购买流程)", async () => {
|
|
175
|
+
await withPlugin(async ({ relayDir, startHost }) => {
|
|
176
|
+
await writeCfg(relayDir, "http://127.0.0.1:1"); // 一定没人监听的端口
|
|
177
|
+
const base = await listen(startHost());
|
|
178
|
+
const res = await fetch(`${base}/dsh-remote/purchase-agreement`);
|
|
179
|
+
assert.equal(res.status, 200, "永远 200:购买入口的协议链接不该因服务端故障消失");
|
|
180
|
+
const r = await res.json();
|
|
181
|
+
assert.equal(r.ok, true);
|
|
182
|
+
assert.equal(r.url, FALLBACK);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("路由:只接受 GET(POST → 404),不会把它当成可写接口", async () => {
|
|
187
|
+
const up = await startFakeRelay((_req, res) => json(res, { agreement_url: "https://relay.test/a" }));
|
|
188
|
+
try {
|
|
189
|
+
await withPlugin(async ({ relayDir, startHost }) => {
|
|
190
|
+
await writeCfg(relayDir, up.base);
|
|
191
|
+
const base = await listen(startHost());
|
|
192
|
+
const res = await fetch(`${base}/dsh-remote/purchase-agreement`, { method: "POST" });
|
|
193
|
+
assert.equal(res.status, 404);
|
|
194
|
+
});
|
|
195
|
+
} finally {
|
|
196
|
+
up.srv.close();
|
|
197
|
+
}
|
|
198
|
+
});
|