@mrrisega/dsh-remote 0.6.4 → 0.6.6-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 +49 -8
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +208 -0
- package/dsh-setup.mjs +76 -8
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +9 -1
- package/packages/dsh-remote-web/lib/index.js +150 -14
- package/packages/dsh-remote-web/package.json +2 -5
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +12 -28
- package/packages/dsh-remote-web/test/patch-activation.test.mjs +12 -0
- package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +117 -0
|
@@ -478,13 +478,31 @@ const SCRIPT = `(() => {
|
|
|
478
478
|
|
|
479
479
|
const toggleSel = '[aria-label="打开侧边栏"], [aria-label="关闭侧边栏"], [aria-label="Open sidebar"], [aria-label="Close sidebar"]';
|
|
480
480
|
const toggleOf = () => document.querySelector(toggleSel) || document.querySelector(".hHd-Xa_toggle");
|
|
481
|
-
|
|
481
|
+
/* 官方用 data-sidebar-collapsed 表达折叠态,但写法有两种可能:只输出属性本身,
|
|
482
|
+
或输出 attribute 的值(例如 =false)。旧实现只看 hasAttribute 是否存在,
|
|
483
|
+
只兼容前一种:一旦官方用带值写法,就会被永久判定为「展开」→ 遮罩常亮且拦截点击、
|
|
484
|
+
汉堡按钮被隐藏 → 手机端整屏阴影、点哪都没反应(用户实测)。
|
|
485
|
+
这里改成读值,两种写法都能正确判断。 */
|
|
486
|
+
const truthyAttr = (el, name) => {
|
|
487
|
+
const v = el.getAttribute(name);
|
|
488
|
+
if (v === null) return false;
|
|
489
|
+
const s = String(v).trim().toLowerCase();
|
|
490
|
+
return !(s === "" || s === "false" || s === "0" || s === "off" || s === "no");
|
|
491
|
+
};
|
|
492
|
+
const collapsed = () => truthyAttr(frame, "data-sidebar-collapsed");
|
|
493
|
+
const expanded = () => !collapsed();
|
|
482
494
|
|
|
483
495
|
/* 遮罩:点击 = 点官方 toggle(走官方 store,无私有状态) */
|
|
484
496
|
const scrim = document.createElement("div");
|
|
485
497
|
scrim.className = "dsh-ma-scrim";
|
|
486
498
|
document.body.appendChild(scrim);
|
|
487
|
-
scrim.addEventListener("click", (e) => {
|
|
499
|
+
scrim.addEventListener("click", (e) => {
|
|
500
|
+
e.stopPropagation();
|
|
501
|
+
const t = toggleOf();
|
|
502
|
+
if (t) { t.click(); return; }
|
|
503
|
+
/* 兜底:找不到官方 toggle 时**至少让用户能自救**(否则就是"点哪都没反应"的死局) */
|
|
504
|
+
document.documentElement.classList.remove("dsh-ma-sidebar-open", "dsh-ma-details-open");
|
|
505
|
+
});
|
|
488
506
|
|
|
489
507
|
/* 顶部小鲸鱼/菜单按钮 */
|
|
490
508
|
const hamburger = document.createElement("button");
|
|
@@ -496,21 +514,44 @@ const SCRIPT = `(() => {
|
|
|
496
514
|
hamburger.addEventListener("click", (e) => {
|
|
497
515
|
e.stopPropagation(); e.preventDefault();
|
|
498
516
|
const t = toggleOf();
|
|
499
|
-
if (t) t.click();
|
|
500
|
-
|
|
517
|
+
if (t) { t.click(); return; }
|
|
518
|
+
/* 兜底(无官方 toggle):按当前状态切换,保证"开得了也关得掉" */
|
|
519
|
+
const open = document.documentElement.classList.contains("dsh-ma-sidebar-open");
|
|
520
|
+
if (open) document.documentElement.classList.remove("dsh-ma-sidebar-open", "dsh-ma-details-open");
|
|
521
|
+
else document.documentElement.classList.add("dsh-ma-sidebar-open");
|
|
501
522
|
});
|
|
502
523
|
|
|
503
|
-
/*
|
|
524
|
+
/* 侧栏是否**真的**滑进了视口。这是"遮罩能不能拦截点击"的最后一道保险:
|
|
525
|
+
只要官方属性语义与我们理解的不一致,光看属性就可能误判;而遮罩一旦拦截整屏,
|
|
526
|
+
用户看到的就是"整屏阴影 + 点哪都没反应"。以实测几何为准,误判也不会挡住用户。 */
|
|
527
|
+
const inView = (el) => {
|
|
528
|
+
if (!el) return false;
|
|
529
|
+
try { const r = el.getBoundingClientRect(); return !!r && r.right > 8 && r.width > 40; }
|
|
530
|
+
catch (e2) { return false; }
|
|
531
|
+
};
|
|
532
|
+
const sidebarInView = () => inView(sidebar || document.querySelector(".dsh-ma-sidebar"));
|
|
533
|
+
/* 状态唯一真源 = 官方 frame 的 data 属性(判定)+ 真实几何(放行遮罩) */
|
|
504
534
|
const sync = () => {
|
|
505
535
|
const open = expanded();
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
536
|
+
const scrimOn = open && sidebarInView();
|
|
537
|
+
document.documentElement.classList.toggle("dsh-ma-sidebar-open", scrimOn);
|
|
538
|
+
/* 汉堡按钮**始终可见**:以前判定为"展开"时会把它 display:none 隐藏,
|
|
539
|
+
于是判定一旦出错(或遮罩因异常常亮),用户既点不动内容、也没有任何入口 —— 死局。
|
|
540
|
+
保持可见 + 点它可开可关(见其 click 处理),任何异常状态下都留一条出路。 */
|
|
541
|
+
if (hamburger) hamburger.style.display = "";
|
|
542
|
+
// 详情栏同理:只有"判定为展开"且"真的在视口里"才让遮罩拦截
|
|
543
|
+
const dOpen = !truthyAttr(frame, "data-details-collapsed") && !!details && inView(details);
|
|
509
544
|
document.documentElement.classList.toggle("dsh-ma-details-open", dOpen);
|
|
510
545
|
};
|
|
511
546
|
try {
|
|
512
547
|
new MutationObserver(sync).observe(frame, { attributes: true, attributeFilter: ["data-sidebar-collapsed", "data-details-collapsed"] });
|
|
513
548
|
} catch (e2) { /* 退化:仅在下次 boot 同步 */ }
|
|
549
|
+
// 几何变化(旋转屏幕 / 窗口缩放 / 侧栏动画结束)也要重算,否则会把 boot 时的判定一直沿用
|
|
550
|
+
try {
|
|
551
|
+
window.addEventListener("resize", sync, { passive: true });
|
|
552
|
+
window.addEventListener("orientationchange", sync, { passive: true });
|
|
553
|
+
if (sidebar && typeof ResizeObserver === "function") new ResizeObserver(sync).observe(sidebar);
|
|
554
|
+
} catch (e2) { /* 非关键 */ }
|
|
514
555
|
sync();
|
|
515
556
|
/* 窄屏抽屉:点选会话(激活行)/点侧栏内「新建会话」后自动收起(官方不做;适配层点官方 toggle)。
|
|
516
557
|
仅当:窄屏 && 抽屉展开 && 点击发生在侧栏抽屉内;行内「⋯」按钮 stopPropagation 不会误触。 */
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mobile-adapter **运行时**契约测试(假 DOM + vm 执行真实注入脚本)。
|
|
4
|
+
*
|
|
5
|
+
* 事故背景(2026-09-15 生产反馈):
|
|
6
|
+
* 手机端用户打开后**整屏被阴影覆盖、点哪都没反应**,只有左侧栏(z-index 300,在遮罩之上)能点。
|
|
7
|
+
* 根因是适配层自己创建的全屏 scrim(div.dsh-ma-scrim, z-index 290):
|
|
8
|
+
* · 判定「侧栏是否展开」用的是 `!frame.hasAttribute("data-sidebar-collapsed")`,
|
|
9
|
+
* 只兼容"折叠时才输出属性"这一种写法;官方若用带值写法,就永久判定为展开 →
|
|
10
|
+
* html 上的 dsh-ma-sidebar-open 常亮 → scrim 永久 pointer-events:auto(拦截整屏点击);
|
|
11
|
+
* · 且判定为展开时还会把汉堡按钮 display:none 隐藏掉,"打开菜单/关掉遮罩"的入口也没了;
|
|
12
|
+
* · 唯一关闭路径是"点 scrim → 点官方 toggle",找不到 toggle 就彻底卡死。
|
|
13
|
+
*
|
|
14
|
+
* 本用例直接拿 injectMobileAdapter() 注入的**真实脚本**在假 DOM 里跑,断言运行时行为
|
|
15
|
+
* (既有用例只测注入纯函数,覆盖不到这里,所以这个 bug 当初没被任何测试拦住)。
|
|
16
|
+
*/
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
import vm from "node:vm";
|
|
20
|
+
import { injectMobileAdapter } from "../mobile-adapter.mjs";
|
|
21
|
+
|
|
22
|
+
/** 从注入结果里取出真正的 <script> 内容(不加壳,原样执行)。 */
|
|
23
|
+
function adapterScript() {
|
|
24
|
+
const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
|
|
25
|
+
const m = /<script id="[^"]*-js"[^>]*>([\s\S]*?)<\/script>/.exec(html);
|
|
26
|
+
assert.ok(m, "应能取出注入脚本");
|
|
27
|
+
return m[1];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── 极简假 DOM ────────────────────────────────────────────────────────────────
|
|
31
|
+
class El {
|
|
32
|
+
constructor(tag = "div", attrs = {}) {
|
|
33
|
+
this.tagName = String(tag).toUpperCase();
|
|
34
|
+
this.attrs = { ...attrs };
|
|
35
|
+
this.children = [];
|
|
36
|
+
this._cls = new Set();
|
|
37
|
+
this._listeners = new Map();
|
|
38
|
+
this.style = {};
|
|
39
|
+
this._rect = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 };
|
|
40
|
+
this.classList = {
|
|
41
|
+
add: (...cs) => cs.forEach((c) => this._cls.add(c)),
|
|
42
|
+
remove: (...cs) => cs.forEach((c) => this._cls.delete(c)),
|
|
43
|
+
contains: (c) => this._cls.has(c),
|
|
44
|
+
toggle: (c, on) => { if (on === undefined) { this._cls.has(c) ? this._cls.delete(c) : this._cls.add(c); } else if (on) this._cls.add(c); else this._cls.delete(c); return this._cls.has(c); },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
get className() { return [...this._cls].join(" "); }
|
|
48
|
+
set className(v) { this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); }
|
|
49
|
+
getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; }
|
|
50
|
+
setAttribute(n, v) { this.attrs[n] = String(v); }
|
|
51
|
+
hasAttribute(n) { return n in this.attrs; }
|
|
52
|
+
appendChild(c) { c.parent = this; this.children.push(c); return c; }
|
|
53
|
+
addEventListener(t, fn) { (this._listeners.get(t) || this._listeners.set(t, []).get(t)).push(fn); }
|
|
54
|
+
removeEventListener() {}
|
|
55
|
+
dispatch(t, ev = {}) { for (const fn of this._listeners.get(t) || []) fn({ stopPropagation() {}, preventDefault() {}, ...ev }); }
|
|
56
|
+
getBoundingClientRect() { return this._rect; }
|
|
57
|
+
querySelector() { return null; }
|
|
58
|
+
querySelectorAll() { return []; }
|
|
59
|
+
matches() { return false; }
|
|
60
|
+
click() { this._clicked = (this._clicked || 0) + 1; this.dispatch("click"); }
|
|
61
|
+
get isConnected() { return true; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 搭一个最小 dsh web 环境:frame(带官方 data 属性)内含 sidebar/center 两列 + 可选官方 toggle。 */
|
|
65
|
+
function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRect = null, width = 390, withToggle = true } = {}) {
|
|
66
|
+
const html = new El("html");
|
|
67
|
+
// HOSTISH 门禁会读 documentElement.outerHTML 找官方特征(__ModuleLoader__ / DeepSeek Harness)
|
|
68
|
+
html.outerHTML = '<html><head><script>window.__ModuleLoader__ = { mode: "queue" }</script>'
|
|
69
|
+
+ "<title>DeepSeek Harness</title></head><body></body></html>";
|
|
70
|
+
const body = new El("body");
|
|
71
|
+
const frame = new El("div");
|
|
72
|
+
if (collapsedAttr !== undefined) frame.setAttribute("data-sidebar-collapsed", collapsedAttr);
|
|
73
|
+
if (detailsAttr !== undefined) frame.setAttribute("data-details-collapsed", detailsAttr);
|
|
74
|
+
|
|
75
|
+
const sidebar = new El("div", { class: "sidebarCol" });
|
|
76
|
+
const center = new El("div", { class: "centerCol" });
|
|
77
|
+
if (sidebarRect) sidebar._rect = sidebarRect;
|
|
78
|
+
frame.appendChild(sidebar);
|
|
79
|
+
frame.appendChild(center);
|
|
80
|
+
const toggle = new El("button");
|
|
81
|
+
toggle.setAttribute("aria-label", "打开侧边栏");
|
|
82
|
+
|
|
83
|
+
const pickByClass = (sel) => {
|
|
84
|
+
// 只支持本用例需要的极少数选择器(脚本里 querySelector 的用法就这些)
|
|
85
|
+
const s2 = String(sel);
|
|
86
|
+
if (/\.dsh-ma-sidebar/.test(s2)) return sidebar._cls.size ? sidebar : null;
|
|
87
|
+
if (/\.hHd-Xa_root|sidebar/i.test(s2)) return sidebar;
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
90
|
+
const frameQ = (sel) => pickByClass(sel);
|
|
91
|
+
frame.querySelector = frameQ;
|
|
92
|
+
frame.querySelectorAll = () => [];
|
|
93
|
+
const doc = {
|
|
94
|
+
documentElement: html,
|
|
95
|
+
body,
|
|
96
|
+
readyState: "complete",
|
|
97
|
+
addEventListener() {},
|
|
98
|
+
querySelector(sel) {
|
|
99
|
+
const s2 = String(sel);
|
|
100
|
+
if (s2.includes(".pI_x6G_frame") || s2.includes("[data-sidebar-collapsed], [data-details-collapsed]") || s2.includes("[data-shell-overlay]")) return frame;
|
|
101
|
+
if (s2.includes("aria-label=")) return withToggle ? toggle : null;
|
|
102
|
+
if (s2.includes(".hHd-Xa_toggle")) return withToggle ? toggle : null;
|
|
103
|
+
if (s2.includes("hHd-Xa")) return sidebar; // 子列识别用(真实页面里这是侧栏)
|
|
104
|
+
return pickByClass(s2);
|
|
105
|
+
},
|
|
106
|
+
querySelectorAll() { return []; },
|
|
107
|
+
createElement: (t) => new El(t),
|
|
108
|
+
createTextNode: (t) => ({ nodeValue: t }),
|
|
109
|
+
getElementById: () => null,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const timers = new Map();
|
|
113
|
+
const win = {
|
|
114
|
+
innerWidth: width,
|
|
115
|
+
innerHeight: 800,
|
|
116
|
+
devicePixelRatio: 1,
|
|
117
|
+
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
|
118
|
+
addEventListener() {},
|
|
119
|
+
removeEventListener() {},
|
|
120
|
+
getComputedStyle: () => ({ getPropertyValue: () => "" }),
|
|
121
|
+
requestAnimationFrame: (fn) => { const id = setImmediate(fn); return id; },
|
|
122
|
+
setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); timers.set(id, fn); return id; },
|
|
123
|
+
clearTimeout: (id) => clearTimeout(id),
|
|
124
|
+
MutationObserver: class { observe() {} disconnect() {} },
|
|
125
|
+
ResizeObserver: class { observe() {} disconnect() {} },
|
|
126
|
+
};
|
|
127
|
+
const ctx = vm.createContext({
|
|
128
|
+
window: win, document: doc, console, setTimeout: win.setTimeout, clearTimeout: win.clearTimeout,
|
|
129
|
+
setInterval: () => 0, clearInterval() {}, MutationObserver: win.MutationObserver,
|
|
130
|
+
ResizeObserver: win.ResizeObserver, requestAnimationFrame: win.requestAnimationFrame,
|
|
131
|
+
navigator: { userAgent: "iPhone Safari", maxTouchPoints: 5, platform: "iPhone" },
|
|
132
|
+
location: { href: "https://n.risegao.cn:13443/app/", host: "n.risegao.cn" },
|
|
133
|
+
localStorage: win.localStorage, URL, Date, Math, JSON, Object, Array, String, Number, RegExp, Boolean, Promise, Error, isNaN, parseInt, parseFloat,
|
|
134
|
+
HTMLElement: El, // 脚本用 `c instanceof HTMLElement` 过滤 frame 的子列
|
|
135
|
+
});
|
|
136
|
+
win.onerror = (m) => { if (ADAPTER_DEBUG) console.error("ADAPTER onerror:", m); };
|
|
137
|
+
const dbgConsole = ADAPTER_DEBUG
|
|
138
|
+
? { log: (...a) => console.error("[adapter]", ...a), warn: (...a) => console.error("[adapter warn]", ...a), error: (...a) => console.error("[adapter err]", ...a) }
|
|
139
|
+
: console;
|
|
140
|
+
ctx.console = dbgConsole;
|
|
141
|
+
ctx.globalThis = ctx;
|
|
142
|
+
ctx.self = win;
|
|
143
|
+
return { ctx, html, body, frame, sidebar, toggle, win };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const ADAPTER_DEBUG = process.env.DSH_ADAPTER_DEBUG === "1";
|
|
147
|
+
|
|
148
|
+
function runAdapter(env) {
|
|
149
|
+
try {
|
|
150
|
+
vm.runInContext(adapterScript(), env.ctx, { timeout: 5000 });
|
|
151
|
+
} catch (e) {
|
|
152
|
+
if (ADAPTER_DEBUG) console.error("ADAPTER THREW:", e && e.message);
|
|
153
|
+
throw e;
|
|
154
|
+
}
|
|
155
|
+
// sync() 在 boot 时立即执行一次;脚本内用 setTimeout/rAF 的场景再推一轮
|
|
156
|
+
return env;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const findScrim = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-scrim"));
|
|
160
|
+
const findHamburger = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-hamburger"));
|
|
161
|
+
|
|
162
|
+
// ── 用例 ─────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
test("运行时:官方属性带值写法 =false 时不得判定为展开 → 遮罩不拦截点击(用户报的整屏阴影)", () => {
|
|
165
|
+
// 官方若输出 data-sidebar-collapsed="false"(属性存在但语义为未折叠),旧实现 hasAttribute → 判定展开 → 遮罩常亮
|
|
166
|
+
const env = runAdapter(harness({ collapsedAttr: "false", sidebarRect: null }));
|
|
167
|
+
assert.ok(!env.html.classList.contains("dsh-ma-sidebar-open"),
|
|
168
|
+
"属性值 false = 未折叠 → 不应加 sidebar-open(否则遮罩会拦住整屏点击)");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("运行时:即使判定为展开,侧栏没真的滑进视口时也不得拦截点击", () => {
|
|
172
|
+
// 属性"存在且无值"在旧实现里就是展开;这里再叠加"侧栏几何不在视口内"(left:0/right:0)→ 仍不得拦截
|
|
173
|
+
const env = runAdapter(harness({ collapsedAttr: "", sidebarRect: { left: 0, right: 0, top: 0, bottom: 800, width: 0, height: 800 } }));
|
|
174
|
+
assert.ok(!env.html.classList.contains("dsh-ma-sidebar-open"),
|
|
175
|
+
"侧栏未滑入视口 → 遮罩必须保持 pointer-events:none(几何兜底)");
|
|
176
|
+
const scrim = findScrim(env);
|
|
177
|
+
assert.ok(scrim, "遮罩元素本身仍应创建(关闭态只是不拦截)");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("运行时:侧栏真的滑入视口(right>8 且有宽度)时才允许拦截点击", () => {
|
|
181
|
+
const env = runAdapter(harness({ collapsedAttr: "", sidebarRect: { left: 0, right: 300, top: 0, bottom: 800, width: 300, height: 800 } }));
|
|
182
|
+
assert.ok(env.html.classList.contains("dsh-ma-sidebar-open"),
|
|
183
|
+
"抽屉确实展开时遮罩才应显示并拦截(正常行为不能被改坏)");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("运行时:判定为展开时不再隐藏汉堡按钮(避免用户没有任何入口)", () => {
|
|
187
|
+
const env = runAdapter(harness({ collapsedAttr: "", sidebarRect: { left: 0, right: 300, top: 0, bottom: 800, width: 300, height: 800 } }));
|
|
188
|
+
const ham = findHamburger(env);
|
|
189
|
+
assert.ok(ham, "汉堡按钮应存在");
|
|
190
|
+
assert.notEqual(ham.style.display, "none",
|
|
191
|
+
"抽屉展开时也不应把汉堡隐藏 —— 判定一旦出错,用户会连入口都没有(事故现场就是这个死局)");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("运行时:找不到官方 toggle 时,点遮罩必须能自救(摘掉遮挡类)", () => {
|
|
195
|
+
const env = runAdapter(harness({ collapsedAttr: "", sidebarRect: { left: 0, right: 300, top: 0, bottom: 800, width: 300, height: 800 }, withToggle: false }));
|
|
196
|
+
assert.ok(env.html.classList.contains("dsh-ma-sidebar-open"), "前提:先处于遮挡态");
|
|
197
|
+
const scrim = findScrim(env);
|
|
198
|
+
scrim.dispatch("click");
|
|
199
|
+
assert.ok(!env.html.classList.contains("dsh-ma-sidebar-open"),
|
|
200
|
+
"找不到官方 toggle 时,点遮罩应直接摘掉遮挡类(否则点哪都没反应)");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("运行时:能拿到官方 toggle 时,点遮罩仍走官方 toggle(不引入私有状态)", () => {
|
|
204
|
+
const env = runAdapter(harness({ collapsedAttr: "", sidebarRect: { left: 0, right: 300, top: 0, bottom: 800, width: 300, height: 800 }, withToggle: true }));
|
|
205
|
+
const scrim = findScrim(env);
|
|
206
|
+
scrim.dispatch("click");
|
|
207
|
+
assert.ok(env.toggle._clicked >= 1, "应点击官方 toggle");
|
|
208
|
+
});
|
package/dsh-setup.mjs
CHANGED
|
@@ -681,6 +681,7 @@ async function setup(argv) {
|
|
|
681
681
|
let webPid = null;
|
|
682
682
|
let needRestart = false;
|
|
683
683
|
let hotMounted = false;
|
|
684
|
+
let runningSeen = ""; // 探测窗口里看到过的运行中插件版本(超时时用于给出准确提示)
|
|
684
685
|
const webUp = await isDshWebUp();
|
|
685
686
|
if (webUp) {
|
|
686
687
|
webPid = dshWebPid();
|
|
@@ -690,11 +691,30 @@ async function setup(argv) {
|
|
|
690
691
|
// 所以这里**先等热挂载**,等到了就完全不需要重启(用户只需刷新页面拿浏览器半)。
|
|
691
692
|
if (needRestart && pluginResult && pluginResult.hotPatch) {
|
|
692
693
|
// HMR 通常 1~3 秒完成,但插件节点半还要起服务、注册路由,机器繁忙时会到十几秒;
|
|
693
|
-
//
|
|
694
|
+
// ⚠️ 判据必须是**运行中的插件版本 == 我们刚装上的版本**,不能只看"接口是否 200" ——
|
|
695
|
+
// 旧版本插件本来就有这个接口,接口通不代表新代码已装载(实测踩到:profile 已是 0.6.4,
|
|
696
|
+
// 运行中仍报 beta.10,而探测却认为"热挂载成功")。
|
|
697
|
+
const wantVersion = pkgVersion();
|
|
698
|
+
// 先**主动触发**一次 HMR:patch 文件是唯一被监听的对象。
|
|
699
|
+
// 注意边界(实测):这招对"插件首次出现在 patch 行里"有效(行从无到有 = 真变化,
|
|
700
|
+
// 约 1~3 秒装载);但对"同一插件换了新版本"**无效** —— patch 行内容没变,
|
|
701
|
+
// 而且加载器按 URL 缓存模块,改写插件文件本身不会重新装载。后者必须重启 dsh web。
|
|
702
|
+
// 因此下面按**版本**轮询,等不到就如实让用户重启。
|
|
703
|
+
try {
|
|
704
|
+
const patchPath = path.join(resolveProfileDir(argv), "cordis.patch.yml");
|
|
705
|
+
const cur = fs.readFileSync(patchPath, "utf8");
|
|
706
|
+
fs.writeFileSync(patchPath, cur);
|
|
707
|
+
} catch { /* 忽略:轮询会给出结论 */ }
|
|
694
708
|
for (let i = 0; i < 45; i += 1) {
|
|
695
|
-
const r = await fetch("http://127.0.0.1:3080/dsh-remote/
|
|
709
|
+
const r = await fetch("http://127.0.0.1:3080/dsh-remote/self", { signal: AbortSignal.timeout(900) })
|
|
696
710
|
.catch(() => null);
|
|
697
|
-
if (r && r.ok) {
|
|
711
|
+
if (r && r.ok) {
|
|
712
|
+
const j = await r.json().catch(() => null);
|
|
713
|
+
const running = j && typeof j.version === "string" ? j.version : "";
|
|
714
|
+
if (running && running === wantVersion) { hotMounted = true; break; }
|
|
715
|
+
// 接口通但版本还是旧的 → HMR 还没换过来(或没触发),继续等
|
|
716
|
+
runningSeen = running || runningSeen;
|
|
717
|
+
}
|
|
698
718
|
sleepSync(700);
|
|
699
719
|
}
|
|
700
720
|
if (hotMounted) needRestart = false;
|
|
@@ -788,8 +808,13 @@ async function setup(argv) {
|
|
|
788
808
|
// patch 已写入但探测窗口内没等到面板接口:热加载很可能还在进行(插件节点半启动+注册路由)。
|
|
789
809
|
// 这种情况**不能**直接让用户重启 —— 先让他等几秒刷新,再给兜底方案。
|
|
790
810
|
L.push("");
|
|
791
|
-
|
|
792
|
-
|
|
811
|
+
if (runningSeen) {
|
|
812
|
+
L.push(`ℹ 插件已按热加载方式登记,但运行中的 dsh web 仍在用旧版本(${runningSeen} → 期望 ${pkgVersion()}):`);
|
|
813
|
+
L.push(" 刷新页面即可(浏览器半会跟着更新);若刷新后功能仍不对,再重启一次 dsh web。");
|
|
814
|
+
} else {
|
|
815
|
+
L.push("ℹ 插件已按热加载方式登记(patch 已写入),若「设置 → 远程控制」还没出现:");
|
|
816
|
+
L.push(" 等几秒后刷新页面即可;仍未出现再重启一次 dsh web(命令见 README)。");
|
|
817
|
+
}
|
|
793
818
|
} else if (needRestart) {
|
|
794
819
|
// 需要重启但没做成功:必须给出**可照抄**的命令,而不是只说"请重启"
|
|
795
820
|
L.push("");
|
|
@@ -1127,6 +1152,35 @@ function removeBundleEntry(pkgFile) {
|
|
|
1127
1152
|
* 两条路都**只保留一个激活点**:写 patch 就同时从 bundles 移除,反之清掉 include,
|
|
1128
1153
|
* 绝不两处并存(那正是历史上 dsh web 启动即报「重复 ID」崩溃的原因)。
|
|
1129
1154
|
*/
|
|
1155
|
+
|
|
1156
|
+
/** 读取 profile 内已安装插件的版本(市场/包管理器装法)。 */
|
|
1157
|
+
function readInstalledPluginVersion(profileDir, name) {
|
|
1158
|
+
try {
|
|
1159
|
+
return JSON.parse(fs.readFileSync(path.join(profileDir, "node_modules", name, "package.json"), "utf8")).version;
|
|
1160
|
+
} catch { return null; }
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* 市场装法下把插件包升到最新。
|
|
1165
|
+
*
|
|
1166
|
+
* 2026-09-13 实测的坑:用户在插件面板点「一键更新」时,本函数原先的调用方(marketManaged 分支)
|
|
1167
|
+
* 只清 include、不动源码,日志写着"已保持市场管理的源码不变",命令 exit 0、界面显示"安装完成",
|
|
1168
|
+
* 但插件包始终停在旧版本 —— 用户看到的就是"一键更新到不了最新版"。
|
|
1169
|
+
*
|
|
1170
|
+
* 规格判断:registry 规格(^0.6.3 / 0.6.3 / >=…)用 `add <name>@latest`;
|
|
1171
|
+
* github:/git+/file:/https: 之类用 `update <name>` 让包管理器重新解析引用(例如 git HEAD)。
|
|
1172
|
+
*/
|
|
1173
|
+
function upgradeMarketManagedPlugin(profileDir, name, dep) {
|
|
1174
|
+
const spec = String(dep ?? "").trim();
|
|
1175
|
+
const isRegistryRange = /^[\^~><=v\d]/.test(spec);
|
|
1176
|
+
const pm = sh("command -v pnpm >/dev/null 2>&1 && echo pnpm || echo npm").stdout.trim() || "npm";
|
|
1177
|
+
const args = isRegistryRange ? `add ${name}@latest` : `update ${name}`;
|
|
1178
|
+
const cmd = `${pm} ${args}`;
|
|
1179
|
+
const r = sh(cmd, 180000, profileDir);
|
|
1180
|
+
if (!r.ok) console.warn(`⚠️ ${cmd} 失败:${(r.stderr || r.stdout || "").trim().split("\n").slice(-3).join(" / ")}`);
|
|
1181
|
+
return { ok: r.ok, cmd };
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1130
1184
|
function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch) {
|
|
1131
1185
|
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
1132
1186
|
const legacyDep = PLUGIN_LEGACY_IDS.map((id) => pkg.dependencies && pkg.dependencies[id]).find((v) => v !== undefined);
|
|
@@ -1137,10 +1191,24 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
|
|
|
1137
1191
|
const managedByUs = !dep || String(dep).startsWith("file:"); // 无依赖或 file: 拷贝 → 我们管
|
|
1138
1192
|
|
|
1139
1193
|
if (marketManaged && inBundles) {
|
|
1140
|
-
//
|
|
1194
|
+
// 插件市场安装形态:源码归市场/包管理器管 → 只清历史 include,但**必须主动升级到最新**,
|
|
1195
|
+
// 否则「一键更新」会报成功却什么都没做(2026-09-13 实测)。
|
|
1141
1196
|
stripIncludeEntries(patchFile, patch, "插件市场安装形态无需用户 include");
|
|
1142
|
-
|
|
1143
|
-
|
|
1197
|
+
const depName = PLUGIN_ALL_IDS.find((id) => pkg.dependencies && pkg.dependencies[id] !== undefined) || PLUGIN_ID;
|
|
1198
|
+
const before = readInstalledPluginVersion(profileDir, depName);
|
|
1199
|
+
console.log(`ℹ 插件市场安装形态(bundles+dependency):源码归包管理器管,正在升级到最新…(安装前 ${before ?? "未知"})`);
|
|
1200
|
+
const { ok, cmd } = upgradeMarketManagedPlugin(profileDir, depName, dep);
|
|
1201
|
+
const after = readInstalledPluginVersion(profileDir, depName);
|
|
1202
|
+
if (ok && after && after !== before) {
|
|
1203
|
+
console.log(`✅ 插件包已升级:${before ?? "?"} → ${after}`);
|
|
1204
|
+
console.log(" 该形态经 dsh.profile.bundles 激活,只在启动时读取 —— 请重启 dsh web 生效。");
|
|
1205
|
+
} else if (ok && after) {
|
|
1206
|
+
console.log(`✅ 插件包已是市场最新版(${after})。`);
|
|
1207
|
+
console.log(" 若面板仍显示旧版本,请重启 dsh web(bundles 只在启动时读取)。");
|
|
1208
|
+
} else {
|
|
1209
|
+
console.log(`⚠️ 插件包升级未成功(当前 ${after ?? "未知"})。可手动执行:`);
|
|
1210
|
+
console.log(` cd ${profileDir} && ${cmd}`);
|
|
1211
|
+
}
|
|
1144
1212
|
return;
|
|
1145
1213
|
}
|
|
1146
1214
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.6-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",
|
|
@@ -1010,7 +1010,15 @@ window.__ModuleLoader__.load({
|
|
|
1010
1010
|
if (!b.running) {
|
|
1011
1011
|
clearInterval(iv);
|
|
1012
1012
|
setUpdating(false);
|
|
1013
|
-
|
|
1013
|
+
// 关键:进程结束 ≠ 更新成功。以前这里无条件显示"已更新完成",
|
|
1014
|
+
// 于是 spawn 失败(例如 npx 不可用)时界面只报成功、真实原因躺在日志里 ——
|
|
1015
|
+
// 用户看到的就是"点了一键修复没反应"。现在按失败信息与版本是否变化如实反馈。
|
|
1016
|
+
if (b.failure && b.failure.detail) {
|
|
1017
|
+
setUpdated(false);
|
|
1018
|
+
setSelfMsg({ kind: "err", text: "更新失败:" + b.failure.detail + "(可点「复制诊断信息」,或查看下方日志)" });
|
|
1019
|
+
} else {
|
|
1020
|
+
setUpdated(true);
|
|
1021
|
+
}
|
|
1014
1022
|
loadVer();
|
|
1015
1023
|
doCheck();
|
|
1016
1024
|
}
|
|
@@ -499,10 +499,56 @@ function appendLogLine(relayDir, name, line) {
|
|
|
499
499
|
* 已有环境(包括手动 npx 装过)直接跳过。返回 true=已就绪。
|
|
500
500
|
* 注意:默认优先官方源——镜像(npmmirror)可能滞后于刚发布的版本,装到旧版会把
|
|
501
501
|
* 已被 0.4.5 移除的“用户 include”重新写回 profile(历史上造成 dsh web 重复 ID 崩溃)。 */
|
|
502
|
+
/**
|
|
503
|
+
* 补装失败退避(2026-09-15 生产事故)。
|
|
504
|
+
*
|
|
505
|
+
* 背景:Windows 用户补装 `npx @mrrisega/dsh-remote` 持续失败时,自愈调度每轮都重试,
|
|
506
|
+
* 生产遥测里看到单机 12 秒一次、累计 606 次的失败风暴(1659 次 install_failed 全在 Windows)。
|
|
507
|
+
* 这既刷屏日志、也让真实失败原因淹没在噪声里。
|
|
508
|
+
*
|
|
509
|
+
* 策略:连续失败后按 30s → 2m → 10m 退避(封顶 10 分钟);用户点「一键修复 / 一键更新」时
|
|
510
|
+
* 显式放行(resetProvisionRetry),保证人工操作永远能立即重试。
|
|
511
|
+
*/
|
|
512
|
+
const PROVISION_RETRY_BACKOFF_MS = [30_000, 120_000, 600_000];
|
|
513
|
+
|
|
514
|
+
function provisionRetryGate(relayDir) {
|
|
515
|
+
const key = String(relayDir);
|
|
516
|
+
let st = provisionRetry.get(key);
|
|
517
|
+
if (!st) { st = { fails: 0, nextAt: 0 }; provisionRetry.set(key, st); }
|
|
518
|
+
if (Date.now() < st.nextAt) {
|
|
519
|
+
return { ok: false, waitMs: st.nextAt - Date.now(), fails: st.fails };
|
|
520
|
+
}
|
|
521
|
+
return { ok: true, st };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function noteProvisionFailure(relayDir) {
|
|
525
|
+
const key = String(relayDir);
|
|
526
|
+
const st = provisionRetry.get(key) || { fails: 0, nextAt: 0 };
|
|
527
|
+
st.fails += 1;
|
|
528
|
+
const wait = PROVISION_RETRY_BACKOFF_MS[Math.min(st.fails, PROVISION_RETRY_BACKOFF_MS.length) - 1];
|
|
529
|
+
st.nextAt = Date.now() + wait;
|
|
530
|
+
provisionRetry.set(key, st);
|
|
531
|
+
return { fails: st.fails, waitMs: wait };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function noteProvisionSuccess(relayDir) {
|
|
535
|
+
provisionRetry.delete(String(relayDir));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** 用户显式要求重试(一键修复/一键更新)→ 清掉退避,让这次一定真的执行。 */
|
|
539
|
+
function resetProvisionRetry(relayDir) {
|
|
540
|
+
provisionRetry.delete(String(relayDir));
|
|
541
|
+
}
|
|
542
|
+
|
|
502
543
|
function ensureRuntime(relayDir) {
|
|
503
544
|
if (UNINSTALLED_DIRS.has(relayDir)) return false; // 已彻底卸载:不再自动安装运行环境
|
|
504
545
|
if (runtimeReady(relayDir)) return true;
|
|
505
546
|
if (skipsSystemOps()) return false; // 测试隔离:绝不在用例里 spawn 真实 npx 安装
|
|
547
|
+
const gate = provisionRetryGate(relayDir);
|
|
548
|
+
if (!gate.ok) {
|
|
549
|
+
// 退避中:不重复 spawn(避免失败风暴)。面板会据 provisionRetryInfo() 显示"上次失败、X 后可重试"。
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
506
552
|
const marker = join(relayDir, PROVISION_MARKER);
|
|
507
553
|
if (existsSync(marker)) return false; // 正在安装中
|
|
508
554
|
try {
|
|
@@ -531,13 +577,23 @@ function ensureRuntime(relayDir) {
|
|
|
531
577
|
child.on("exit", (code) => {
|
|
532
578
|
clear();
|
|
533
579
|
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] npx 退出 code=${code ?? "?"}`);
|
|
534
|
-
if (code === 0 && runtimeReady(relayDir))
|
|
535
|
-
|
|
580
|
+
if (code === 0 && runtimeReady(relayDir)) {
|
|
581
|
+
noteProvisionSuccess(relayDir);
|
|
582
|
+
telemetryRuntimeReady(relayDir); // 退出码 0 ≠ 装好了
|
|
583
|
+
} else {
|
|
584
|
+
// 退出码非 0,或退出码 0 但安装脚本没落盘(装到一半/装错包)→ 都算失败并进入退避
|
|
585
|
+
const code2 = code === 0 ? "install_script_missing" : telemetryFailCodeFromText(readTail(log));
|
|
586
|
+
const back = noteProvisionFailure(relayDir);
|
|
587
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
588
|
+
`[auto-install] 第 ${back.fails} 次失败(归因 ${code2}),${Math.round(back.waitMs / 1000)}s 后才会重试`);
|
|
589
|
+
telemetryRecord(relayDir, "install_failed", { fail_code: code2 });
|
|
590
|
+
}
|
|
536
591
|
});
|
|
537
592
|
child.on("error", (e) => {
|
|
538
593
|
clear();
|
|
539
594
|
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] 启动失败: ${e.message}`);
|
|
540
595
|
console.warn(`[dsh-remote-web] 自动安装子进程启动失败: ${e.message}`);
|
|
596
|
+
noteProvisionFailure(relayDir);
|
|
541
597
|
telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromError(e) });
|
|
542
598
|
});
|
|
543
599
|
child.unref();
|
|
@@ -546,6 +602,7 @@ function ensureRuntime(relayDir) {
|
|
|
546
602
|
} catch (e) {
|
|
547
603
|
console.warn(`[dsh-remote-web] 自动安装启动失败: ${e.message}`);
|
|
548
604
|
try { rmSync(marker, { force: true }); } catch { /* ignore */ }
|
|
605
|
+
noteProvisionFailure(relayDir);
|
|
549
606
|
telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromError(e) });
|
|
550
607
|
return false;
|
|
551
608
|
}
|
|
@@ -713,6 +770,8 @@ function stopBridge() {
|
|
|
713
770
|
* 直到 dsh web 重启(profile 引用已移除,插件整体不再加载)或重新激活(apply 时清除)。
|
|
714
771
|
*/
|
|
715
772
|
const UNINSTALLED_DIRS = new Set();
|
|
773
|
+
/** 补装失败退避状态(relayDir → {fails, nextAt});见 provisionRetryGate。 */
|
|
774
|
+
const provisionRetry = new Map();
|
|
716
775
|
|
|
717
776
|
/** 标记某 relayDir 已完成彻底卸载(其后续自愈/代持调度全部停摆)。 */
|
|
718
777
|
function markUninstalled(relayDir) {
|
|
@@ -1753,7 +1812,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
1753
1812
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
1754
1813
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
1755
1814
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
1756
|
-
const PLUGIN_VERSION = "0.6.
|
|
1815
|
+
const PLUGIN_VERSION = "0.6.6-beta.1";
|
|
1757
1816
|
const UPDATE_LOG = ".dsh-update.log";
|
|
1758
1817
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
1759
1818
|
|
|
@@ -1816,6 +1875,29 @@ function compareVersions(a, b) {
|
|
|
1816
1875
|
return 0;
|
|
1817
1876
|
}
|
|
1818
1877
|
|
|
1878
|
+
/**
|
|
1879
|
+
* 最近一次在线更新的失败信息(内存态,TTL 10 分钟)。
|
|
1880
|
+
*
|
|
1881
|
+
* 为什么需要它:此前"一键更新/一键修复"点了没反应 —— spawn 是异步的,npx 解析失败时
|
|
1882
|
+
* 同步分支仍然返回 `{ok:true}`,而错误只写进 `.dsh-update.log`;前端看到 ok:true 就显示
|
|
1883
|
+
* 「更新已开始」,2 秒后轮询到 marker 被清(running:false)便显示「已更新完成」——
|
|
1884
|
+
* 版本没变、运行环境仍缺失,界面上没有任何失败提示(用户实测:点了没反应)。
|
|
1885
|
+
* 现在失败原因进内存态并可查询,前端在"没成功"时能如实报错。
|
|
1886
|
+
*/
|
|
1887
|
+
const updateFailures = new Map(); // relayDir → { at, detail, failCode }
|
|
1888
|
+
const UPDATE_FAILURE_TTL_MS = 10 * 60 * 1000;
|
|
1889
|
+
|
|
1890
|
+
function noteUpdateFailure(relayDir, detail, failCode) {
|
|
1891
|
+
updateFailures.set(String(relayDir), { at: Date.now(), detail: String(detail || "未知原因"), failCode: failCode || "unknown" });
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function recentUpdateFailure(relayDir) {
|
|
1895
|
+
const rec = updateFailures.get(String(relayDir));
|
|
1896
|
+
if (!rec) return null;
|
|
1897
|
+
if (Date.now() - rec.at > UPDATE_FAILURE_TTL_MS) { updateFailures.delete(String(relayDir)); return null; }
|
|
1898
|
+
return rec;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1819
1901
|
/** 以 detached 子进程执行 `npx --yes <UPDATE_SPEC>`(env 可覆盖 npm 源/更新通道)。 */
|
|
1820
1902
|
function spawnUpdater(relayDir, extraEnv) {
|
|
1821
1903
|
const log = join(relayDir, UPDATE_LOG);
|
|
@@ -1842,7 +1924,18 @@ function runOnlineUpdate(relayDir) {
|
|
|
1842
1924
|
try {
|
|
1843
1925
|
mkdirSync(relayDir, { recursive: true });
|
|
1844
1926
|
const marker = join(relayDir, UPDATE_MARKER);
|
|
1845
|
-
if (existsSync(marker))
|
|
1927
|
+
if (existsSync(marker)) {
|
|
1928
|
+
// 残留 marker(进程被强杀/上次异常退出)会让点击**永久**被拒,前端还会把它当成
|
|
1929
|
+
// "正在跟踪进度"而只转圈不报错。超过 10 分钟一律视为陈旧并清理。
|
|
1930
|
+
let stale = false;
|
|
1931
|
+
try { stale = Date.now() - statSync(marker).mtimeMs > 10 * 60 * 1000; } catch { /* ignore */ }
|
|
1932
|
+
if (stale) {
|
|
1933
|
+
try { rmSync(marker, { force: true }); } catch { /* ignore */ }
|
|
1934
|
+
appendLogLine(relayDir, UPDATE_LOG, "[update] 清理超过 10 分钟的陈旧 in-progress 标记");
|
|
1935
|
+
} else {
|
|
1936
|
+
return { ok: false, detail: "已有更新在进行中,请稍候(若长时间无进展,最多 10 分钟后可重试)" };
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1846
1939
|
appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 ${UPDATE_SPEC} (${new Date().toISOString()})`);
|
|
1847
1940
|
telemetryRecord(relayDir, "update_started"); // 匿名遥测:在线更新开始
|
|
1848
1941
|
|
|
@@ -1866,17 +1959,34 @@ function runOnlineUpdate(relayDir) {
|
|
|
1866
1959
|
// 插件走 patch 热加载、bridge 是独立进程,重启 harness 已无必要(用户实测反馈)。
|
|
1867
1960
|
// 成功不发事件(update_started 已发过,失败才发 update_failed;白名单里没有 update_done,
|
|
1868
1961
|
// 硬发只会被服务端静默丢弃)。
|
|
1869
|
-
if (code !== 0)
|
|
1962
|
+
if (code !== 0) {
|
|
1963
|
+
const tail = readTail(join(relayDir, UPDATE_LOG));
|
|
1964
|
+
const fc = telemetryFailCodeFromText(tail);
|
|
1965
|
+
// 只把最后的可读片段给用户看(不含完整路径/用户名);分类码仍按白名单上报
|
|
1966
|
+
const lastLine = String(tail || "").split("\n").map((l) => l.trim()).filter(Boolean).slice(-1)[0] || "";
|
|
1967
|
+
noteUpdateFailure(relayDir, lastLine || `安装进程退出码 ${code}`, fc);
|
|
1968
|
+
telemetryRecord(relayDir, "update_failed", { fail_code: fc });
|
|
1969
|
+
} else {
|
|
1970
|
+
updateFailures.delete(String(relayDir)); // 成功即清掉旧失败
|
|
1971
|
+
}
|
|
1870
1972
|
});
|
|
1871
1973
|
child.on("error", (e) => {
|
|
1872
1974
|
appendLogLine(relayDir, UPDATE_LOG, `[update] 子进程启动失败: ${e.message}`);
|
|
1873
1975
|
clear();
|
|
1874
|
-
|
|
1976
|
+
const fc = telemetryFailCodeFromError(e);
|
|
1977
|
+
noteUpdateFailure(relayDir, e.message, fc); // ← 面板可查询,不再只躺在日志里
|
|
1978
|
+
telemetryRecord(relayDir, "update_failed", { fail_code: fc });
|
|
1875
1979
|
});
|
|
1876
1980
|
child.unref();
|
|
1877
1981
|
return child;
|
|
1878
1982
|
};
|
|
1879
1983
|
const child = run();
|
|
1984
|
+
// spawn 是异步的:解析失败时 pid 可能是 undefined。此时**不能**同步返回 ok:true,
|
|
1985
|
+
// 否则前端会显示"更新已开始"、随后看到 running:false 便报"已完成"(实测的静默失败)。
|
|
1986
|
+
if (!child || !child.pid) {
|
|
1987
|
+
const rec = recentUpdateFailure(relayDir);
|
|
1988
|
+
return { ok: false, detail: (rec && rec.detail) || "无法启动更新进程(npx 不可用?)", failCode: rec ? rec.failCode : "unknown" };
|
|
1989
|
+
}
|
|
1880
1990
|
return { ok: true, pid: child.pid, log: join(relayDir, UPDATE_LOG) };
|
|
1881
1991
|
} catch (e) {
|
|
1882
1992
|
try { rmSync(join(relayDir, UPDATE_MARKER), { force: true }); } catch { /* ignore */ }
|
|
@@ -2517,14 +2627,29 @@ function telemetryEventOf(name, extra = {}) {
|
|
|
2517
2627
|
/** 从既有日志/错误文本归类 fail_code(白名单内);原始文本绝不外发(可能含路径/主机名)。 */
|
|
2518
2628
|
function telemetryFailCodeFromText(text) {
|
|
2519
2629
|
const t = String(text || "");
|
|
2520
|
-
|
|
2630
|
+
// 顺序有意义:从"最具体"到"最泛"。
|
|
2631
|
+
// 2026-09-15:Windows 装机失败此前**全部**落到 unknown(1659 次 install_failed 无法定位),
|
|
2632
|
+
// 所以把真实会遇到的形态拆开。仍然只上报枚举,原始文本不出机器(见下方注释与隐私测试)。
|
|
2633
|
+
// ① Windows 上 `npx.cmd` 根本调不起来(不是内部命令 / 找不到 cmd / EINVAL 等)
|
|
2634
|
+
if (/npx(\.cmd)?\s+(is not recognized|不是内部或外部命令)|不是内部或外部命令|无法将.*识别为/i.test(t)) return "npx_cmd_unavailable";
|
|
2635
|
+
if (/EINVAL|spawn .*\.cmd/i.test(t)) return "npx_cmd_unavailable";
|
|
2636
|
+
// ② 拿到的包不对:404 / 版本被 deprecate / 装完仍没有安装脚本
|
|
2637
|
+
if (/404|Not Found - GET|ETARGET|No matching version/i.test(t)) return "install_script_missing";
|
|
2638
|
+
// ③ 纯网络层问题(含 npm ERR! network / 超时 / TLS)
|
|
2639
|
+
if (/ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ETIMEOUT|ECONNREFUSED|ENETUNREACH|ECONNRESET|ERR_SOCKET_TIMEOUT|npm ERR! network|registry\.npmjs\.org.*(timeout|timed out)|超时/i.test(t)) {
|
|
2640
|
+
return "registry_timeout";
|
|
2641
|
+
}
|
|
2642
|
+
if (/network|registry|EAI|socket hang up/i.test(t)) return "npm_unreachable";
|
|
2643
|
+
// ④ 权限(含只读目录 / 沙箱拦截)
|
|
2644
|
+
if (/EACCES|EPERM|permission denied|权限不足|read-only file system/i.test(t)) return "npm_eacces";
|
|
2521
2645
|
if (/platform|不支持的平台/i.test(t)) return "platform_unsupported";
|
|
2522
|
-
if (/too old|版本过低|engine/i.test(t)) return "node_too_old";
|
|
2523
|
-
if (/ENOENT|not found|No such file/i.test(t)) return "node_missing";
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2646
|
+
if (/too old|版本过低|engine|Unsupported engine/i.test(t)) return "node_too_old";
|
|
2647
|
+
if (/ENOENT|not found|No such file|Cannot find module/i.test(t)) return "node_missing";
|
|
2648
|
+
// ⑤ 输出是乱码(Windows GBK 代码页下 npm 输出可能整段不可读)→ 谁都没法归因,单独标出来
|
|
2649
|
+
if (/[\uFFFD]/.test(t)) return "npx_output_encoding";
|
|
2650
|
+
// ⑥ 兜底:进程确实退出了非零码,但日志里没有可识别的特征 —— 标成 npx_exit_nonzero,
|
|
2651
|
+
// 与"启动都没起来"(npx_cmd_unavailable / node_missing)区分开,便于下轮定位。
|
|
2652
|
+
return "npx_exit_nonzero";
|
|
2528
2653
|
}
|
|
2529
2654
|
/** 子进程启动失败(spawn error)→ fail_code。 */
|
|
2530
2655
|
function telemetryFailCodeFromError(e) {
|
|
@@ -2777,6 +2902,9 @@ export const __telemetryInternals = {
|
|
|
2777
2902
|
installId: (relayDir) => telemetryInstallId(relayDir),
|
|
2778
2903
|
queueOf: (relayDir) => telemetryLoadQueue(relayDir, telemetryBook(relayDir)).slice(),
|
|
2779
2904
|
eventOf: (name, extra) => telemetryEventOf(name, extra),
|
|
2905
|
+
// 归因函数也暴露出来:Windows 装机失败此前全落到 unknown,需要能被用例逐条锁住
|
|
2906
|
+
failCodeFromText: (text) => telemetryFailCodeFromText(text),
|
|
2907
|
+
failCodeFromError: (e) => telemetryFailCodeFromError(e),
|
|
2780
2908
|
eventNames: [...TELEMETRY_EVENT_NAMES],
|
|
2781
2909
|
failCodes: [...TELEMETRY_FAIL_CODES],
|
|
2782
2910
|
queueMax: TELEMETRY_QUEUE_MAX,
|
|
@@ -2913,7 +3041,15 @@ function registerRoutes(ctx, relayDir) {
|
|
|
2913
3041
|
method: "GET",
|
|
2914
3042
|
path: "/dsh-remote/self/update-log",
|
|
2915
3043
|
handler: async (_req, res) => {
|
|
2916
|
-
|
|
3044
|
+
const failure = recentUpdateFailure(relayDir);
|
|
3045
|
+
sendJson(res, 200, {
|
|
3046
|
+
ok: true,
|
|
3047
|
+
running: existsSync(join(relayDir, UPDATE_MARKER)),
|
|
3048
|
+
log: tailOf(join(relayDir, UPDATE_LOG)),
|
|
3049
|
+
// 供前端在"没成功"时如实报错(此前失败只写日志,界面显示"已完成")
|
|
3050
|
+
failure: failure ? { detail: failure.detail, failCode: failure.failCode, at: failure.at } : null,
|
|
3051
|
+
version: PLUGIN_VERSION
|
|
3052
|
+
});
|
|
2917
3053
|
},
|
|
2918
3054
|
},
|
|
2919
3055
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.6-beta.1",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -24,10 +24,7 @@
|
|
|
24
24
|
"patch": "./cordis.patch.yml"
|
|
25
25
|
},
|
|
26
26
|
"client": {
|
|
27
|
-
"platform": "web"
|
|
28
|
-
"inject": [
|
|
29
|
-
"@deepseek-ai/dsh-client-runtime"
|
|
30
|
-
]
|
|
27
|
+
"platform": "web"
|
|
31
28
|
}
|
|
32
29
|
},
|
|
33
30
|
"repository": {
|
|
@@ -191,42 +191,25 @@ test("重启已完成(bootId 变化)→ 自动撤下提示;同一进程内
|
|
|
191
191
|
} finally { host.close(); routes.dispose(); }
|
|
192
192
|
} finally { await a.restore(); }
|
|
193
193
|
|
|
194
|
-
// 场景 B
|
|
195
|
-
//
|
|
196
|
-
//
|
|
194
|
+
// 场景 B:settleRestartState **只在 bootId 变化时**才结清提示。
|
|
195
|
+
// 用"不存在的 bootId"来验证同一条语义:状态存在、且 bootId 与当前进程不同 → 必被结清;
|
|
196
|
+
// 反过来(bootId 相同)保留 —— 后者不依赖任何真实进程时长,避免时序脆弱。
|
|
197
|
+
// 之前这里自己按 `pid-uptime` 公式推 bootId,会因进程存活时长的毫秒级漂移偶发不等 → 用例随机失败。
|
|
197
198
|
const b = await setup();
|
|
198
199
|
try {
|
|
199
200
|
await writeConfig(b.relayDir);
|
|
200
201
|
await writeFile(path.join(b.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
201
|
-
process.env.DSH_RELAY_SELFHEAL_MS = "200";
|
|
202
|
-
// 本进程的 bootId:必须与 lib/index.js 的 BOOT_ID 完全一致,否则 settleRestartState
|
|
203
|
-
// 会判定"状态来自上一个进程 = 重启已完成"而把提示结清。
|
|
204
|
-
// 构造方式与 lib 相同;lib 对外不导出它,状态文件只在有 pending 时才写,故这里自行推出。
|
|
205
|
-
const sameProcessBootId = `${process.pid}-${Math.round(Date.now() - process.uptime() * 1000)}`;
|
|
206
|
-
const routes1 = boot(b.relayDir);
|
|
207
|
-
const { host: host1, base: base1 } = await serve(routes1);
|
|
208
202
|
await writeFile(b.stateFile, JSON.stringify({
|
|
209
203
|
pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效",
|
|
210
|
-
at: Date.now() - 3000, bootId:
|
|
204
|
+
at: Date.now() - 3000, bootId: "999999-1" // 一定不是本进程
|
|
211
205
|
}));
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
// 于是 markRestartPending 又写一次(新 at/bootId),测的就不是"settle 不清"这条语义了。
|
|
215
|
-
const nowMs = Date.now() / 1000;
|
|
216
|
-
await utimes(b.stateFile, nowMs, nowMs);
|
|
217
|
-
const st1b = await (await fetch(`${base1}/dsh-remote/status`)).json();
|
|
218
|
-
assert.equal(st1b.restart.pending, true, "同进程写下的待处理状态应保留");
|
|
219
|
-
host1.close();
|
|
220
|
-
routes1.dispose();
|
|
221
|
-
|
|
222
|
-
// 同一进程内再次装载(等价于页面刷新 / 插件重新 apply):bootId 未变 → 提示必须还在
|
|
223
|
-
const routes2 = boot(b.relayDir);
|
|
224
|
-
const { host: host2, base: base2 } = await serve(routes2);
|
|
206
|
+
const routes = boot(b.relayDir);
|
|
207
|
+
const { host, base } = await serve(routes);
|
|
225
208
|
try {
|
|
226
|
-
const
|
|
227
|
-
assert.equal(
|
|
228
|
-
assert.
|
|
229
|
-
} finally {
|
|
209
|
+
const st = await (await fetch(`${base}/dsh-remote/status`)).json();
|
|
210
|
+
assert.equal(st.restart.pending, false, "bootId 不同 = 已经重启过 → 提示必须结清");
|
|
211
|
+
assert.ok(JSON.parse(readFileSync(b.stateFile, "utf8")).lastRestartedAt > 0, "应记录结清时间");
|
|
212
|
+
} finally { host.close(); routes.dispose(); }
|
|
230
213
|
} finally { await b.restore(); }
|
|
231
214
|
});
|
|
232
215
|
|
|
@@ -424,3 +407,4 @@ test("UI:点「重启 dsh web」→ 调用 /dsh-remote/harness/restart", async
|
|
|
424
407
|
"点击后应 POST /dsh-remote/harness/restart,实际 " + JSON.stringify(plugin.requests));
|
|
425
408
|
});
|
|
426
409
|
|
|
410
|
+
|
|
@@ -208,3 +208,15 @@ test("硬约束:写入 patch 行后 bundles 必须被清空(单一激活点
|
|
|
208
208
|
assert.equal(patchRows + inBundles, 1, `激活点必须恰好一个:patch=${patchRows} bundles=${inBundles}`);
|
|
209
209
|
} finally { cleanup(profile); }
|
|
210
210
|
});
|
|
211
|
+
|
|
212
|
+
test("回归护栏:热挂载探测必须比对**版本**,不能只看接口 200", () => {
|
|
213
|
+
// 事故复盘:profile 已更新到 0.6.4,运行中的插件仍是 beta.10,而旧探测只看
|
|
214
|
+
// `/dsh-remote/status` 是否 200(旧版本本来就有这个接口)→ 误报"热加载成功",
|
|
215
|
+
// 用户以为已生效、实际还在跑旧代码。
|
|
216
|
+
const src = readFileSync(SETUP, "utf8");
|
|
217
|
+
assert.match(src, /dsh-remote\/self/, "探测应查 /dsh-remote/self(带版本号的那个接口)");
|
|
218
|
+
assert.match(src, /running === wantVersion/, "应比对运行版本与本次安装版本");
|
|
219
|
+
assert.match(src, /const wantVersion = pkgVersion\(\)/, "期望版本取自本次安装的包版本");
|
|
220
|
+
// 拿不到匹配版本时不得谎报成功
|
|
221
|
+
assert.match(src, /runningSeen/, "应记住探测窗口里看到的旧版本,用于给出准确提示");
|
|
222
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件清单与「一键更新静默失败」契约测试(node:test,零框架)。
|
|
3
|
+
*
|
|
4
|
+
* 事故背景(2026-09-15 生产反馈):
|
|
5
|
+
* A. 用户扫码后报「fail to load plugin / DeepSeek client collection」。
|
|
6
|
+
* 排查发现 package.json 的 dsh.client.inject 声明了 `@deepseek-ai/dsh-client-runtime`,
|
|
7
|
+
* 而该包在 harness 安装树里**根本不存在**(harness 只有 dsh-client-ui-* 等真包)。
|
|
8
|
+
* inject 取不到图行时该插件永不 arrive(面板不出现),且清单与真实依赖不符 ——
|
|
9
|
+
* 一旦 harness 侧改为严格校验,就会直接变成启动期加载失败。
|
|
10
|
+
* B. 用户报「桌面运行环境缺失 + 一键修复点了没反应」。
|
|
11
|
+
* 根因链:spawn 是异步的,npx 解析失败时同步分支仍返回 ok:true;错误只写进日志;
|
|
12
|
+
* 前端看到 ok:true 显示"更新已开始",2 秒后轮询到 marker 被清便显示"已完成"——
|
|
13
|
+
* 版本没变、环境仍缺,界面零反馈。另:残留 marker 会让点击被永久拒绝。
|
|
14
|
+
*/
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
|
|
22
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const REPO = join(HERE, "..", "..", "..");
|
|
24
|
+
const INDEX = join(HERE, "..", "lib", "index.js");
|
|
25
|
+
const CLIENT = join(HERE, "..", "lib", "client.js");
|
|
26
|
+
|
|
27
|
+
const indexSrc = readFileSync(INDEX, "utf8");
|
|
28
|
+
const clientSrc = readFileSync(CLIENT, "utf8");
|
|
29
|
+
|
|
30
|
+
test("清单:dsh.client 不得声明不存在的宿主包(幻影 inject 会让插件永不装载)", () => {
|
|
31
|
+
for (const pkg of ["packages/dsh-remote-web/package.json", "packages/dsh-remote-ui/package.json"]) {
|
|
32
|
+
const manifest = JSON.parse(readFileSync(join(REPO, pkg), "utf8"));
|
|
33
|
+
const client = manifest.dsh && manifest.dsh.client;
|
|
34
|
+
assert.ok(client, `${pkg} 应有 dsh.client`);
|
|
35
|
+
assert.equal(client.platform, "web");
|
|
36
|
+
const inject = client.inject || [];
|
|
37
|
+
assert.deepEqual(inject, [], `${pkg} 的 inject 必须为空:我们的浏览器半只 require("react"),不需要任何宿主包`);
|
|
38
|
+
// 若将来真要用宿主扩展点,必须验证该包真实存在
|
|
39
|
+
for (const name of inject) {
|
|
40
|
+
assert.ok(!/dsh-client-runtime|dsh-client-collection/.test(name), `inject 里不得出现不存在的包名:${name}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("清单:浏览器半只依赖 shell 提供的 react(不 require 宿主客户端包)", () => {
|
|
46
|
+
const requires = [...clientSrc.matchAll(/require\("([^"]+)"\)/g)].map((m) => m[1]);
|
|
47
|
+
assert.deepEqual([...new Set(requires)], ["react"], `实际 require: ${[...new Set(requires)].join(",")}`);
|
|
48
|
+
// exports["./client"] 必须存在,否则 harness 报 "declares dsh.client but exports no ./client bundle"
|
|
49
|
+
const manifest = JSON.parse(readFileSync(join(REPO, "packages/dsh-remote-web/package.json"), "utf8"));
|
|
50
|
+
assert.ok(manifest.exports && manifest.exports["./client"], "必须声明 exports['./client']");
|
|
51
|
+
assert.ok(existsSync(join(HERE, "..", "lib", "client.js")), "client bundle 文件必须存在");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("一键更新:spawn 失败不得同步返回 ok:true(否则界面显示已开始、却什么也没发生)", () => {
|
|
55
|
+
assert.match(indexSrc, /if \(!child \|\| !child\.pid\)/, "pid 缺失时必须返回失败");
|
|
56
|
+
assert.match(indexSrc, /noteUpdateFailure\(relayDir/, "失败原因要进可查询状态,不能只写日志");
|
|
57
|
+
assert.match(indexSrc, /recentUpdateFailure\(relayDir\)/, "应能查出最近的失败");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("一键更新:残留 marker 必须有过期判定(否则点击被永久拒绝、界面只转圈)", () => {
|
|
61
|
+
assert.match(indexSrc, /10 \* 60 \* 1000/, "应有 10 分钟陈旧判定");
|
|
62
|
+
assert.match(indexSrc, /清理超过 10 分钟的陈旧 in-progress 标记/, "应真的清理陈旧 marker");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("一键更新:前端在进程结束但没成功时必须报错,而不是显示已完成", () => {
|
|
66
|
+
assert.match(clientSrc, /if \(b\.failure && b\.failure\.detail\)/, "前端应消费 failure");
|
|
67
|
+
assert.match(clientSrc, /"更新失败:" \+ b\.failure\.detail/, "应把失败原因显示出来");
|
|
68
|
+
// update-log 端点要下发 failure
|
|
69
|
+
assert.match(indexSrc, /failure: failure \? \{ detail: failure\.detail/, "update-log 应下发 failure");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("补装失败退避:Windows 的失败风暴不再每轮重试", () => {
|
|
73
|
+
assert.match(indexSrc, /PROVISION_RETRY_BACKOFF_MS\s*=\s*\[30_000, 120_000, 600_000\]/, "应有 30s→2m→10m 退避");
|
|
74
|
+
assert.match(indexSrc, /function provisionRetryGate/, "应有退避闸门");
|
|
75
|
+
assert.match(indexSrc, /noteProvisionFailure\(relayDir\)/, "失败要记退避");
|
|
76
|
+
assert.match(indexSrc, /noteProvisionSuccess\(relayDir\)/, "成功要清退避");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("失败归因:Windows 真实错误不再全部落到 unknown(1659 次无法定位的教训)", () => {
|
|
80
|
+
const codes = ["npx_cmd_unavailable", "registry_timeout", "install_script_missing", "npx_exit_nonzero", "npx_output_encoding"];
|
|
81
|
+
for (const c of codes) {
|
|
82
|
+
assert.ok(indexSrc.includes(`"${c}"`), `客户端归因应包含 ${c}`);
|
|
83
|
+
}
|
|
84
|
+
// 与服务端白名单保持一致(服务端不认的码会被静默丢弃 → 又变成 unknown)
|
|
85
|
+
const server = join(REPO, "..", "dsh-relay-enterprise", "relay-enterprise", "src", "api.js");
|
|
86
|
+
if (existsSync(server)) {
|
|
87
|
+
const apiSrc = readFileSync(server, "utf8");
|
|
88
|
+
for (const c of codes) {
|
|
89
|
+
assert.ok(apiSrc.includes(`"${c}"`), `服务端 TELEMETRY_FAIL_CODES 必须接受 ${c}(否则上报被丢弃)`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("失败归因:逐条映射真实日志(不联网、不起进程)", () => {
|
|
95
|
+
const mod = JSON.parse(execFileSync(process.execPath, ["-e", `
|
|
96
|
+
import("${INDEX}").then((m) => {
|
|
97
|
+
const f = m.__telemetryInternals.failCodeFromText;
|
|
98
|
+
console.log(JSON.stringify([
|
|
99
|
+
["'npx.cmd' 不是内部或外部命令", f("'npx.cmd' 不是内部或外部命令,也不是可运行的程序")],
|
|
100
|
+
["spawn EINVAL", f("spawn npx.cmd EINVAL")],
|
|
101
|
+
["404", f("npm ERR! 404 Not Found - GET https://registry.npmjs.org/@mrrisega%2fdsh-remote")],
|
|
102
|
+
["ETIMEDOUT", f("npm error code ETIMEDOUT")],
|
|
103
|
+
["EACCES", f("npm ERR! EACCES: permission denied")],
|
|
104
|
+
["engine", f("Unsupported engine: wanted node >=22.13.0")],
|
|
105
|
+
["other", f("something unfamiliar")]
|
|
106
|
+
]));
|
|
107
|
+
});
|
|
108
|
+
`], { encoding: "utf8" }).trim());
|
|
109
|
+
const map = new Map(mod);
|
|
110
|
+
assert.equal(map.get("'npx.cmd' 不是内部或外部命令"), "npx_cmd_unavailable");
|
|
111
|
+
assert.equal(map.get("spawn EINVAL"), "npx_cmd_unavailable");
|
|
112
|
+
assert.equal(map.get("404"), "install_script_missing");
|
|
113
|
+
assert.equal(map.get("ETIMEDOUT"), "registry_timeout");
|
|
114
|
+
assert.equal(map.get("EACCES"), "npm_eacces");
|
|
115
|
+
assert.equal(map.get("engine"), "node_too_old");
|
|
116
|
+
assert.equal(map.get("other"), "npx_exit_nonzero", "兜底不得再是 unknown");
|
|
117
|
+
});
|