@mrrisega/dsh-remote 0.7.0 → 0.7.1-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/mobile-adapter.mjs +273 -22
- package/clients/dsh-remote/test/mobile-adapter-settings.test.mjs +352 -0
- package/clients/dsh-remote/test/mobile-adapter.test.mjs +35 -0
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/index.js +337 -16
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +273 -22
- package/packages/dsh-remote-web/test/selfheal-restart-guard.test.mjs +420 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mobile-adapter 设置面板窄屏适配回归(2026-10-13 新增)。
|
|
4
|
+
*
|
|
5
|
+
* 背景(用户反馈 + 真机 390×844 实测):
|
|
6
|
+
* 「手机端访问设置界面布局挤到一条竖线上」——官方设置弹层是**桌面两栏**
|
|
7
|
+
* (0.2.0-rc.2 实测:panel 342×796、nav 188 宽竖排、content 只剩 154px 可视宽)。
|
|
8
|
+
* 适配层此前只挂 [class~="VOzbGW_*"](0.1.2–0.1.5 的混淆前缀),
|
|
9
|
+
* 0.2.0-rc.2 重新打包后前缀变成 Dws9Sa_* → **整段 CSS 静默失效**。
|
|
10
|
+
* 用户要求的目标形态:**tab 放顶部(横排)+ 下方内容区满宽 + 弹窗按手机尺寸收敛**。
|
|
11
|
+
*
|
|
12
|
+
* 本文件锁三件事(都是"下次不会再静默坏掉"的判据):
|
|
13
|
+
* ① CSS 契约:三层识别(已知前缀 / 结构稳定类 / :has 兜底)、面板竖排、nav 横排置顶、
|
|
14
|
+
* content 满宽、通用弹层 100vw-24px + max-height + 内部滚动、规则顺序(通用弹层在前);
|
|
15
|
+
* ② 结构兜底**真的能跑**:把官方类名全部换成未知前缀(模拟下次改名),在假 DOM 上仍然
|
|
16
|
+
* 认出面板并打上 dsh-ma-settings-* 稳定类;
|
|
17
|
+
* ③ 不回归:宽屏(>820)零动作、只给**已有**节点打类(不新建任何覆盖层节点)、
|
|
18
|
+
* 认不出结构时只留一条可见 console.warn(护栏禁止全屏覆盖层)。
|
|
19
|
+
*/
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import test from "node:test";
|
|
22
|
+
import vm from "node:vm";
|
|
23
|
+
import { injectMobileAdapter } from "../mobile-adapter.mjs";
|
|
24
|
+
|
|
25
|
+
/** 取注入结果里的真实 <script> / <style> 内容(不加壳,原样执行)。 */
|
|
26
|
+
function injected() {
|
|
27
|
+
const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
|
|
28
|
+
const js = /<script id="[^"]*-js"[^>]*>([\s\S]*?)<\/script>/.exec(html);
|
|
29
|
+
const css = /<style id="[^"]*-css"[^>]*>([\s\S]*?)<\/style>/.exec(html);
|
|
30
|
+
assert.ok(js && css, "应能取出注入脚本与样式");
|
|
31
|
+
return { js: js[1], css: css[1] };
|
|
32
|
+
}
|
|
33
|
+
const { js: ADAPTER_JS, css: ADAPTER_CSS } = injected();
|
|
34
|
+
|
|
35
|
+
/** 只取 ≤820px 媒体块(新规则必须全部落在里面,这才谈得上"桌面零影响")。 */
|
|
36
|
+
function narrowCss() {
|
|
37
|
+
const start = ADAPTER_CSS.indexOf("@media (max-width: 820px)");
|
|
38
|
+
assert.ok(start >= 0, "应有窄屏媒体查询");
|
|
39
|
+
let depth = 0, i = ADAPTER_CSS.indexOf("{", start);
|
|
40
|
+
const from = i + 1;
|
|
41
|
+
for (; i < ADAPTER_CSS.length; i++) {
|
|
42
|
+
if (ADAPTER_CSS[i] === "{") depth++;
|
|
43
|
+
else if (ADAPTER_CSS[i] === "}") { depth--; if (depth === 0) break; }
|
|
44
|
+
}
|
|
45
|
+
return ADAPTER_CSS.slice(from, i);
|
|
46
|
+
}
|
|
47
|
+
const NARROW_CSS = narrowCss();
|
|
48
|
+
|
|
49
|
+
// ── 极简假 DOM(只实现本用例需要的那些面) ─────────────────────────────────────
|
|
50
|
+
/** 支持 `tag`、`.cls`、`[attr="v"]`、`[attr]` 与其逗号组合;不支持 `~=`(脚本里没用到)。 */
|
|
51
|
+
function matchesSel(el, sel) {
|
|
52
|
+
return String(sel).split(",").some((one) => {
|
|
53
|
+
const s = one.trim();
|
|
54
|
+
if (!s) return false;
|
|
55
|
+
const m = /^([a-zA-Z][\w-]*)?((?:\.[\w-]+)*)((?:\[[^\]]+\])*)$/.exec(s);
|
|
56
|
+
if (!m) return false;
|
|
57
|
+
if (m[1] && el.tagName !== m[1].toUpperCase()) return false;
|
|
58
|
+
for (const c of (m[2].match(/\.[\w-]+/g) || [])) if (!el._cls.has(c.slice(1))) return false;
|
|
59
|
+
for (const a of (m[3].match(/\[[^\]]+\]/g) || [])) {
|
|
60
|
+
const body = a.slice(1, -1);
|
|
61
|
+
const eq = /^([\w-]+)(?:="([^"]*)")?$/.exec(body);
|
|
62
|
+
if (!eq) return false;
|
|
63
|
+
if (!(eq[1] in el.attrs)) return false;
|
|
64
|
+
if (eq[2] !== undefined && String(el.attrs[eq[1]]).toLowerCase() !== eq[2].toLowerCase()) return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
class El {
|
|
71
|
+
constructor(tag = "div", attrs = {}) {
|
|
72
|
+
this.tagName = String(tag).toUpperCase();
|
|
73
|
+
this.attrs = { ...attrs };
|
|
74
|
+
this.children = [];
|
|
75
|
+
this.parent = null;
|
|
76
|
+
this._cls = new Set(String(attrs.class || "").split(/\s+/).filter(Boolean));
|
|
77
|
+
this._rect = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 };
|
|
78
|
+
this._text = "";
|
|
79
|
+
this.scrolled = 0;
|
|
80
|
+
this.createdBy = null;
|
|
81
|
+
this.classList = {
|
|
82
|
+
add: (...cs) => cs.forEach((c) => this._cls.add(c)),
|
|
83
|
+
remove: (...cs) => cs.forEach((c) => this._cls.delete(c)),
|
|
84
|
+
contains: (c) => this._cls.has(c),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
get className() { return [...this._cls].join(" "); }
|
|
88
|
+
set className(v) { this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); }
|
|
89
|
+
get textContent() { return this._text; }
|
|
90
|
+
set textContent(v) { this._text = String(v); }
|
|
91
|
+
getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; }
|
|
92
|
+
setAttribute(n, v) { this.attrs[n] = String(v); }
|
|
93
|
+
hasAttribute(n) { return n in this.attrs; }
|
|
94
|
+
removeAttribute(n) { delete this.attrs[n]; }
|
|
95
|
+
appendChild(c) { c.parent = this; this.children.push(c); return c; }
|
|
96
|
+
removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parent = null; return c; }
|
|
97
|
+
getBoundingClientRect() { return this._rect; }
|
|
98
|
+
get parentElement() { return this.parent || null; }
|
|
99
|
+
get isConnected() { let n = this; while (n.parent) n = n.parent; return n._isRoot === true; }
|
|
100
|
+
matches(sel) { return matchesSel(this, sel); }
|
|
101
|
+
closest(sel) { let n = this; while (n) { if (n.matches(sel)) return n; n = n.parent; } return null; }
|
|
102
|
+
contains(n) { while (n) { if (n === this) return true; n = n.parent; } return false; }
|
|
103
|
+
descendants() { return this.children.flatMap((c) => [c, ...c.descendants()]); }
|
|
104
|
+
querySelector(sel) { return this.descendants().find((e) => e.matches(sel)) || null; }
|
|
105
|
+
querySelectorAll(sel) { return this.descendants().filter((e) => e.matches(sel)); }
|
|
106
|
+
addEventListener() {}
|
|
107
|
+
removeEventListener() {}
|
|
108
|
+
remove() { if (this.parent) this.parent.removeChild(this); }
|
|
109
|
+
scrollIntoView() { this.scrolled++; }
|
|
110
|
+
focus() {}
|
|
111
|
+
click() {}
|
|
112
|
+
setPointerCapture() {}
|
|
113
|
+
prepend() {}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 建一个"官方设置弹层"盒子:prefix 就是官方混淆前缀(改成未知前缀 = 模拟下次改名)。 */
|
|
117
|
+
function settingsBox(prefix, { width = 390, height = 844, withNav = true, navTag = "nav" } = {}) {
|
|
118
|
+
const rect = (l, t, w, h) => ({ left: l, top: t, width: w, height: h, right: l + w, bottom: t + h });
|
|
119
|
+
const overlay = new El("div", { class: prefix + "overlay", role: "presentation" });
|
|
120
|
+
overlay._rect = rect(0, 0, width, height);
|
|
121
|
+
const mask = new El("div", { class: prefix + "mask" });
|
|
122
|
+
const panel = new El("div", { class: prefix + "panel", role: "dialog", "aria-modal": "true" });
|
|
123
|
+
// 真实 0.2 实测:390 宽下 panel 342×796(桌面两栏),nav 188 宽竖排,content 只剩 154
|
|
124
|
+
panel._rect = rect(24, 24, Math.min(800, width - 48), height - 48);
|
|
125
|
+
const content = new El("div", { class: prefix + "content" });
|
|
126
|
+
content._rect = rect(212, 24, 154, height - 48);
|
|
127
|
+
const header = new El("div", { class: prefix + "header" });
|
|
128
|
+
const options = new El("div", { class: prefix + "options" });
|
|
129
|
+
content.appendChild(header); content.appendChild(options);
|
|
130
|
+
panel.appendChild(content);
|
|
131
|
+
const nodes = [overlay, panel, content, header, options];
|
|
132
|
+
if (withNav) {
|
|
133
|
+
const n = new El(navTag, { class: prefix + "nav" });
|
|
134
|
+
n._rect = rect(24, 24, 188, height - 48);
|
|
135
|
+
const navTitle = new El("div", { class: prefix + "navTitle" });
|
|
136
|
+
navTitle.textContent = "设置";
|
|
137
|
+
const navList = new El("div", { class: prefix + "navList" });
|
|
138
|
+
for (const [i, label] of ["通用设置", "模型", "内置插件"].entries()) {
|
|
139
|
+
const cell = new El("button", { class: prefix + "navCell" + (i === 1 ? " " + prefix + "active" : "") });
|
|
140
|
+
cell.textContent = label;
|
|
141
|
+
if (i === 1) cell.setAttribute("aria-current", "true");
|
|
142
|
+
navList.appendChild(cell);
|
|
143
|
+
nodes.push(cell);
|
|
144
|
+
}
|
|
145
|
+
n.appendChild(navTitle); n.appendChild(navList);
|
|
146
|
+
panel.children.unshift(n);
|
|
147
|
+
n.parent = panel;
|
|
148
|
+
nodes.push(n, navTitle, navList);
|
|
149
|
+
}
|
|
150
|
+
overlay.appendChild(mask); overlay.appendChild(panel);
|
|
151
|
+
return { overlay, panel, content, nodes };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 搭假环境:document/window 够跑注入脚本,且能捕获 console.warn 与 pump 定时器。 */
|
|
155
|
+
function harness({ prefix = "Dws9Sa_", width = 390, height = 844, withNav = true, navTag = "nav", withDialog = true } = {}) {
|
|
156
|
+
const html = new El("html");
|
|
157
|
+
html._isRoot = true;
|
|
158
|
+
// HOSTISH() 读 documentElement.outerHTML 找官方特征
|
|
159
|
+
html.outerHTML = '<html><head><script>window.__ModuleLoader__ = { mode: "queue" }</script>'
|
|
160
|
+
+ "<title>DeepSeek Harness</title></head><body></body></html>";
|
|
161
|
+
const body = new El("body");
|
|
162
|
+
body.parent = html;
|
|
163
|
+
const box = withDialog ? settingsBox(prefix, { width, height, withNav, navTag }) : null;
|
|
164
|
+
if (box) { body.appendChild(box.overlay); }
|
|
165
|
+
// 记录"脚本自己新建"的节点(用来断言我们没有凭空造覆盖层/面板)
|
|
166
|
+
const created = [];
|
|
167
|
+
|
|
168
|
+
const doc = {
|
|
169
|
+
documentElement: html,
|
|
170
|
+
body,
|
|
171
|
+
readyState: "complete",
|
|
172
|
+
title: "DeepSeek Harness",
|
|
173
|
+
addEventListener() {},
|
|
174
|
+
removeEventListener() {},
|
|
175
|
+
createElement(tag) { const el = new El(tag); el.createdBy = "adapter"; created.push(el); return el; },
|
|
176
|
+
createTextNode: (t) => ({ nodeValue: t, createdBy: "adapter" }),
|
|
177
|
+
getElementById: () => null,
|
|
178
|
+
querySelector: (sel) => body.querySelector(sel),
|
|
179
|
+
querySelectorAll: (sel) => body.querySelectorAll(sel),
|
|
180
|
+
};
|
|
181
|
+
const warns = [];
|
|
182
|
+
const intervals = [];
|
|
183
|
+
const timers = new Set();
|
|
184
|
+
const win = {
|
|
185
|
+
innerWidth: width,
|
|
186
|
+
innerHeight: height,
|
|
187
|
+
devicePixelRatio: 1,
|
|
188
|
+
localStorage: { _d: {}, getItem(k) { return k in this._d ? this._d[k] : null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; } },
|
|
189
|
+
addEventListener() {},
|
|
190
|
+
removeEventListener() {},
|
|
191
|
+
getComputedStyle: () => ({ getPropertyValue: () => "" }),
|
|
192
|
+
requestAnimationFrame: (fn) => { fn(); return 1; },
|
|
193
|
+
MutationObserver: class { observe() {} disconnect() {} },
|
|
194
|
+
ResizeObserver: class { observe() {} disconnect() {} },
|
|
195
|
+
setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); try { id.unref?.(); } catch { /* 忽略 */ } timers.add(id); return id; },
|
|
196
|
+
clearTimeout: (id) => { timers.delete(id); clearTimeout(id); },
|
|
197
|
+
setInterval: (fn) => { intervals.push(fn); return intervals.length; },
|
|
198
|
+
clearInterval() {},
|
|
199
|
+
fetch: undefined,
|
|
200
|
+
};
|
|
201
|
+
const loc = { href: "https://n.risegao.cn:13443/remote/dev-x/", host: "n.risegao.cn", pathname: "/remote/dev-x/", origin: "https://n.risegao.cn:13443" };
|
|
202
|
+
win.location = loc;
|
|
203
|
+
const consoleMock = { ...console, warn: (...a) => { warns.push(a.map(String).join(" ")); }, log: () => {}, error: () => {} };
|
|
204
|
+
win.console = consoleMock; // 适配层的诊断走 window.console(镜像页里就是页面控制台)
|
|
205
|
+
const ctx = vm.createContext({
|
|
206
|
+
window: win, document: doc, console: consoleMock,
|
|
207
|
+
setTimeout: win.setTimeout, clearTimeout: win.clearTimeout,
|
|
208
|
+
setInterval: win.setInterval, clearInterval: win.clearInterval,
|
|
209
|
+
MutationObserver: win.MutationObserver, ResizeObserver: win.ResizeObserver,
|
|
210
|
+
requestAnimationFrame: win.requestAnimationFrame,
|
|
211
|
+
navigator: { userAgent: "iPhone Safari", maxTouchPoints: 5, platform: "iPhone" },
|
|
212
|
+
location: loc, localStorage: win.localStorage,
|
|
213
|
+
URL, Date, Math, JSON, Object, Array, String, Number, RegExp, Boolean, Promise, Error, isNaN, parseInt, parseFloat,
|
|
214
|
+
HTMLElement: El,
|
|
215
|
+
});
|
|
216
|
+
ctx.globalThis = ctx;
|
|
217
|
+
ctx.self = win;
|
|
218
|
+
ctx.console = consoleMock;
|
|
219
|
+
return { ctx, doc, body, html, box, warns, intervals, created, win };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 跑注入脚本 + 主动推一拍 pump(脚本用 setInterval(pump, 1000) 兜底驱动)。 */
|
|
223
|
+
function runAdapter(env) {
|
|
224
|
+
vm.runInContext(ADAPTER_JS, env.ctx, { timeout: 5000 });
|
|
225
|
+
for (const fn of env.intervals) { try { fn(); } catch { /* 忽略 */ } }
|
|
226
|
+
return env;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const has = (el, cls) => el._cls.has(cls);
|
|
230
|
+
|
|
231
|
+
// ── ① CSS 契约 ───────────────────────────────────────────────────────────────
|
|
232
|
+
test("CSS:三层识别都在(已知前缀 0.1.5/0.2 + 结构稳定类 + :has 兜底)", () => {
|
|
233
|
+
for (const s of ['[class~="VOzbGW_overlay"]', '[class~="VOzbGW_panel"]', '[class~="VOzbGW_nav"]',
|
|
234
|
+
'[class~="Dws9Sa_overlay"]', '[class~="Dws9Sa_panel"]', '[class~="Dws9Sa_nav"]',
|
|
235
|
+
'[class~="Dws9Sa_navList"]', '[class~="Dws9Sa_navCell"]', '[class~="Dws9Sa_content"]',
|
|
236
|
+
'[class~="Dws9Sa_options"]', ".dsh-ma-settings-panel", ".dsh-ma-settings-nav",
|
|
237
|
+
".dsh-ma-settings-navlist", ".dsh-ma-settings-cell", ".dsh-ma-settings-content"]) {
|
|
238
|
+
assert.ok(NARROW_CSS.includes(s), `窄屏 CSS 应包含 ${s}`);
|
|
239
|
+
}
|
|
240
|
+
assert.match(NARROW_CSS, /div\[role="dialog"\]:has\(> nav\)/, ":has() 兜底应存在");
|
|
241
|
+
assert.match(NARROW_CSS, /div\[role="dialog"\] > nav\b/, "结构选择器(直接子 nav)应存在");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("CSS:窄屏 = 顶部横向 tab 行 + 下方内容满宽(nav 与 content 上下而非左右)", () => {
|
|
245
|
+
// 面板竖排
|
|
246
|
+
assert.match(NARROW_CSS, /\[class~="Dws9Sa_panel"\][^{}]*\{[^}]*flex-direction:\s*column\s*!important/,
|
|
247
|
+
"面板必须竖排(nav 在上、content 在下)");
|
|
248
|
+
assert.match(NARROW_CSS, /\[class~="Dws9Sa_panel"\][^{}]*\{[^}]*width:\s*100%\s*!important/);
|
|
249
|
+
// nav 横排 + 整行 + 可横滑
|
|
250
|
+
assert.match(NARROW_CSS, /\[class~="Dws9Sa_nav"\][^{}]*\{[^}]*flex-direction:\s*row\s*!important/,
|
|
251
|
+
"nav 必须横排");
|
|
252
|
+
assert.match(NARROW_CSS, /\[class~="Dws9Sa_nav"\][^{}]*\{[^}]*width:\s*100%\s*!important/);
|
|
253
|
+
assert.match(NARROW_CSS, /\[class~="Dws9Sa_navList"\][^{}]*\{[^}]*overflow-x:\s*auto/,
|
|
254
|
+
"tab 行要能横向滚动");
|
|
255
|
+
assert.match(NARROW_CSS, /\[class~="Dws9Sa_navCell"\][^{}]*\{[^}]*font-size:\s*13px\s*!important/,
|
|
256
|
+
"tab 字号 ≤13px");
|
|
257
|
+
// content 满宽 + 内部滚动
|
|
258
|
+
assert.match(NARROW_CSS, /\[class~="Dws9Sa_content"\][^{}]*\{[^}]*width:\s*100%\s*!important/);
|
|
259
|
+
assert.match(NARROW_CSS, /\[class~="Dws9Sa_options"\][^{}]*\{[^}]*overflow-y:\s*auto/);
|
|
260
|
+
// 安全区留白
|
|
261
|
+
assert.match(NARROW_CSS, /env\(safe-area-inset-bottom\)/);
|
|
262
|
+
assert.match(NARROW_CSS, /env\(safe-area-inset-top\)/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("CSS:弹层按手机尺寸收敛(100vw-24px / max-height / 内部滚动 / 圆角收敛)", () => {
|
|
266
|
+
// 官方 dialog/alertdialog/modal + 本项目 .dru-popup-card
|
|
267
|
+
assert.match(NARROW_CSS, /\[role="dialog"\][^{}]*\{[^}]*max-width:\s*calc\(100vw - 24px\)\s*!important/);
|
|
268
|
+
assert.match(NARROW_CSS, /\[role="alertdialog"\]/);
|
|
269
|
+
assert.match(NARROW_CSS, /\[aria-modal="true"\]/);
|
|
270
|
+
assert.match(NARROW_CSS, /\.dru-popup-card/);
|
|
271
|
+
assert.match(NARROW_CSS, /max-height:\s*calc\(100dvh - 24px\)\s*!important/);
|
|
272
|
+
assert.match(NARROW_CSS, /max-height:\s*calc\(100vh - 24px\)\s*!important/, "不认 dvh 的老浏览器要有 vh 兜底");
|
|
273
|
+
assert.match(NARROW_CSS, /border-radius:\s*12px\s*!important/);
|
|
274
|
+
assert.match(NARROW_CSS, /\.dru-popup\s*\{[^}]*padding:\s*12px\s*!important/);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("CSS:通用弹层规则必须排在设置弹层规则之前(否则设置面板被 24px 边距钉住)", () => {
|
|
278
|
+
const dialogAt = NARROW_CSS.indexOf('[role="dialog"]');
|
|
279
|
+
const panelAt = NARROW_CSS.indexOf('[class~="Dws9Sa_panel"]');
|
|
280
|
+
assert.ok(dialogAt >= 0 && panelAt >= 0);
|
|
281
|
+
assert.ok(dialogAt < panelAt, "通用弹层在前、设置面板在后(同特异性靠顺序决胜)");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("CSS:新增的弹层/设置规则全部落在 ≤820px 媒体块内(宽屏零影响)", () => {
|
|
285
|
+
for (const s of ['[role="dialog"]', ".dru-popup-card", '[class~="Dws9Sa_panel"]', ".dsh-ma-settings-panel"]) {
|
|
286
|
+
assert.ok(NARROW_CSS.includes(s), `${s} 必须在窄屏媒体块内`);
|
|
287
|
+
}
|
|
288
|
+
// 媒体块之外不得出现这些选择器(整体 <style> 里只能在媒体块出现一次)
|
|
289
|
+
const outside = ADAPTER_CSS.replace(NARROW_CSS, "");
|
|
290
|
+
for (const s of ['[class~="Dws9Sa_panel"]', ".dsh-ma-settings-panel", ".dru-popup-card"]) {
|
|
291
|
+
assert.ok(!outside.includes(s), `${s} 不得出现在窄屏媒体块之外`);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ── ② 结构兜底(模拟"官方再次改名") ───────────────────────────────────────────
|
|
296
|
+
test("结构兜底:官方类名全部换成未知前缀,窄屏仍能认出设置面板并打稳定类", () => {
|
|
297
|
+
const env = runAdapter(harness({ prefix: "Zz9Qq_" }));
|
|
298
|
+
const { panel, content } = env.box;
|
|
299
|
+
assert.ok(has(panel, "dsh-ma-settings-panel"), "未知前缀下也应认出面板");
|
|
300
|
+
assert.ok(has(env.box.overlay, "dsh-ma-settings-overlay"), "overlay 也应打标");
|
|
301
|
+
assert.ok(has(panel.children[0], "dsh-ma-settings-nav"), "nav 应打标");
|
|
302
|
+
assert.ok(has(content, "dsh-ma-settings-content"), "content 应打标");
|
|
303
|
+
const navList = panel.children[0].children[1];
|
|
304
|
+
assert.ok(has(navList, "dsh-ma-settings-navlist"), "tab 列表应打标");
|
|
305
|
+
for (const cell of navList.children) assert.ok(has(cell, "dsh-ma-settings-cell"), "每个 tab 都应打标");
|
|
306
|
+
assert.ok(has(navList.children[1], "dsh-ma-settings-active"), "当前 tab(aria-current/_active)应打标");
|
|
307
|
+
assert.ok(env.warns.some((w) => w.includes("结构")), "前缀改名必须留下**可见**降级诊断");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("0.2 前缀(Dws9Sa_):稳定类照打,但不报「改名」告警", () => {
|
|
311
|
+
const env = runAdapter(harness({ prefix: "Dws9Sa_" }));
|
|
312
|
+
assert.ok(has(env.box.panel, "dsh-ma-settings-panel"));
|
|
313
|
+
assert.ok(has(env.box.content, "dsh-ma-settings-content"));
|
|
314
|
+
assert.equal(env.warns.filter((w) => w.includes("混淆前缀")).length, 0, "已知前缀不该报改名告警");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("0.1.5 前缀(VOzbGW_)同样打标(老版本不许回归)", () => {
|
|
318
|
+
const env = runAdapter(harness({ prefix: "VOzbGW_" }));
|
|
319
|
+
assert.ok(has(env.box.panel, "dsh-ma-settings-panel"));
|
|
320
|
+
assert.ok(has(env.box.panel.children[0], "dsh-ma-settings-nav"));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("结构兜底:nav 不是 <nav> 标签(官方改了标签名)时认不出 → 只留一条可见诊断,不报错", () => {
|
|
324
|
+
const env = runAdapter(harness({ prefix: "Zz9Qq_", navTag: "div" }));
|
|
325
|
+
assert.ok(!has(env.box.panel, "dsh-ma-settings-panel"), "认不出就不许乱打类(宁可不做,不可做错)");
|
|
326
|
+
assert.ok(env.warns.some((w) => w.includes("未能识别为设置面板")), "应给出可见的降级诊断");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("假 DOM 也不会被官方改名吓住:识别是幂等的,重复 pump 不重复打标/不刷屏告警", () => {
|
|
330
|
+
const env = harness({ prefix: "Zz9Qq_" });
|
|
331
|
+
runAdapter(env);
|
|
332
|
+
for (const fn of env.intervals) { try { fn(); } catch { /* 忽略 */ } }
|
|
333
|
+
const renamed = env.warns.filter((w) => w.includes("结构"));
|
|
334
|
+
assert.equal(renamed.length, 1, "同类降级诊断只报一次(不刷屏)");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ── ③ 不回归 ─────────────────────────────────────────────────────────────────
|
|
338
|
+
test("宽屏(>820)零动作:不打稳定类、不告警", () => {
|
|
339
|
+
const env = runAdapter(harness({ prefix: "Zz9Qq_", width: 1280, height: 900 }));
|
|
340
|
+
assert.ok(!has(env.box.panel, "dsh-ma-settings-panel"), "宽屏不得改动设置面板");
|
|
341
|
+
assert.equal(env.warns.length, 0, "宽屏不得有任何诊断输出");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("只给**已有**节点打类:不新建任何设置/覆盖层节点(护栏:适配层不得引入全屏覆盖层)", () => {
|
|
345
|
+
const env = runAdapter(harness({ prefix: "Zz9Qq_" }));
|
|
346
|
+
const tagged = [env.box.overlay, env.box.panel, ...env.box.nodes].filter((el) =>
|
|
347
|
+
[...el._cls].some((c) => c.startsWith("dsh-ma-settings")));
|
|
348
|
+
assert.ok(tagged.length > 0);
|
|
349
|
+
for (const el of tagged) assert.notEqual(el.createdBy, "adapter", "稳定类只能打在官方已有节点上");
|
|
350
|
+
// 新建节点里不得出现任何 fixed/absolute 覆盖层(只看适配层自己造的)
|
|
351
|
+
for (const el of env.created) assert.ok(!/overlay|scrim|mask/i.test(el.className), "不得新建覆盖层节点");
|
|
352
|
+
});
|
|
@@ -101,6 +101,18 @@ test("适配层文本不含口令类敏感标记(仅结构提示)", () => {
|
|
|
101
101
|
assert.ok(!/password|secret|token\s*=/i.test(mod.ADAPTER_TAG + "")); // ADAPTER_TAG 只是 id 常量
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
test("注入体里不得出现反引号(模板字符串会被提前闭合 —— 本仓 AGENTS.md 明列的坑)", () => {
|
|
105
|
+
const r = injectMobileAdapter(OFFICIAL_HTML);
|
|
106
|
+
const cssAt = r.html.indexOf('<style id="dsh-mobile-adapter-css"');
|
|
107
|
+
const jsAt = r.html.indexOf('<script id="dsh-mobile-adapter-js"');
|
|
108
|
+
const css = r.html.slice(r.html.indexOf(">", cssAt) + 1, r.html.indexOf("</style>", cssAt));
|
|
109
|
+
const js = r.html.slice(r.html.indexOf(">", jsAt) + 1, r.html.indexOf("</script>", jsAt));
|
|
110
|
+
assert.ok(css.length > 1000 && js.length > 1000, "应取到注入体");
|
|
111
|
+
// 注入体是 JS 模板字符串拼出来的:里面再出现反引号就会提前闭合,报错信息往往毫不相干。
|
|
112
|
+
assert.equal(css.includes("`"), false, "注入样式体内出现反引号(注释里也不行)");
|
|
113
|
+
assert.equal(js.includes("`"), false, "注入脚本体内出现反引号(注释里也不行)");
|
|
114
|
+
});
|
|
115
|
+
|
|
104
116
|
test("fix2:设置区用精确类+小字号,浮层不再持久 will-change,鲸鱼协同 JS 载荷存在", () => {
|
|
105
117
|
const r = injectMobileAdapter(OFFICIAL_HTML);
|
|
106
118
|
const css = r.html.slice(r.html.indexOf('<style id="dsh-mobile-adapter-css"'), r.html.indexOf("</style>"));
|
|
@@ -122,3 +134,26 @@ test("fix2:设置区用精确类+小字号,浮层不再持久 will-change,鲸鱼
|
|
|
122
134
|
assert.ok(css.indexOf("@media (max-width: 820px)") !== -1);
|
|
123
135
|
assert.match(js, /NARROW\s*=\s*\(\)\s*=>\s*window\.innerWidth\s*<=\s*820/);
|
|
124
136
|
});
|
|
137
|
+
|
|
138
|
+
test("2026-10-13:0.2 前缀(Dws9Sa_*)与结构兜底都在(官方改名不再整段静默失效)", () => {
|
|
139
|
+
const r = injectMobileAdapter(OFFICIAL_HTML);
|
|
140
|
+
const css = r.html.slice(r.html.indexOf('<style id="dsh-mobile-adapter-css"'), r.html.indexOf("</style>"));
|
|
141
|
+
const js = r.html.slice(r.html.indexOf('<script id="dsh-mobile-adapter-js"'));
|
|
142
|
+
// ① 0.2.0-rc.2 实测前缀:VOzbGW_* → Dws9Sa_*(结构/类名一一对应,只换哈希)
|
|
143
|
+
for (const k of ["overlay", "panel", "nav", "navTitle", "navList", "navCell", "content", "header", "close", "options"]) {
|
|
144
|
+
assert.match(css, new RegExp(`\\[class~="VOzbGW_${k}"\\]`), `0.1.5 前缀钩子 ${k} 必须保留`);
|
|
145
|
+
assert.match(css, new RegExp(`\\[class~="Dws9Sa_${k}"\\]`), `0.2 前缀钩子 ${k} 必须并挂`);
|
|
146
|
+
}
|
|
147
|
+
// ② 结构兜底:JS 打稳定类 + CSS 认稳定类 + :has() 纯 CSS 兜底
|
|
148
|
+
assert.match(js, /maTagSettingsDialog/);
|
|
149
|
+
assert.match(js, /dsh-ma-settings-panel/);
|
|
150
|
+
assert.match(css, /\.dsh-ma-settings-panel/);
|
|
151
|
+
assert.match(css, /div\[role="dialog"\]:has\(> nav\)/);
|
|
152
|
+
// ③ 可见的降级诊断(不得引入覆盖层):console.warn 走适配层既有通道
|
|
153
|
+
assert.match(js, /\[dsh-mobile-adapter\]/);
|
|
154
|
+
assert.match(js, /window\.console\.warn|console\.warn/);
|
|
155
|
+
// ④ 通用弹层按手机尺寸收敛
|
|
156
|
+
assert.match(css, /max-width:\s*calc\(100vw - 24px\)\s*!important/);
|
|
157
|
+
assert.match(css, /max-height:\s*calc\(100dvh - 24px\)\s*!important/);
|
|
158
|
+
assert.match(css, /\.dru-popup-card/);
|
|
159
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1-beta.1",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"test": "npm run test:router && npm run test:plugin && npm run test:bridge",
|
|
54
|
-
"test:router": "DSH_PROFILE_DIR= NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/native-app-entry.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs packages/relay-router/test/device-free-assets.test.mjs packages/relay-router/test/sse-streaming-contract.test.mjs",
|
|
54
|
+
"test:router": "DSH_PROFILE_DIR= NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/native-app-entry.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs packages/relay-router/test/device-free-assets.test.mjs packages/relay-router/test/sse-streaming-contract.test.mjs packages/relay-router/test/local-session.test.mjs",
|
|
55
55
|
"_comment:test:plugin": "DSH_PROFILE_DIR= = 测试隔离铁律:本套件跑在 DSH 宿主里时环境**天然带着** DSH_PROFILE_DIR(指向真实 ~/.dsh/profiles/desktop),而多数用例只隔离了 HOME —— 于是它们会去写**真实** profile 的 cordis.patch.yml(2026-10-10 故障:测试 relayDir 被写进真 profile,面板连到假配置、验证码/公告全 502)。这里显式清空,让 profile 解析回落到各用例自己的假 HOME。 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent = 强制走 npx 兜底分支(否则本机跑过 npm pack 后 packages/dsh-remote-web/runtime 存在,用例会改成走本地拷贝)。DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)。DSH_RELAY_DEFAULT_API= 死端口:用例拆掉临时目录后,仍在飞的后台请求(装机上报重试等)会读到空配置并回落「默认云端地址」—— 指到本地死端口才不会打生产。",
|
|
56
56
|
"test:plugin": "DSH_PROFILE_DIR= DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent-dsh-bundled NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
|
|
57
57
|
"_comment:test:bridge": "⚠️ 必须 --test-concurrency=1(与 test:plugin 对齐):tunnel-auth 有一条「bridge 5 秒内必须重连」的硬期限,并行跑时新增用例抢 CPU 会把它撞破 → 偶发假红(实测单独跑 2/2 通过、并发跑偶发 0/2)。假红比慢更贵。",
|