@mrrisega/dsh-remote 0.6.13 → 0.6.14

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.
@@ -370,8 +370,23 @@ test("运行时 dsh-setup.mjs:上游端口只有一处真相源,watcher/子
370
370
  const code = SETUP_SRC.split("\n").filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join("\n");
371
371
  assert.ok(!/fetch\("http:\/\/127\.0\.0\.1:3080/.test(code), "探测不得写死 3080(dsh web 可能在别的端口)");
372
372
  assert.ok(!/findstr ":3080"|iTCP:3080/.test(code), "按端口找 dsh web 进程也不得写死 3080");
373
- assert.ok(/fetch\(upstreamUrl\(\) \+ "\/"\)/.test(code), "watcher 的在线判据必须用 upstreamUrl()");
373
+ assert.ok(/async function upstreamReachable\(url = upstreamUrl\(\)/.test(code), "在线判据必须用解析出来的上游地址");
374
+ assert.ok(/await upstreamReachable\(upstreamUrl\(\)/.test(code), "isDshWebUp 必须基于 upstreamUrl() 探测");
374
375
  assert.ok(/DSH_BRIDGE_UPSTREAM: upstreamUrl\(\)/.test(code), "必须把上游显式透传给 bridge 子进程");
376
+ // ── 上游端口动态发现(2026-09-23 第二起实测:DSH Desktop 默认 43120,占用还会 +1)──
377
+ // 只靠"端口文件"不够:文件可能还没写、或写着上一轮的旧端口;那时必须自己去找。
378
+ assert.ok(
379
+ /from "\.\/clients\/dsh-remote\/upstream-discovery\.mjs"/.test(SETUP_SRC),
380
+ "必须复用共享的上游发现实现(watcher/bridge 同一份,避免两边漂移)"
381
+ );
382
+ assert.ok(
383
+ /const after = await refreshUpstreamDiscovery\(true\)/.test(code),
384
+ "探测失败必须主动做一次动态发现再试(否则端口一变就永远卡在 starting)"
385
+ );
386
+ assert.ok(
387
+ /await refreshUpstreamDiscovery\(true\);/.test(code),
388
+ "启动时先发现再打印:日志不得再谎称固定端口"
389
+ );
375
390
  // 半装运行时的根因之一:本安装器自己 import 的 src/lifecycle.mjs 必须在同步表里
376
391
  assert.ok(/"src\/lifecycle\.mjs"/.test(SETUP_SRC), "RUNTIME_CLIENT_FILES 必须包含 src/lifecycle.mjs(否则会漏同步出半装运行时)");
377
392
  });
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 目录选择器固定为「浏览器内实现」的契约测试。
4
+ *
5
+ * 用户实测(Windows):镜像页点「添加工作区」**没反应**;同功能在 Mac 上正常。
6
+ * 根因:官方 `directory-picker-auto` 只在**绑定非回环**时才改选 browse,否则 darwin/win32 一律挂
7
+ * **原生**选择器 —— 那个对话框弹在**电脑那台机器的屏幕上**,拿手机的人看不到;
8
+ * Windows 那条原生链路还要 koffi 原生模块 + worker 子进程(合成 Alt 抢前台),失败点更多。
9
+ * 远程控制不能把"手机能不能选工作区"押在宿主的绑定地址上,所以插件主动把它钉成 browse。
10
+ *
11
+ * 这里守的是三条护栏(顺序错一条就会把用户的选择器搞没):
12
+ * ① 已是 browse → 一根手指都不许动;
13
+ * ② **先建 browse、后摘 auto**:browse 建不起来时必须原样保留原生;
14
+ * ③ 全程不抛异常(插件绝不能把 dsh web 拖挂)。
15
+ *
16
+ * 用法: node --test test/picker-pin.test.mjs
17
+ */
18
+ import assert from "node:assert/strict";
19
+ import { readFileSync } from "node:fs";
20
+ import path from "node:path";
21
+ import test from "node:test";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ import {
25
+ __pinBrowseDirectoryPicker,
26
+ __pickerPinState,
27
+ __PICKER_AUTO_ENTRY_ID,
28
+ __PICKER_BROWSE_PACKAGES
29
+ } from "../lib/index.js";
30
+
31
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
32
+ const SRC = readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8");
33
+
34
+ /** 假宿主:loader 记录 create/remove 的**调用顺序**,directoryPicker 报告当前 kind。 */
35
+ function fakeCtx({ kind = "native", failCreateOn = null } = {}) {
36
+ const calls = [];
37
+ const store = { [__PICKER_AUTO_ENTRY_ID]: { id: __PICKER_AUTO_ENTRY_ID } };
38
+ const loader = {
39
+ store,
40
+ async create({ name }) {
41
+ calls.push(`create:${name}`);
42
+ if (failCreateOn && name === failCreateOn) throw new Error("browse 装不起来");
43
+ const id = `dyn-${calls.length}`;
44
+ store[id] = { id };
45
+ return id;
46
+ },
47
+ async remove(id) {
48
+ calls.push(`remove:${id}`);
49
+ delete store[id];
50
+ }
51
+ };
52
+ return {
53
+ ctx: {
54
+ get(name) {
55
+ if (name === "loader") return loader;
56
+ if (name === "directoryPicker") return { capability: () => ({ kind }) };
57
+ return null;
58
+ },
59
+ logger: { info() {}, warn() {} }
60
+ },
61
+ calls,
62
+ store
63
+ };
64
+ }
65
+
66
+ const withEnv = async (value, fn) => {
67
+ const prev = process.env.DSH_REMOTE_NATIVE_PICKER;
68
+ if (value === undefined) delete process.env.DSH_REMOTE_NATIVE_PICKER;
69
+ else process.env.DSH_REMOTE_NATIVE_PICKER = value;
70
+ try { return await fn(); } finally {
71
+ if (prev === undefined) delete process.env.DSH_REMOTE_NATIVE_PICKER;
72
+ else process.env.DSH_REMOTE_NATIVE_PICKER = prev;
73
+ }
74
+ };
75
+
76
+ test("★ 原生(native)→ 换成 browse:先建 browse 那一对,再摘 auto 条目", async () => {
77
+ const { ctx, calls, store } = fakeCtx({ kind: "native" });
78
+ const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
79
+
80
+ assert.deepEqual(calls, [
81
+ `create:${__PICKER_BROWSE_PACKAGES[0]}`,
82
+ `create:${__PICKER_BROWSE_PACKAGES[1]}`,
83
+ `remove:${__PICKER_AUTO_ENTRY_ID}`
84
+ ], "顺序必须是「先建 browse、后摘 auto」——反过来一旦建失败,用户就没有选择器了");
85
+ assert.equal(store[__PICKER_AUTO_ENTRY_ID], undefined, "auto 条目应被摘除(它的析构会卸掉 native 那对)");
86
+ assert.equal(state.action, "pinned");
87
+ assert.equal(state.kind, "browse");
88
+ assert.match(state.detail, /远程/, "详情要写明为什么改(远程看不到原生对话框)");
89
+ });
90
+
91
+ test("已经是 browse → 完全不干预(自建部署 / 绑了 0.0.0.0 的场景)", async () => {
92
+ const { ctx, calls } = fakeCtx({ kind: "browse" });
93
+ const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
94
+ assert.deepEqual(calls, [], "不得创建、也不得摘除任何条目");
95
+ assert.equal(state.action, "none");
96
+ });
97
+
98
+ test("★ browse 装不起来 → 原样保留原生(宁可原生可用,也不能让选择器消失)", async () => {
99
+ const { ctx, calls, store } = fakeCtx({ kind: "native", failCreateOn: __PICKER_BROWSE_PACKAGES[0] });
100
+ const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
101
+ assert.ok(!calls.some((c) => c.startsWith(`remove:${__PICKER_AUTO_ENTRY_ID}`)), "摘 auto 必须发生在 browse 建成之后");
102
+ assert.ok(store[__PICKER_AUTO_ENTRY_ID], "auto 条目必须还在");
103
+ assert.equal(state.action, "kept_native");
104
+ assert.match(state.detail, /保留原生/);
105
+ });
106
+
107
+ test("第二个 browse 包失败 → 回滚已建的那个,并保留原生", async () => {
108
+ const { ctx, calls, store } = fakeCtx({ kind: "native", failCreateOn: __PICKER_BROWSE_PACKAGES[1] });
109
+ const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
110
+ assert.equal(calls[0], `create:${__PICKER_BROWSE_PACKAGES[0]}`);
111
+ assert.equal(calls[1], `create:${__PICKER_BROWSE_PACKAGES[1]}`);
112
+ assert.ok(calls[2] && calls[2].startsWith("remove:"), "失败后必须回滚刚建的宿主");
113
+ assert.ok(store[__PICKER_AUTO_ENTRY_ID], "auto 条目必须还在");
114
+ assert.equal(state.action, "kept_native");
115
+ });
116
+
117
+ test("DSH_REMOTE_NATIVE_PICKER=1 → 整体退出,保留系统原生对话框", async () => {
118
+ const { ctx, calls } = fakeCtx({ kind: "native" });
119
+ const state = await withEnv("1", () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
120
+ assert.deepEqual(calls, []);
121
+ assert.equal(state.action, "skipped");
122
+ assert.match(state.detail, /DSH_REMOTE_NATIVE_PICKER/);
123
+ });
124
+
125
+ test("服务还没挂上(kind 未知)→ 不抛错、按 skipped 收尾", async () => {
126
+ const { ctx, calls } = fakeCtx({ kind: "" });
127
+ const state = await withEnv(undefined, () => __pinBrowseDirectoryPicker(ctx, { attempts: 1, gapMs: 1 }));
128
+ assert.deepEqual(calls, []);
129
+ assert.equal(state.action, "skipped");
130
+ });
131
+
132
+ test("没有 loader 服务 / ctx.get 抛错 → 静默跳过(绝不把宿主拖挂)", async () => {
133
+ const noLoader = { get: () => null, logger: { info() {}, warn() {} } };
134
+ const throwing = { get() { throw new Error("ctx destroyed"); }, logger: {} };
135
+ const a = await __pinBrowseDirectoryPicker(noLoader, { attempts: 1, gapMs: 1 });
136
+ const b = await __pinBrowseDirectoryPicker(throwing, { attempts: 1, gapMs: 1 });
137
+ assert.equal(a.action, "skipped");
138
+ assert.equal(b.action, "skipped");
139
+ });
140
+
141
+ test("诊断面板必须显示目录选择器状态(这类'按钮没反应'才能一眼定位)", () => {
142
+ assert.match(SRC, /`目录选择器: \$\{/, "诊断行缺失");
143
+ assert.match(SRC, /系统原生(远程时对话框弹在电脑那台机器上/, "原生时要给出可操作的解释");
144
+ });
145
+
146
+ test("接线:apply 里挂了选择器固定(eff/effect),并且是 fire-and-forget 不阻塞装载", () => {
147
+ assert.match(SRC, /ctx\.effect\(\(\) => \{[\s\S]{0,200}?pinBrowseDirectoryPicker\(ctx\)[\s\S]{0,120}?\}, "dsh-remote-web: pin browser directory picker"\);/);
148
+ });
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * watcher 的上游端口发现回归 —— 复现 2026-09-23 两起真实事故的**用户可见形态**。
4
+ *
5
+ * 现场(用户报告,DSH Desktop 2.0.4 + 插件 0.6.10):
6
+ * ```
7
+ * 连接阶段: starting(正在启动 Bridge…)
8
+ * bridge 进程: 未运行 中继注册: 未注册 自动重试: 已尝试 6 次
9
+ * ```
10
+ * 面板永久停在 starting,设备从未登记。根因:**watcher 把上游写死 3080**,
11
+ * 而 DSH Desktop(Electron 壳 `dsh-plugin-desktop`)把 Web 服务放在 **43120**(占用则 +1)、
12
+ * 独立用户也可能 `dsh web --port 8090` / `--port 0`。探不到 → `checkUpstream()` 恒 false
13
+ * → **bridge 永远不被派生**,而面板只会说「正在启动 Bridge…」。
14
+ *
15
+ * 本用例锁的是**决策点**(不是实现细节):只要上游在**非默认端口**且应答像 dsh web,
16
+ * watcher 就必须走进「dsh web 在线,启动 bridge...」并带着**正确的**
17
+ * `DSH_BRIDGE_UPSTREAM` 去派生 bridge。
18
+ *
19
+ * 隔离手法与 runtime-provision / connect-loop 一致:临时 HOME + 假 launchctl/pgrep/ps/npx,
20
+ * 绝不用到本机真实服务;上游是**测试进程内**起的假 dsh web(真 dsh web 只在 3080,
21
+ * 那正是我们要避开的默认值)。
22
+ */
23
+ import assert from "node:assert/strict";
24
+ import { spawn } from "node:child_process";
25
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
26
+ import http from "node:http";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+ import test from "node:test";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
33
+ const SETUP = path.join(HERE, "..", "..", "..", "dsh-setup.mjs");
34
+
35
+ /** 假 dsh web 的应答:带官方特征(身份校验靠它),并记录被访问过。 */
36
+ const DSH_HTML = "<!doctype html><html><head><title>DeepSeek Harness</title></head>"
37
+ + "<body><script>__ModuleLoader__</script></body></html>";
38
+
39
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
40
+
41
+ /** 起一个假的上游(= 非默认端口上的 dsh web)。 */
42
+ async function startFakeDshWeb() {
43
+ const hits = [];
44
+ const srv = http.createServer((req, res) => {
45
+ hits.push(req.url);
46
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
47
+ res.end(DSH_HTML);
48
+ });
49
+ await new Promise((r) => srv.listen(0, "127.0.0.1", r));
50
+ return { srv, port: srv.address().port, hits, url: `http://127.0.0.1:${srv.address().port}` };
51
+ }
52
+
53
+ /** 临时 relayDir + 一份"已登录"的配置(指向本地死端口,绝不外联)。 */
54
+ function makeEnv() {
55
+ const root = mkdtempSync(path.join(os.tmpdir(), "dsh-upstream-port-"));
56
+ const home = path.join(root, "home");
57
+ const relayDir = path.join(root, "relay");
58
+ const bin = path.join(root, "bin");
59
+ for (const d of [home, relayDir, bin]) mkdirSync(d, { recursive: true });
60
+
61
+ // 假命令:让 watcher/插件的系统操作全部落空,绝不碰本机真实服务
62
+ const stub = (name, body) => {
63
+ const p = path.join(bin, name);
64
+ writeFileSync(p, `#!/bin/sh\n${body}\n`);
65
+ chmodSync(p, 0o755);
66
+ };
67
+ stub("launchctl", 'case "$1" in list) exit 0 ;; print) echo "could not find service" >&2; exit 113 ;; esac\nexit 0');
68
+ stub("pgrep", "exit 1");
69
+ stub("ps", 'case "$*" in *ppid*) echo 1 ;; *lstart*) echo "Mon Jan 1 00:00:00 2026" ;; esac\nexit 0');
70
+ stub("npx", "exit 127");
71
+
72
+ writeFileSync(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
73
+ phone: "13800000000",
74
+ password: "pw",
75
+ device_id: "dev-aaaaaaaaaaaa",
76
+ tunnel_url: "ws://127.0.0.1:1",
77
+ api_url: "http://127.0.0.1:1"
78
+ }, null, 2), { mode: 0o600 });
79
+
80
+ return { root, home, relayDir, bin };
81
+ }
82
+
83
+ /** 起 watcher,返回 {proc, out(), stop()}。 */
84
+ function startWatcher(env, extraEnv = {}) {
85
+ let out = "";
86
+ const proc = spawn(process.execPath, [SETUP, "run"], {
87
+ cwd: env.root,
88
+ env: {
89
+ ...process.env,
90
+ HOME: env.home,
91
+ DSH_RELAY_DIR: env.relayDir,
92
+ DSH_BRIDGE_CONFIG: path.join(env.relayDir, ".dsh-config.json"),
93
+ PATH: `${env.bin}:${process.env.PATH}`,
94
+ DSH_REMOTE_TELEMETRY: "0",
95
+ ...extraEnv
96
+ },
97
+ stdio: ["ignore", "pipe", "pipe"]
98
+ });
99
+ proc.stdout.on("data", (d) => { out += String(d); });
100
+ proc.stderr.on("data", (d) => { out += String(d); });
101
+ return {
102
+ proc,
103
+ out: () => out,
104
+ async stop() {
105
+ try { proc.kill("SIGKILL"); } catch { /* ignore */ }
106
+ await sleep(120);
107
+ }
108
+ };
109
+ }
110
+
111
+ const waitFor = async (fn, timeoutMs = 12_000) => {
112
+ const t0 = Date.now();
113
+ while (Date.now() - t0 < timeoutMs) {
114
+ if (fn()) return true;
115
+ await sleep(150);
116
+ }
117
+ return false;
118
+ };
119
+
120
+ test("★ 上游在非默认端口(DSH Desktop 场景):watcher 必须发现它并派生 bridge", async (t) => {
121
+ const env = makeEnv();
122
+ const upstream = await startFakeDshWeb();
123
+ let watcher = null;
124
+ try {
125
+ // 真实的 Desktop 情形:端口文件要么还没写、要么写着上一轮的旧端口
126
+ writeFileSync(path.join(env.relayDir, ".dsh-upstream"), "http://127.0.0.1:1\n", { mode: 0o600 });
127
+ // DSH_WEB_URL 是宿主真实端口的合法来源之一(Desktop 包就是用它把端口暴露给子进程的)
128
+ watcher = startWatcher(env, { DSH_WEB_URL: upstream.url });
129
+
130
+ const started = await waitFor(() => /dsh web 在线,启动 bridge/.test(watcher.out()));
131
+ assert.ok(
132
+ started,
133
+ `上游在 ${upstream.port}(非 3080)时 watcher 依然要启动 bridge。实际输出:\n${watcher.out().slice(0, 1200)}`
134
+ );
135
+ assert.ok(upstream.hits.length > 0, "watcher 必须真的探测了那个非默认端口");
136
+ assert.ok(
137
+ !/等待 dsh web(http:\/\/127\.0\.0\.1:3080)/.test(watcher.out()),
138
+ "日志不得再宣称自己在等 3080(用户实测就是被这句误导)"
139
+ );
140
+ // ★ 关键:派生出去的 bridge 必须拿到**被发现的那个端口**(否则它会自己去探 3080 并永久失败)
141
+ const gotBridgeUpstream = await waitFor(() => new RegExp(`上游地址已确定: http://127\\.0\\.0\\.1:${upstream.port}`).test(watcher.out()), 6000);
142
+ assert.ok(
143
+ gotBridgeUpstream,
144
+ `bridge 应被注入发现到的上游 ${upstream.url}。实际输出:\n${watcher.out().slice(0, 1200)}`
145
+ );
146
+ } finally {
147
+ if (watcher) await watcher.stop();
148
+ await new Promise((r) => upstream.srv.close(r));
149
+ rmSync(env.root, { recursive: true, force: true });
150
+ }
151
+ });
152
+
153
+ test("上游确实不在时:watcher 保持等待(不谎报在线,也不派生 bridge)", async (t) => {
154
+ const env = makeEnv();
155
+ // ⚠️ 必须用**显式** DSH_BRIDGE_UPSTREAM 钉死死端口:不加它时动态发现会去探 3080,
156
+ // 而开发机/CI 上真的可能有一个 dsh web 在 3080 —— 那样这条断言就变成看环境了。
157
+ // 显式值优先且不做探测(既有语义),于是"上游不可达"这件事在测试里是确定的。
158
+ let watcher = null;
159
+ try {
160
+ writeFileSync(path.join(env.relayDir, ".dsh-upstream"), "http://127.0.0.1:1\n", { mode: 0o600 });
161
+ watcher = startWatcher(env, { DSH_BRIDGE_UPSTREAM: "http://127.0.0.1:1" });
162
+ await sleep(3500);
163
+ const out = watcher.out();
164
+ assert.ok(!/dsh web 在线,启动 bridge/.test(out), `不得谎报在线:\n${out.slice(0, 600)}`);
165
+ assert.match(out, /等待 dsh web/, "应如实说明在等 dsh web");
166
+ } finally {
167
+ if (watcher) await watcher.stop();
168
+ rmSync(env.root, { recursive: true, force: true });
169
+ }
170
+ });