@mrrisega/dsh-remote 0.7.4-beta.1 → 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,323 @@
|
|
|
1
|
+
// 📄 会员服务购买协议链接 —— **浏览器半**(lib/client.js 面板)行为回归。
|
|
2
|
+
//
|
|
3
|
+
// 背景(业主 2026-10-23,客户端 0.7.5-beta.1):购买/升级入口必须带《会员服务购买协议》链接。
|
|
4
|
+
// 桌面端购买入口有两条(都在「账号卡」与「连接偏慢双路块」里),两者都要摆链接:
|
|
5
|
+
// · 「🚀 升级 PRO / 转正式 / 续费会员」主按钮下方(账号卡)
|
|
6
|
+
// · 「连接偏慢」双路块里的升级按钮旁(只在该块有购买路时出现)
|
|
7
|
+
//
|
|
8
|
+
// 本文件锁:
|
|
9
|
+
// ① 链接存在、文案/新标签/安全属性正确;
|
|
10
|
+
// ② URL 优先级:node 半下发的 `public-config.agreement_url` 覆盖兜底;
|
|
11
|
+
// ③ 安全过滤:node 半给了非法协议(javascript:/data:/相对)→ 客户端**再**过滤回兜底;
|
|
12
|
+
// ④ 失败软着陆:node 半接口 500/网络异常 → 链接仍用兜底常量存在,购买按钮不受影响;
|
|
13
|
+
// ⑤ 未登录不展示(购买入口本来就只在登录后出现)。
|
|
14
|
+
//
|
|
15
|
+
// 做法与 community-invite-ui.test.mjs 一致:真脚本 + vm + 迷你 React(真实 useEffect)。
|
|
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
|
+
const AGREEMENT_ABS = "https://relay.test/relay-api/api/purchase-agreement"; // node 半下发的(模拟 public-config 配的)
|
|
23
|
+
const AGREEMENT_FALLBACK = "https://n.risegao.cn:13443/relay-api/api/purchase-agreement"; // 固定兜底常量
|
|
24
|
+
// 复制自 community-invite-ui 的迷你 React harness 里,交流群/弹层接口仍有默认夹具,统一给一个假二维码地址。
|
|
25
|
+
const QR_ABS = "https://relay.test/relay-api/qr/group-qr.png";
|
|
26
|
+
|
|
27
|
+
/** 购买协议的 <a>(按 className 定位;要求带 href)。 */
|
|
28
|
+
function agreementLink(tree) {
|
|
29
|
+
return find(tree, (n) => n.props && n.props.className === "dru-legal-a" && typeof n.props.href === "string");
|
|
30
|
+
}
|
|
31
|
+
function linkTexts(node) {
|
|
32
|
+
return (node.children || []).filter((c) => typeof c === "string").join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function walk(node, visit) {
|
|
36
|
+
if (node == null) return;
|
|
37
|
+
if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
|
|
38
|
+
if (typeof node !== "object") return;
|
|
39
|
+
visit(node);
|
|
40
|
+
for (const child of node.children || []) walk(child, visit);
|
|
41
|
+
}
|
|
42
|
+
function find(tree, predicate) {
|
|
43
|
+
let match;
|
|
44
|
+
walk(tree, (node) => { if (!match && predicate(node)) match = node; });
|
|
45
|
+
return match;
|
|
46
|
+
}
|
|
47
|
+
function textHas(tree, substr) {
|
|
48
|
+
return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
49
|
+
}
|
|
50
|
+
function nodeWithText(tree, substr) {
|
|
51
|
+
return find(tree, (node) => typeof node.props?.onClick === "function" && (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
52
|
+
}
|
|
53
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 迷你 React(真实 useEffect)+ 假定时器;fetch 覆盖 /dsh-remote/* 面板接口。
|
|
57
|
+
* @param {object} opts - community:/dsh-remote/community 的返回值(默认已配置二维码)
|
|
58
|
+
*/
|
|
59
|
+
function loadPlugin(opts = {}) {
|
|
60
|
+
let moduleFactory;
|
|
61
|
+
const registered = new Map();
|
|
62
|
+
const injects = new Map();
|
|
63
|
+
const requests = [];
|
|
64
|
+
|
|
65
|
+
let clock = 0;
|
|
66
|
+
let timerSeq = 1;
|
|
67
|
+
const timers = new Map();
|
|
68
|
+
const job = (fn) => { try { fn(); } catch (e) { /* 断言会暴露问题 */ } };
|
|
69
|
+
const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
|
|
70
|
+
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; };
|
|
71
|
+
const fakeClear = (id) => { timers.delete(id); };
|
|
72
|
+
|
|
73
|
+
// ── 迷你 React:真实 useState/useEffect + 递归渲染函数组件(FeedbackCard 等嵌套组件) ──
|
|
74
|
+
// 每个组件实例按「树中位置路径」持有自己的 hook 槽位(近似 React 的位置化协调),故子组件状态
|
|
75
|
+
// 可跨渲染保持;effects 在对应子树展开后执行(cleanup 语义与 React 一致)。
|
|
76
|
+
const scopes = new Map(); // key -> { hook, effectCursor, slots, pending }
|
|
77
|
+
const scopeStack = [];
|
|
78
|
+
const currentScope = () => scopeStack[scopeStack.length - 1];
|
|
79
|
+
|
|
80
|
+
const react = {
|
|
81
|
+
createElement(type, props, ...children) { return { type, props: props || {}, children }; },
|
|
82
|
+
useState(initial) {
|
|
83
|
+
const sc = currentScope();
|
|
84
|
+
const index = sc.hook++;
|
|
85
|
+
if (!(index in sc.state)) sc.state[index] = typeof initial === "function" ? initial() : initial;
|
|
86
|
+
return [sc.state[index], (value) => { sc.state[index] = typeof value === "function" ? value(sc.state[index]) : value; }];
|
|
87
|
+
},
|
|
88
|
+
useEffect(fn, deps) {
|
|
89
|
+
const sc = currentScope();
|
|
90
|
+
const index = sc.effectCursor++;
|
|
91
|
+
const prev = sc.effects[index];
|
|
92
|
+
const changed = !prev || !deps || deps.length !== prev.deps.length || deps.some((d, i) => !Object.is(d, prev.deps[i]));
|
|
93
|
+
if (changed) sc.pending.push({ index, fn, deps: deps ? [...deps] : deps });
|
|
94
|
+
},
|
|
95
|
+
useCallback(fn) { return fn; },
|
|
96
|
+
useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** 渲染一棵树:函数组件递归展开(含 effects),普通节点保留并递归子节点。 */
|
|
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 community = opts.community !== undefined ? opts.community : { ok: true, qrcode: QR_ABS, wechat: "dsh-remote-support" };
|
|
132
|
+
const status = opts.status || {
|
|
133
|
+
ok: true,
|
|
134
|
+
config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" },
|
|
135
|
+
service: { running: true },
|
|
136
|
+
remoteUrl: "https://relay.test/app/",
|
|
137
|
+
};
|
|
138
|
+
const response = (s, body) => Promise.resolve({ ok: s >= 200 && s < 300, status: s, text: async () => JSON.stringify(body) });
|
|
139
|
+
|
|
140
|
+
const makeEl = () => ({
|
|
141
|
+
tag: "div", children: [], style: {}, className: "", attributes: {}, textContent: "",
|
|
142
|
+
setAttribute(k, v) { this.attributes[k] = v; },
|
|
143
|
+
getAttribute(k) { return this.attributes[k] ?? null; },
|
|
144
|
+
appendChild(c) { this.children.push(c); },
|
|
145
|
+
addEventListener() {}, removeEventListener() {},
|
|
146
|
+
});
|
|
147
|
+
const doc = {
|
|
148
|
+
hidden: false,
|
|
149
|
+
createElement: makeEl,
|
|
150
|
+
head: makeEl(),
|
|
151
|
+
body: makeEl(),
|
|
152
|
+
querySelector() { return null; },
|
|
153
|
+
querySelectorAll() { return []; },
|
|
154
|
+
getElementById() { return null; },
|
|
155
|
+
addEventListener() {}, removeEventListener() {},
|
|
156
|
+
};
|
|
157
|
+
class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
|
|
158
|
+
const localStorage = {
|
|
159
|
+
_s: new Map(),
|
|
160
|
+
getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
|
|
161
|
+
setItem(k, v) { this._s.set(k, String(v)); },
|
|
162
|
+
removeItem(k) { this._s.delete(k); },
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const sessionStorage = {
|
|
166
|
+
_s: new Map(),
|
|
167
|
+
getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
|
|
168
|
+
setItem(k, v) { this._s.set(k, String(v)); },
|
|
169
|
+
removeItem(k) { this._s.delete(k); },
|
|
170
|
+
};
|
|
171
|
+
const sandbox = {
|
|
172
|
+
window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
|
|
173
|
+
document: doc,
|
|
174
|
+
localStorage,
|
|
175
|
+
sessionStorage,
|
|
176
|
+
location: { origin: "https://127.0.0.1:3080", href: "https://127.0.0.1:3080/" },
|
|
177
|
+
MutationObserver: MutationObserverMock,
|
|
178
|
+
navigator: { clipboard: { writeText: async () => {} } },
|
|
179
|
+
setTimeout: fakeSetTimeout,
|
|
180
|
+
clearTimeout: fakeClear,
|
|
181
|
+
setInterval: fakeSetInterval,
|
|
182
|
+
clearInterval: fakeClear,
|
|
183
|
+
fetch(path, options = {}) {
|
|
184
|
+
requests.push({ path, method: options.method || "GET" });
|
|
185
|
+
if (path === "/dsh-remote/status") return response(200, status);
|
|
186
|
+
if (path === "/dsh-remote/community") return response(200, community);
|
|
187
|
+
if (path === "/dsh-remote/community-invite/status") {
|
|
188
|
+
if (opts.inviteReject) return Promise.reject(new Error("network down"));
|
|
189
|
+
if (opts.inviteStatus && opts.inviteStatus !== 200) return response(opts.inviteStatus, { ok: false, error: "boom" });
|
|
190
|
+
return response(200, opts.invite !== undefined ? opts.invite : {
|
|
191
|
+
ok: true, show: true, reason: "ok", used_seconds: 7300, required_seconds: 7200, qr_url: QR_ABS,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (path === "/dsh-remote/community-invite/shown") {
|
|
195
|
+
if (opts.inviteShownFail) return response(500, { ok: false, error: "report failed" });
|
|
196
|
+
return response(200, { ok: true });
|
|
197
|
+
}
|
|
198
|
+
if (path === "/dsh-remote/purchase-agreement") {
|
|
199
|
+
if (opts.agreementReject) return Promise.reject(new Error("public-config down"));
|
|
200
|
+
if (opts.agreementStatus && opts.agreementStatus !== 200) return response(opts.agreementStatus, { ok: false });
|
|
201
|
+
return response(200, opts.agreement !== undefined ? opts.agreement : { ok: true, url: AGREEMENT_ABS });
|
|
202
|
+
}
|
|
203
|
+
return response(200, { ok: true });
|
|
204
|
+
},
|
|
205
|
+
Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
vm.runInNewContext(SOURCE, sandbox);
|
|
209
|
+
const plugin = moduleFactory((name) => {
|
|
210
|
+
assert.equal(name, "react");
|
|
211
|
+
return react;
|
|
212
|
+
});
|
|
213
|
+
plugin.apply({
|
|
214
|
+
slots: {
|
|
215
|
+
inject(name, cb) { injects.set(name, cb); cb(); },
|
|
216
|
+
register(meta, component) { registered.set(meta.id, component); return () => {}; },
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
requests,
|
|
222
|
+
sessionStorage,
|
|
223
|
+
statusRequests: () => requests.filter((r) => r.path === "/dsh-remote/community-invite/status"),
|
|
224
|
+
agreementRequests: () => requests.filter((r) => r.path === "/dsh-remote/purchase-agreement"),
|
|
225
|
+
shownRequests: () => requests.filter((r) => r.path === "/dsh-remote/community-invite/shown"),
|
|
226
|
+
/** 渲染 → 让在途请求落地 → 再渲染(返回最新树)。 */
|
|
227
|
+
async settle(times = 2) {
|
|
228
|
+
let tree = null;
|
|
229
|
+
for (let i = 0; i < times; i++) { tree = this.render(); await flush(); await flush(); }
|
|
230
|
+
return this.render();
|
|
231
|
+
},
|
|
232
|
+
render() {
|
|
233
|
+
const root = { type: registered.get("dsh-remote"), props: { close() {} }, children: [] };
|
|
234
|
+
return renderNode(root, "root");
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
test("① 登录后账号卡购买入口旁有协议链接:新标签打开 + 文案/属性正确,URL 用 node 半下发的地址", async () => {
|
|
240
|
+
const plugin = loadPlugin();
|
|
241
|
+
const tree = await plugin.settle(4);
|
|
242
|
+
const a = agreementLink(tree);
|
|
243
|
+
assert.ok(a, "购买/升级入口旁必须能看到《会员服务购买协议》链接");
|
|
244
|
+
assert.equal(a.props.href, AGREEMENT_ABS, "URL 优先用 node 半下发的 public-config.agreement_url");
|
|
245
|
+
assert.equal(a.props.target, "_blank", "必须在新标签打开(不在面板里跳走)");
|
|
246
|
+
assert.match(String(a.props.rel || ""), /noopener/, "rel 必须带 noopener(反钓鱼面)");
|
|
247
|
+
assert.match(String(a.props.rel || ""), /noreferrer/, "rel 应带 noreferrer");
|
|
248
|
+
assert.equal(a.type, "a", "必须是真正的链接(可复制地址/中键打开),不是 button");
|
|
249
|
+
assert.match(linkTexts(a), /《会员服务购买协议》/, "链接文案必须是《会员服务购买协议》");
|
|
250
|
+
// 购买/升级按钮仍然在(链接不替代购买)
|
|
251
|
+
assert.ok(nodeWithText(tree, "🚀 升级 PRO") || nodeWithText(tree, "🔄 续费会员") || nodeWithText(tree, "🚀 转正式 PRO"),
|
|
252
|
+
"购买/续费主按钮必须仍在(协议链接只是补充)");
|
|
253
|
+
assert.equal(plugin.agreementRequests().length, 1, "每页面会话只问一次协议地址");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("② 优先级:node 半下发 public-config 配的地址 → 覆盖兜底常量", async () => {
|
|
257
|
+
const plugin = loadPlugin({ agreement: { ok: true, url: "https://relay.test/custom/agreement.html", source: "public_config" } });
|
|
258
|
+
const tree = await plugin.settle(4);
|
|
259
|
+
const a = agreementLink(tree);
|
|
260
|
+
assert.ok(a);
|
|
261
|
+
assert.equal(a.props.href, "https://relay.test/custom/agreement.html", "服务端配置优先于兜底");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("③ 安全过滤:node 半给了非法协议/相对路径 → 客户端丢弃并回兜底(<a href> 不能吃 javascript:)", async () => {
|
|
265
|
+
for (const bad of ["javascript:alert(1)", "data:text/html,x", "//evil.example/agreement", "/relay-api/x", ""]) {
|
|
266
|
+
const plugin = loadPlugin({ agreement: { ok: true, url: bad } });
|
|
267
|
+
const tree = await plugin.settle(4);
|
|
268
|
+
const a = agreementLink(tree);
|
|
269
|
+
assert.ok(a, `非法 URL 时链接仍应存在(用兜底): ${JSON.stringify(bad)}`);
|
|
270
|
+
assert.equal(a.props.href, AGREEMENT_FALLBACK, `非法 URL 必须回兜底: ${JSON.stringify(bad)}`);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("④ 失败软着陆:node 半 500 / 网络异常 → 链接仍存在且用兜底,购买按钮不受影响", async () => {
|
|
275
|
+
for (const opts of [{ agreementStatus: 500 }, { agreementReject: true }]) {
|
|
276
|
+
const plugin = loadPlugin(opts);
|
|
277
|
+
const tree = await plugin.settle(4);
|
|
278
|
+
const a = agreementLink(tree);
|
|
279
|
+
assert.ok(a, "协议接口挂了也不能让链接消失(这是法律/产品硬要求)");
|
|
280
|
+
assert.equal(a.props.href, AGREEMENT_FALLBACK, "失败时用固定兜底 URL");
|
|
281
|
+
assert.equal(a.props.target, "_blank");
|
|
282
|
+
assert.ok(nodeWithText(tree, "🚀 升级 PRO") || nodeWithText(tree, "🔄 续费会员") || nodeWithText(tree, "🚀 转正式 PRO"),
|
|
283
|
+
"失败不影响购买流程:主按钮照常渲染");
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("⑤ 「连接偏慢」双路块里的购买路旁也有协议链接,且与账号卡同一 URL", async () => {
|
|
288
|
+
const plugin = loadPlugin({
|
|
289
|
+
status: {
|
|
290
|
+
ok: true,
|
|
291
|
+
config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" },
|
|
292
|
+
service: { running: true },
|
|
293
|
+
// 触发 connSlow:服务端已自动重试 ≥3 次(见 client.js connSlow 判定)
|
|
294
|
+
connect: { phase: "connecting", attempts: 3, text: "正在自动重试连接" },
|
|
295
|
+
remoteUrl: "https://relay.test/app/",
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
const tree = await plugin.settle(4);
|
|
299
|
+
const dual = find(tree, (n) => n.props && n.props.className === "dru-dual");
|
|
300
|
+
assert.ok(dual, "前提:慢连接时双路块应出现");
|
|
301
|
+
const inDual = find(dual, (n) => n.props && n.props.className === "dru-legal-a" && typeof n.props.href === "string");
|
|
302
|
+
assert.ok(inDual, "双路块的购买(升级)路旁必须有协议链接");
|
|
303
|
+
assert.equal(inDual.props.href, AGREEMENT_ABS);
|
|
304
|
+
// 账号卡那份也在 → 同页两处;两处 href 一致(同一个来源,不能各写各的)
|
|
305
|
+
const all = [];
|
|
306
|
+
walk(tree, (n) => { if (n.props && n.props.className === "dru-legal-a" && typeof n.props.href === "string") all.push(n); });
|
|
307
|
+
assert.ok(all.length >= 2, `应至少两处(账号卡 + 双路块),实际 ${all.length}`);
|
|
308
|
+
for (const a of all) assert.equal(a.props.href, AGREEMENT_ABS, "同一页面各处 URL 必须一致");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("⑥ 未登录 → 不展示协议链接、也不请求协议地址(购买入口不存在时不该出现)", async () => {
|
|
312
|
+
const plugin = loadPlugin({
|
|
313
|
+
status: {
|
|
314
|
+
ok: true,
|
|
315
|
+
config: { phone: "", hasPhone: false, mode: "saas", deviceId: "dev-x" },
|
|
316
|
+
service: { running: false },
|
|
317
|
+
remoteUrl: "https://relay.test/app/",
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
const tree = await plugin.settle(4);
|
|
321
|
+
assert.equal(agreementLink(tree), undefined, "未登录没有购买入口,自然不该出现协议链接");
|
|
322
|
+
assert.equal(plugin.agreementRequests().length, 0, "未登录不该请求协议地址");
|
|
323
|
+
});
|