@mrrisega/dsh-remote 0.7.6-beta.1 → 0.7.6-beta.3
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 +641 -49
- package/clients/dsh-remote/e2ee-client.mjs +256 -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 +251 -0
- package/clients/dsh-remote/test/mobile-adapter-single-page.test.mjs +328 -0
- package/clients/dsh-remote/test/pending-plugin-update.test.mjs +96 -0
- package/dsh-setup.mjs +51 -6
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +137 -6
- package/packages/dsh-remote-web/lib/index.js +172 -2
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +641 -49
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +256 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +261 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +51 -6
- 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/desktop-compat.test.mjs +7 -5
- package/packages/dsh-remote-web/test/purchase-agreement-proxy.test.mjs +198 -0
- package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +336 -0
- package/packages/dsh-remote-web/test/setup-output.test.mjs +4 -1
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// 📄 购买协议**不在插件面板展示** —— **浏览器半**(lib/client.js 面板)行为回归。
|
|
2
|
+
//
|
|
3
|
+
// 需求变更(业主 2026-10-24):面板的续费/购买按钮下方**不要**再出现
|
|
4
|
+
// 「购买即表示同意《会员服务购买协议》」这句话 —— 协议只在**手机端支付扫码前**(支付页)展示。
|
|
5
|
+
// 上一轮(task-17)在 client.js 里加的两处链接(账号卡购买按钮下方、连接偏慢双路块升级路旁)
|
|
6
|
+
// 已连同相关常量/渲染函数/样式一并删除;node 半 `GET /dsh-remote/purchase-agreement` 代理**保留**
|
|
7
|
+
// (手机端/其它端后续可复用,其 URL 优先级/安全过滤/降级契约见 purchase-agreement-proxy.test.mjs)。
|
|
8
|
+
//
|
|
9
|
+
// 本文件锁「不该出现」这件事(四条路径都要覆盖):
|
|
10
|
+
// ① 免费用户账号卡:升级 PRO 按钮在,但协议文案/链接/旧样式类都不在,也不再请求协议地址;
|
|
11
|
+
// ② 会员账号卡:续费会员按钮下同样没有这句协议文案(业主点名的位置);
|
|
12
|
+
// ③ 连接偏慢双路块:升级(购买)路旁没有协议链接;
|
|
13
|
+
// ④ 未登录:本来就没有购买入口,也不该有任何协议痕迹/请求。
|
|
14
|
+
// ⑤ 源码级回归锁:client.js 里不得再残留协议渲染痕迹(防止以后又被"顺手加回来")。
|
|
15
|
+
//
|
|
16
|
+
// 做法与 community-invite-ui.test.mjs 一致:真脚本 + vm + 迷你 React(真实 useEffect)。
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import test from "node:test";
|
|
20
|
+
import vm from "node:vm";
|
|
21
|
+
|
|
22
|
+
const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
23
|
+
// 复制自 community-invite-ui 的迷你 React harness 里,交流群/弹层接口仍有默认夹具,统一给一个假二维码地址。
|
|
24
|
+
const QR_ABS = "https://relay.test/relay-api/qr/group-qr.png";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 面板树里所有「购买协议」痕迹:协议文案 / 协议链接 / 旧样式类。
|
|
28
|
+
* 期望结果恒为空数组 —— 这正是业主这次要的"面板上不再出现这句话"。
|
|
29
|
+
*/
|
|
30
|
+
function agreementTraces(tree) {
|
|
31
|
+
const out = [];
|
|
32
|
+
walk(tree, (n) => {
|
|
33
|
+
if (!n || typeof n !== "object") return;
|
|
34
|
+
const cls = String((n.props && n.props.className) || "");
|
|
35
|
+
if (cls === "dru-legal" || cls === "dru-legal-a" || cls.startsWith("dru-legal ")) out.push({ kind: "class", node: n });
|
|
36
|
+
if (n.type === "a" && n.props && /purchase-agreement/i.test(String(n.props.href || ""))) out.push({ kind: "href", node: n });
|
|
37
|
+
for (const c of n.children || []) {
|
|
38
|
+
if (typeof c !== "string") continue;
|
|
39
|
+
if (/购买协议|会员服务购买协议|购买即表示同意/.test(c)) out.push({ kind: "text", text: c });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/** 断言整棵面板树里没有任何协议痕迹(失败时把命中项打印出来,便于定位是谁加回来的)。 */
|
|
45
|
+
function assertNoAgreementInPanel(tree) {
|
|
46
|
+
const hits = agreementTraces(tree);
|
|
47
|
+
assert.equal(hits.length, 0, `面板里不得再出现购买协议文案/链接,实际命中: ${JSON.stringify(hits.slice(0, 4))}`);
|
|
48
|
+
assert.equal(textHas(tree, "购买即表示同意"), false, "业主明确要求:不要「购买即表示同意…」这句话");
|
|
49
|
+
assert.equal(textHas(tree, "会员服务购买协议"), false);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function walk(node, visit) {
|
|
53
|
+
if (node == null) return;
|
|
54
|
+
if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
|
|
55
|
+
if (typeof node !== "object") return;
|
|
56
|
+
visit(node);
|
|
57
|
+
for (const child of node.children || []) walk(child, visit);
|
|
58
|
+
}
|
|
59
|
+
function find(tree, predicate) {
|
|
60
|
+
let match;
|
|
61
|
+
walk(tree, (node) => { if (!match && predicate(node)) match = node; });
|
|
62
|
+
return match;
|
|
63
|
+
}
|
|
64
|
+
function textHas(tree, substr) {
|
|
65
|
+
return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
66
|
+
}
|
|
67
|
+
function nodeWithText(tree, substr) {
|
|
68
|
+
return find(tree, (node) => typeof node.props?.onClick === "function" && (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
69
|
+
}
|
|
70
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 迷你 React(真实 useEffect)+ 假定时器;fetch 覆盖 /dsh-remote/* 面板接口。
|
|
74
|
+
* @param {object} opts - community:/dsh-remote/community 的返回值(默认已配置二维码)
|
|
75
|
+
*/
|
|
76
|
+
function loadPlugin(opts = {}) {
|
|
77
|
+
let moduleFactory;
|
|
78
|
+
const registered = new Map();
|
|
79
|
+
const injects = new Map();
|
|
80
|
+
const requests = [];
|
|
81
|
+
|
|
82
|
+
let clock = 0;
|
|
83
|
+
let timerSeq = 1;
|
|
84
|
+
const timers = new Map();
|
|
85
|
+
const job = (fn) => { try { fn(); } catch (e) { /* 断言会暴露问题 */ } };
|
|
86
|
+
const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
|
|
87
|
+
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; };
|
|
88
|
+
const fakeClear = (id) => { timers.delete(id); };
|
|
89
|
+
|
|
90
|
+
// ── 迷你 React:真实 useState/useEffect + 递归渲染函数组件(FeedbackCard 等嵌套组件) ──
|
|
91
|
+
// 每个组件实例按「树中位置路径」持有自己的 hook 槽位(近似 React 的位置化协调),故子组件状态
|
|
92
|
+
// 可跨渲染保持;effects 在对应子树展开后执行(cleanup 语义与 React 一致)。
|
|
93
|
+
const scopes = new Map(); // key -> { hook, effectCursor, slots, pending }
|
|
94
|
+
const scopeStack = [];
|
|
95
|
+
const currentScope = () => scopeStack[scopeStack.length - 1];
|
|
96
|
+
|
|
97
|
+
const react = {
|
|
98
|
+
createElement(type, props, ...children) { return { type, props: props || {}, children }; },
|
|
99
|
+
useState(initial) {
|
|
100
|
+
const sc = currentScope();
|
|
101
|
+
const index = sc.hook++;
|
|
102
|
+
if (!(index in sc.state)) sc.state[index] = typeof initial === "function" ? initial() : initial;
|
|
103
|
+
return [sc.state[index], (value) => { sc.state[index] = typeof value === "function" ? value(sc.state[index]) : value; }];
|
|
104
|
+
},
|
|
105
|
+
useEffect(fn, deps) {
|
|
106
|
+
const sc = currentScope();
|
|
107
|
+
const index = sc.effectCursor++;
|
|
108
|
+
const prev = sc.effects[index];
|
|
109
|
+
const changed = !prev || !deps || deps.length !== prev.deps.length || deps.some((d, i) => !Object.is(d, prev.deps[i]));
|
|
110
|
+
if (changed) sc.pending.push({ index, fn, deps: deps ? [...deps] : deps });
|
|
111
|
+
},
|
|
112
|
+
useCallback(fn) { return fn; },
|
|
113
|
+
useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** 渲染一棵树:函数组件递归展开(含 effects),普通节点保留并递归子节点。 */
|
|
117
|
+
function renderNode(node, path) {
|
|
118
|
+
if (node == null) return node;
|
|
119
|
+
if (Array.isArray(node)) return node.map((n, i) => renderNode(n, path + "/" + i));
|
|
120
|
+
if (typeof node !== "object") return node;
|
|
121
|
+
if (typeof node.type === "function") {
|
|
122
|
+
const name = node.type.name || "anon";
|
|
123
|
+
const key = path + ":" + name;
|
|
124
|
+
const sc = scopes.get(key) || { hook: 0, effectCursor: 0, state: [], effects: [], pending: [] };
|
|
125
|
+
sc.hook = 0; sc.effectCursor = 0; sc.pending = [];
|
|
126
|
+
scopes.set(key, sc);
|
|
127
|
+
scopeStack.push(sc);
|
|
128
|
+
let out;
|
|
129
|
+
try {
|
|
130
|
+
out = node.type(node.props || {});
|
|
131
|
+
} finally {
|
|
132
|
+
scopeStack.pop();
|
|
133
|
+
}
|
|
134
|
+
const expanded = renderNode(out, key);
|
|
135
|
+
const todos = sc.pending;
|
|
136
|
+
sc.pending = [];
|
|
137
|
+
for (const t of todos) {
|
|
138
|
+
const prev = sc.effects[t.index];
|
|
139
|
+
if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
|
|
140
|
+
const cleanup = t.fn();
|
|
141
|
+
sc.effects[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
|
|
142
|
+
}
|
|
143
|
+
return expanded;
|
|
144
|
+
}
|
|
145
|
+
return { ...node, children: (node.children || []).map((c, i) => renderNode(c, path + "/" + i)) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const community = opts.community !== undefined ? opts.community : { ok: true, qrcode: QR_ABS, wechat: "dsh-remote-support" };
|
|
149
|
+
const status = opts.status || {
|
|
150
|
+
ok: true,
|
|
151
|
+
config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" },
|
|
152
|
+
service: { running: true },
|
|
153
|
+
remoteUrl: "https://relay.test/app/",
|
|
154
|
+
};
|
|
155
|
+
const response = (s, body) => Promise.resolve({ ok: s >= 200 && s < 300, status: s, text: async () => JSON.stringify(body) });
|
|
156
|
+
|
|
157
|
+
const makeEl = () => ({
|
|
158
|
+
tag: "div", children: [], style: {}, className: "", attributes: {}, textContent: "",
|
|
159
|
+
setAttribute(k, v) { this.attributes[k] = v; },
|
|
160
|
+
getAttribute(k) { return this.attributes[k] ?? null; },
|
|
161
|
+
appendChild(c) { this.children.push(c); },
|
|
162
|
+
addEventListener() {}, removeEventListener() {},
|
|
163
|
+
});
|
|
164
|
+
const doc = {
|
|
165
|
+
hidden: false,
|
|
166
|
+
createElement: makeEl,
|
|
167
|
+
head: makeEl(),
|
|
168
|
+
body: makeEl(),
|
|
169
|
+
querySelector() { return null; },
|
|
170
|
+
querySelectorAll() { return []; },
|
|
171
|
+
getElementById() { return null; },
|
|
172
|
+
addEventListener() {}, removeEventListener() {},
|
|
173
|
+
};
|
|
174
|
+
class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
|
|
175
|
+
const localStorage = {
|
|
176
|
+
_s: new Map(),
|
|
177
|
+
getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
|
|
178
|
+
setItem(k, v) { this._s.set(k, String(v)); },
|
|
179
|
+
removeItem(k) { this._s.delete(k); },
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const sessionStorage = {
|
|
183
|
+
_s: new Map(),
|
|
184
|
+
getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
|
|
185
|
+
setItem(k, v) { this._s.set(k, String(v)); },
|
|
186
|
+
removeItem(k) { this._s.delete(k); },
|
|
187
|
+
};
|
|
188
|
+
const sandbox = {
|
|
189
|
+
window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
|
|
190
|
+
document: doc,
|
|
191
|
+
localStorage,
|
|
192
|
+
sessionStorage,
|
|
193
|
+
location: { origin: "https://127.0.0.1:3080", href: "https://127.0.0.1:3080/" },
|
|
194
|
+
MutationObserver: MutationObserverMock,
|
|
195
|
+
navigator: { clipboard: { writeText: async () => {} } },
|
|
196
|
+
setTimeout: fakeSetTimeout,
|
|
197
|
+
clearTimeout: fakeClear,
|
|
198
|
+
setInterval: fakeSetInterval,
|
|
199
|
+
clearInterval: fakeClear,
|
|
200
|
+
fetch(path, options = {}) {
|
|
201
|
+
requests.push({ path, method: options.method || "GET" });
|
|
202
|
+
if (path === "/dsh-remote/status") return response(200, status);
|
|
203
|
+
if (path === "/dsh-remote/account") return response(200, opts.account !== undefined ? { ok: true, account: opts.account } : { ok: true });
|
|
204
|
+
if (path === "/dsh-remote/community") return response(200, community);
|
|
205
|
+
if (path === "/dsh-remote/community-invite/status") {
|
|
206
|
+
if (opts.inviteReject) return Promise.reject(new Error("network down"));
|
|
207
|
+
if (opts.inviteStatus && opts.inviteStatus !== 200) return response(opts.inviteStatus, { ok: false, error: "boom" });
|
|
208
|
+
return response(200, opts.invite !== undefined ? opts.invite : {
|
|
209
|
+
ok: true, show: true, reason: "ok", used_seconds: 7300, required_seconds: 7200, qr_url: QR_ABS,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (path === "/dsh-remote/community-invite/shown") {
|
|
213
|
+
if (opts.inviteShownFail) return response(500, { ok: false, error: "report failed" });
|
|
214
|
+
return response(200, { ok: true });
|
|
215
|
+
}
|
|
216
|
+
if (path === "/dsh-remote/purchase-agreement") {
|
|
217
|
+
if (opts.agreementReject) return Promise.reject(new Error("public-config down"));
|
|
218
|
+
if (opts.agreementStatus && opts.agreementStatus !== 200) return response(opts.agreementStatus, { ok: false });
|
|
219
|
+
return response(200, opts.agreement !== undefined ? opts.agreement : { ok: true, url: AGREEMENT_ABS });
|
|
220
|
+
}
|
|
221
|
+
return response(200, { ok: true });
|
|
222
|
+
},
|
|
223
|
+
Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
vm.runInNewContext(SOURCE, sandbox);
|
|
227
|
+
const plugin = moduleFactory((name) => {
|
|
228
|
+
assert.equal(name, "react");
|
|
229
|
+
return react;
|
|
230
|
+
});
|
|
231
|
+
plugin.apply({
|
|
232
|
+
slots: {
|
|
233
|
+
inject(name, cb) { injects.set(name, cb); cb(); },
|
|
234
|
+
register(meta, component) { registered.set(meta.id, component); return () => {}; },
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
requests,
|
|
240
|
+
sessionStorage,
|
|
241
|
+
statusRequests: () => requests.filter((r) => r.path === "/dsh-remote/community-invite/status"),
|
|
242
|
+
agreementRequests: () => requests.filter((r) => r.path === "/dsh-remote/purchase-agreement"),
|
|
243
|
+
shownRequests: () => requests.filter((r) => r.path === "/dsh-remote/community-invite/shown"),
|
|
244
|
+
/** 渲染 → 让在途请求落地 → 再渲染(返回最新树)。 */
|
|
245
|
+
async settle(times = 2) {
|
|
246
|
+
let tree = null;
|
|
247
|
+
for (let i = 0; i < times; i++) { tree = this.render(); await flush(); await flush(); }
|
|
248
|
+
return this.render();
|
|
249
|
+
},
|
|
250
|
+
render() {
|
|
251
|
+
const root = { type: registered.get("dsh-remote"), props: { close() {} }, children: [] };
|
|
252
|
+
return renderNode(root, "root");
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
test("① 免费用户账号卡:升级 PRO 按钮仍在,但协议文案/链接/旧样式全都不再出现,也不再请求协议地址", async () => {
|
|
259
|
+
const plugin = loadPlugin();
|
|
260
|
+
const tree = await plugin.settle(4);
|
|
261
|
+
// 购买入口(升级按钮)必须还在 —— 这次只删协议文案,不动购买能力
|
|
262
|
+
assert.ok(nodeWithText(tree, "🚀 升级 PRO"), "免费用户的「🚀 升级 PRO」按钮必须保留");
|
|
263
|
+
// 协议痕迹一个都不能有
|
|
264
|
+
assertNoAgreementInPanel(tree);
|
|
265
|
+
// 客户端也不再为了这句话去问 node 半(面板里已经没有消费者了)
|
|
266
|
+
assert.equal(plugin.agreementRequests().length, 0, "面板已不再展示协议,就不该再请求 /dsh-remote/purchase-agreement");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("② 会员账号卡(业主点名场景):续费会员按钮下方不再有「购买即表示同意…」", async () => {
|
|
270
|
+
const plugin = loadPlugin({
|
|
271
|
+
account: { plan: "pro", plan_source: "subscription", plan_ends_at: 1893456000000, invite_code: "ABC12345" },
|
|
272
|
+
});
|
|
273
|
+
const tree = await plugin.settle(4);
|
|
274
|
+
assert.ok(nodeWithText(tree, "🔄 续费会员"), "前提:会员的续费入口在(这正是业主看到那句话的位置)");
|
|
275
|
+
assertNoAgreementInPanel(tree);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("③ 连接偏慢双路块:升级(购买)路旁也没有协议链接,协议地址零请求", async () => {
|
|
279
|
+
const plugin = loadPlugin({
|
|
280
|
+
status: {
|
|
281
|
+
ok: true,
|
|
282
|
+
config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" },
|
|
283
|
+
service: { running: true },
|
|
284
|
+
// 触发 connSlow:服务端已自动重试 ≥3 次(见 client.js connSlow 判定)
|
|
285
|
+
connect: { phase: "connecting", attempts: 3, text: "正在自动重试连接" },
|
|
286
|
+
remoteUrl: "https://relay.test/app/",
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
const tree = await plugin.settle(4);
|
|
290
|
+
const dual = find(tree, (n) => n.props && n.props.className === "dru-dual");
|
|
291
|
+
assert.ok(dual, "前提:慢连接时双路块应出现(购买路仍在)");
|
|
292
|
+
assert.equal(find(dual, (n) => n.props && n.props.className === "dru-legal-a"), undefined,
|
|
293
|
+
"双路块的购买路旁不得再有协议链接");
|
|
294
|
+
assertNoAgreementInPanel(tree);
|
|
295
|
+
assert.equal(plugin.agreementRequests().length, 0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("④ 未登录:没有购买入口,也没有任何协议痕迹/请求", async () => {
|
|
299
|
+
const plugin = loadPlugin({
|
|
300
|
+
status: {
|
|
301
|
+
ok: true,
|
|
302
|
+
config: { phone: "", hasPhone: false, mode: "saas", deviceId: "dev-x" },
|
|
303
|
+
service: { running: false },
|
|
304
|
+
remoteUrl: "https://relay.test/app/",
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
const tree = await plugin.settle(4);
|
|
308
|
+
assert.equal(nodeWithText(tree, "🚀 升级 PRO"), undefined, "未登录没有购买入口");
|
|
309
|
+
assertNoAgreementInPanel(tree);
|
|
310
|
+
assert.equal(plugin.agreementRequests().length, 0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("⑤ 源码级回归锁:client.js 里不得再残留协议渲染痕迹(node 半代理保留)", async () => {
|
|
314
|
+
const { readFileSync: rf } = await import("node:fs");
|
|
315
|
+
const src = rf(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
316
|
+
for (const sym of ["dru-legal", "renderAgreementLink", "购买即表示同意", "会员服务购买协议", "PURCHASE_AGREEMENT_FALLBACK", "loadPurchaseAgreementUrl"]) {
|
|
317
|
+
assert.equal(src.includes(sym), false, `client.js 不得再出现「${sym}」——面板不再展示购买协议(协议只在手机端支付页展示)`);
|
|
318
|
+
}
|
|
319
|
+
// node 半的代理仍在(不要顺手删掉:手机端/其它端后续可复用,且 proxy 用例锁着它的契约)
|
|
320
|
+
const idx = rf(new URL("../lib/index.js", import.meta.url), "utf8");
|
|
321
|
+
assert.ok(idx.includes('path: "/dsh-remote/purchase-agreement"'), "node 半 /dsh-remote/purchase-agreement 代理必须保留");
|
|
322
|
+
assert.ok(SOURCE.length > 0);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("⑥ 只删文案、不动购买能力:点「升级 PRO」仍走既有购买流程(请求 /dsh-remote/access-key)", async () => {
|
|
326
|
+
const plugin = loadPlugin();
|
|
327
|
+
let tree = await plugin.settle(4);
|
|
328
|
+
const btn = nodeWithText(tree, "🚀 升级 PRO");
|
|
329
|
+
assert.ok(btn, "升级按钮必须还在");
|
|
330
|
+
btn.props.onClick();
|
|
331
|
+
await plugin.settle(2);
|
|
332
|
+
assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/access-key"),
|
|
333
|
+
"购买入口仍走既有 openUpgradeAuth(生成一次性访问链接 → 打开套餐页);删的只是协议文案");
|
|
334
|
+
tree = plugin.render();
|
|
335
|
+
assertNoAgreementInPanel(tree);
|
|
336
|
+
});
|
|
@@ -240,7 +240,10 @@ test("★ 桌面应用在运行时,绝不改写它的 profile(否则应用
|
|
|
240
240
|
const applyAt = src.indexOf("await pluginApplyTo(", loopAt);
|
|
241
241
|
assert.ok(loopAt > 0 && guardAt > loopAt && applyAt > guardAt,
|
|
242
242
|
"顺序必须是:循环 → 应用在运行?→ 跳过;否则才 pluginApplyTo()");
|
|
243
|
-
|
|
243
|
+
// 2026-10-24:应用在运行时不再只是"跳过",而是把插件更新**暂存**,由 bridge 在应用退出后自动安装。
|
|
244
|
+
assert.match(src, /stagePluginUpdateForProfile\(/, "应用在运行时必须暂存插件更新(否则一键更新永远装不上桌面端)");
|
|
245
|
+
assert.match(src, /\.plugin-update-pending\.json/, "暂存要写 marker 供 bridge 落地");
|
|
246
|
+
assert.match(src, /退出 DeepSeek Harness 后会自动安装/, "要如实告诉用户:退出应用后自动安装、重开生效");
|
|
244
247
|
assert.match(src, /应用内「插件」页安装/, "要给出官方替代路径(应用内插件页)");
|
|
245
248
|
assert.match(src, /"--force"/, "要留 --force 逃生口");
|
|
246
249
|
// sync-local 脚本同样要保护(本机同步最常用)
|