@mzzsfy/dsh-tunnel 0.1.0 → 0.1.2
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/README.md +7 -3
- package/package.json +16 -3
- package/src/client.js +62 -51
- package/src/index.js +10 -0
- package/test/_mocks.common-tools.mjs +20 -0
- package/test/ai-switch.test.mjs +6 -1
- package/test/client-bus-lifecycle.test.mjs +146 -0
- package/test/switch-guard.test.mjs +1 -1
- package/test/tunnel.test.mjs +19 -3
package/README.md
CHANGED
|
@@ -24,16 +24,20 @@
|
|
|
24
24
|
## 使用
|
|
25
25
|
|
|
26
26
|
- ai 通道: 会话内直接让 ai 开隧道 `tunnel_open`(name, targetPort, entry?, wsPaths?) / `tunnel_list` / `tunnel_close`; 「允许 ai 使用」开关(默认开)关闭即向 ai 注销三件工具, 看板与 REST 管理不受影响
|
|
27
|
-
- GUI 通道: 侧边栏「穿透隧道」面板手动添加/删除/复制访问地址(与 ai 同表); 设置卡两枚开关——「允许 ai 使用隧道工具」与「看板移入 better-sidebar 侧边栏」(设置 > 插件页 / 0.1.7+ 插件管理页包详情)
|
|
27
|
+
- GUI 通道: 侧边栏「穿透隧道」面板手动添加/删除/复制访问地址(与 ai 同表); 设置卡两枚开关——「允许 ai 使用隧道工具」与「看板移入 better-sidebar 侧边栏」(设置 > 插件页 / 0.1.7+ 插件管理页包详情);数据与偏好变更经公共 WS 总线 `@mzzsfy/dsh-tunnel` 插件通道推送;host/client 在 `apply(ctx)` 调用 `setupCommonTools(ctx)`,以 `ctx.fiber.entry.options.name` 的完整 npm 包名绑定通道,组件捕获所属 ctx 的 bus,卸载可提前退订;ctx 卸载释放连接持有与全部订阅
|
|
28
28
|
- 设置面: legacy(≤0.1.6) 设置 > 插件页独立配置节; 0.1.7+ Config volatile 字段自动配置节(connectTimeoutMs / aiTools / sidebarTab 均支持原地热更, dataDir 为环境路径不进 UI)。`connectTimeoutMs`(响应头等待超时, 改动即时生效, 无需重启)
|
|
29
29
|
- 持久化 `~/.dsh/dsh-tunnel/tunnels.json`(随 `DSH_HOME`), 重启自动恢复; 目标拒连 502, 响应头等待超时(默认 10s, 头到即解除, SSE 不受影响)504
|
|
30
|
-
- REST: `/api/tunnel/tunnels`(GET/POST/DELETE `/api/tunnel/tunnels/:name`) + `/api/tunnel/status` + `/api/tunnel/ui-settings`(
|
|
30
|
+
- REST: `/api/tunnel/tunnels`(GET/POST/DELETE `/api/tunnel/tunnels/:name`) + `/api/tunnel/status` + `/api/tunnel/ui-settings`(POST: aiTools / sidebarTab), 与 GUI/ai 同一入口
|
|
31
31
|
|
|
32
32
|
## 验证
|
|
33
33
|
|
|
34
|
-
- `node --test "test/*.test.mjs"`:
|
|
34
|
+
- `node --test "test/*.test.mjs"`: 37 场景 + 8 api 用例 + 11 ai 开关注册面 + 11 守卫断言 + client 端 8 用例(id 守卫 + 总线生命周期;mock webServer + 真实 http/net 端到端)
|
|
35
35
|
- `node scripts/compat/tunnel-fullchain.mjs`: 隔离 dsh 实例全链路(真实激活/恢复/分发面七项探针)
|
|
36
36
|
|
|
37
37
|
## 设计文档
|
|
38
38
|
|
|
39
39
|
`docs/调研-路径穿透插件.md`(「方案设计」两节为本包实施规格, 含已知限制); `docs/设计-隧道GUI.md`(GUI/设置/侧边栏注入实施规格)。
|
|
40
|
+
|
|
41
|
+
## dsh 版本兼容
|
|
42
|
+
|
|
43
|
+
按仓库动态窗口验证(scripts/compat/window.mjs),不预置任何固定版本槽:主测 = 渠道 dist-tag 最新 rc,完全适配口径;基线 = npm latest,不崩溃底线口径;主测与基线同版时收敛单槽。兼容面以窗口实测为准,不逐版本承诺。逐轮实测结论见 `docs/兼容性测试/逐包测试进度.md` 与本包 `兼容性测试.md`。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-tunnel",
|
|
3
3
|
"description": "路径穿透隧道:在 dsh 宿主进程内把 /p/<name> 反向代理到本地 TCP 端口(HTTP strip 转发 + 声明式 WS upgrade + 3xx Location 单头改写),支持 <name>.* 子域名全路径透传(任意 WS 路径);GUI 看板手动管理隧道,设置页独立配置节与侧边栏注入开关;向 ai 注册 tunnel_open/tunnel_list/tunnel_close 工具且「允许 ai 使用」可开关(关闭即注销);代理逻辑全在插件进程内,不引入 nginx/caddy 等外部反代",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -14,11 +14,21 @@
|
|
|
14
14
|
"patch": "./cordis.patch.yml"
|
|
15
15
|
},
|
|
16
16
|
"client": {
|
|
17
|
-
"platform": "web"
|
|
17
|
+
"platform": "web",
|
|
18
|
+
"external": [
|
|
19
|
+
"@mzzsfy/dsh-common-tools/client"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"compatibility": {
|
|
23
|
+
"dshReleases": {
|
|
24
|
+
"0.2.0-rc.1": "unknown",
|
|
25
|
+
"0.2.0-rc.2": "compatible",
|
|
26
|
+
"0.2.1-alpha.1": "unknown"
|
|
27
|
+
}
|
|
18
28
|
}
|
|
19
29
|
},
|
|
20
30
|
"scripts": {
|
|
21
|
-
"test": "node --test \"test/*.test.mjs\"",
|
|
31
|
+
"test": "node --experimental-test-module-mocks --import ./test/_mocks.common-tools.mjs --test \"test/*.test.mjs\"",
|
|
22
32
|
"npmPublish": "npm publish --access public --provenance=false --registry=https://registry.npmjs.org"
|
|
23
33
|
},
|
|
24
34
|
"license": "MIT",
|
|
@@ -29,6 +39,9 @@
|
|
|
29
39
|
"engines": {
|
|
30
40
|
"node": ">=22"
|
|
31
41
|
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@mzzsfy/dsh-common-tools": "^0.2.0"
|
|
44
|
+
},
|
|
32
45
|
"peerDependencies": {
|
|
33
46
|
"@deepseek-ai/schemastery": ">=3.18.0",
|
|
34
47
|
"@deepseek-ai/dsh-tools": ">=0.1.5-rc.2"
|
package/src/client.js
CHANGED
|
@@ -9,8 +9,6 @@ const PANEL_ID = 'tunnel'
|
|
|
9
9
|
const PANEL_LABEL = '穿透隧道'
|
|
10
10
|
const TAB_ID = 'tunnel:board'
|
|
11
11
|
const TAB_ORDER = 47
|
|
12
|
-
const REFRESH_INTERVAL_MS = 30 * 1000
|
|
13
|
-
const PREF_POLL_MS = 5 * 1000
|
|
14
12
|
const COPY_FLASH_MS = 1200
|
|
15
13
|
const PORT_MIN = 1
|
|
16
14
|
const PORT_MAX = 65535
|
|
@@ -48,25 +46,25 @@ async function copyText(text) {
|
|
|
48
46
|
/* SBUILD-BEGIN */
|
|
49
47
|
const STYLE = `
|
|
50
48
|
/* 组件令牌层(形制对齐 dsh-im / cron-board):宿主 dsw-alias 定义在 body 作用域, tu- 令牌必须在 body 声明才能解析到主题值;
|
|
51
|
-
|
|
49
|
+
直接引用宿主语义别名, 禁止字面 fallback(暗色主题漏色, 规范见 DEVELOPMENT/plugin-ui-design.md) */
|
|
52
50
|
body{
|
|
53
|
-
--tu-bg:var(--dsw-alias-bg-layer-1
|
|
54
|
-
--tu-bg-sub:var(--dsw-alias-bg-layer-2
|
|
55
|
-
--tu-bg-muted:var(--dsw-alias-bg-module-platform
|
|
56
|
-
--tu-border:var(--dsw-alias-border-l2
|
|
57
|
-
--tu-border-strong:var(--dsw-alias-border-l3
|
|
58
|
-
--tu-text:var(--dsw-alias-label-primary
|
|
59
|
-
--tu-text-sub:var(--dsw-alias-label-secondary
|
|
60
|
-
--tu-text-dim:var(--dsw-alias-label-tertiary
|
|
61
|
-
--tu-accent
|
|
62
|
-
--tu-accent-deep
|
|
63
|
-
--tu-accent-wash
|
|
64
|
-
--tu-danger:var(--dsw-alias-state-error-primary
|
|
65
|
-
--tu-ok:var(--dsw-alias-state-success-primary
|
|
66
|
-
--tu-hover:var(--dsw-alias-interactive-bg-hover
|
|
67
|
-
--tu-mask:var(--dsw-alias-bg-mask-1
|
|
68
|
-
--tu-shadow:0 1px 2px
|
|
69
|
-
--tu-radius-sm:6px;--tu-radius-md:8px;--tu-radius-lg:
|
|
51
|
+
--tu-bg:var(--dsw-alias-bg-layer-1);
|
|
52
|
+
--tu-bg-sub:var(--dsw-alias-bg-layer-2);
|
|
53
|
+
--tu-bg-muted:var(--dsw-alias-bg-module-platform);
|
|
54
|
+
--tu-border:var(--dsw-alias-border-l2);
|
|
55
|
+
--tu-border-strong:var(--dsw-alias-border-l3);
|
|
56
|
+
--tu-text:var(--dsw-alias-label-primary);
|
|
57
|
+
--tu-text-sub:var(--dsw-alias-label-secondary);
|
|
58
|
+
--tu-text-dim:var(--dsw-alias-label-tertiary);
|
|
59
|
+
--tu-accent:var(--dsw-alias-brand-primary);
|
|
60
|
+
--tu-accent-deep:var(--dsw-alias-button-primary-hover);
|
|
61
|
+
--tu-accent-wash:color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent);
|
|
62
|
+
--tu-danger:var(--dsw-alias-state-error-primary);
|
|
63
|
+
--tu-ok:var(--dsw-alias-state-success-primary);
|
|
64
|
+
--tu-hover:var(--dsw-alias-interactive-bg-hover);
|
|
65
|
+
--tu-mask:var(--dsw-alias-bg-mask-1);
|
|
66
|
+
--tu-shadow:0 1px 2px color-mix(in srgb, var(--dsw-alias-label-primary) 4%, transparent);
|
|
67
|
+
--tu-radius-sm:6px;--tu-radius-md:8px;--tu-radius-lg:12px;
|
|
70
68
|
--tu-space-2:6px;--tu-space-3:8px;--tu-space-4:10px;--tu-space-5:12px;--tu-space-6:18px;
|
|
71
69
|
--tu-font-sm:12px;--tu-font-md:13px;--tu-font-lg:15px;
|
|
72
70
|
--tu-control-h:30px;
|
|
@@ -76,21 +74,21 @@ body{
|
|
|
76
74
|
.tu-toolbar{display:flex;align-items:center;gap:var(--tu-space-3);flex-wrap:wrap}
|
|
77
75
|
.tu-spacer{flex:1}
|
|
78
76
|
.tu-button{min-height:var(--tu-control-h);display:inline-flex;align-items:center;justify-content:center;gap:7px;border:1px solid var(--tu-border-strong);border-radius:var(--tu-radius-md);padding:0 13px;font:inherit;font-size:var(--tu-font-md);font-weight:560;background:var(--tu-bg);color:var(--tu-text);cursor:pointer;transition:border-color .15s,background .15s,color .15s}
|
|
79
|
-
.tu-button:hover:not(:disabled){border-color
|
|
77
|
+
.tu-button:hover:not(:disabled){border-color:var(--tu-border-strong);background:var(--tu-hover)}
|
|
80
78
|
.tu-button:disabled{cursor:not-allowed;opacity:.55}
|
|
81
|
-
.tu-button--primary{background:var(--tu-accent);border-color:var(--tu-accent);color
|
|
79
|
+
.tu-button--primary{background:var(--tu-accent);border-color:var(--tu-accent);color:var(--dsw-alias-bg-base)}
|
|
82
80
|
.tu-button--primary:hover:not(:disabled){background:var(--tu-accent-deep);border-color:var(--tu-accent-deep)}
|
|
83
81
|
.tu-button--danger{color:var(--tu-danger)}
|
|
84
|
-
.tu-button--danger:hover:not(:disabled){border-color:var(--tu-danger);background:var(--tu-danger);color
|
|
82
|
+
.tu-button--danger:hover:not(:disabled){border-color:var(--tu-danger);background:var(--tu-danger);color:var(--dsw-alias-bg-base)}
|
|
85
83
|
.tu-button--sm{min-height:28px;padding:0 10px;font-size:var(--tu-font-sm)}
|
|
86
|
-
.tu-button:focus-visible,.tu-icon:focus-visible{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%,
|
|
84
|
+
.tu-button:focus-visible,.tu-icon:focus-visible{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%, var(--dsw-alias-bg-base));outline-offset:2px}
|
|
87
85
|
.tu-meta{font-size:var(--tu-font-sm);color:var(--tu-text-sub)}
|
|
88
86
|
.tu-rows{display:flex;flex-direction:column;gap:var(--tu-space-1);font-size:var(--tu-font-md)}
|
|
89
87
|
.tu-row{display:flex;align-items:center;gap:var(--tu-space-3);border:1px solid var(--tu-border);border-radius:var(--tu-radius-md);padding:var(--tu-space-2) var(--tu-space-4);flex-wrap:wrap;transition:background .15s,border-color .15s}
|
|
90
88
|
.tu-row:hover{background:var(--tu-hover)}
|
|
91
89
|
.tu-name{font-size:14px;font-weight:600;color:var(--tu-text)}
|
|
92
90
|
.tu-badge{min-height:22px;display:inline-flex;align-items:center;padding:0 9px;border-radius:999px;color:var(--tu-text-sub);background:var(--tu-bg-muted);font-size:var(--tu-font-sm);white-space:nowrap}
|
|
93
|
-
.tu-code{font-family:
|
|
91
|
+
.tu-code{font-family:var(--ds-font-family-code);font-size:var(--tu-font-sm);color:var(--tu-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
|
94
92
|
.tu-link{border:none;background:transparent;padding:0;font:inherit;cursor:pointer;color:var(--tu-accent);text-decoration:underline;text-underline-offset:3px}
|
|
95
93
|
.tu-link:hover{color:var(--tu-accent-deep)}
|
|
96
94
|
.tu-actions{display:flex;align-items:center;gap:var(--tu-space-2);margin-left:auto}
|
|
@@ -100,11 +98,11 @@ body{
|
|
|
100
98
|
.tu-error{font-size:var(--tu-font-sm);color:var(--tu-danger);background:var(--tu-bg-sub);border-left:3px solid var(--tu-danger);border-radius:var(--tu-radius-sm);padding:var(--tu-space-2) var(--tu-space-3)}
|
|
101
99
|
.tu-switch{position:relative;display:inline-flex;align-items:center;cursor:pointer;gap:var(--tu-space-2)}
|
|
102
100
|
.tu-switch input[type="checkbox"] { position:absolute;opacity:0;width:1px;height:1px }
|
|
103
|
-
.tu-switch__track{width:30px;height:17px;border-radius:999px;background
|
|
104
|
-
.tu-switch__thumb{position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background
|
|
101
|
+
.tu-switch__track{width:30px;height:17px;border-radius:999px;background:var(--tu-border-strong);position:relative;transition:background .15s;flex:none}
|
|
102
|
+
.tu-switch__thumb{position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background:var(--dsw-alias-bg-base);transition:left .15s;box-shadow:0 1px 2px color-mix(in srgb, var(--dsw-alias-label-primary) 30%, transparent)}
|
|
105
103
|
.tu-switch input[type="checkbox"]:checked + .tu-switch__track{background:var(--tu-accent)}
|
|
106
104
|
.tu-switch input[type="checkbox"]:checked + .tu-switch__track .tu-switch__thumb{left:15px}
|
|
107
|
-
.tu-switch input[type="checkbox"]:focus-visible + .tu-switch__track{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%,
|
|
105
|
+
.tu-switch input[type="checkbox"]:focus-visible + .tu-switch__track{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%, var(--dsw-alias-bg-base));outline-offset:1px}
|
|
108
106
|
.tu-switch input[type="checkbox"]:disabled + .tu-switch__track{opacity:.4;cursor:not-allowed}
|
|
109
107
|
.tu-switch:not(:has(input[type="checkbox"]:disabled)):hover .tu-switch__track{background:var(--tu-accent-deep);opacity:.85}
|
|
110
108
|
.tu-switch input[type="checkbox"]:checked:not(:disabled) + .tu-switch__track:hover{background:var(--tu-accent-deep)}
|
|
@@ -126,7 +124,7 @@ body{
|
|
|
126
124
|
.tu-pc__rowLabel{flex:1;min-width:0}
|
|
127
125
|
.tu-pc__hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
|
128
126
|
.tu-modal-mask{position:fixed;inset:0;background:var(--tu-mask);display:flex;align-items:center;justify-content:center;z-index:60;animation:tu-fade-in .16s ease-out}
|
|
129
|
-
.tu-modal{background:var(--tu-bg);color:var(--tu-text);border-radius:var(--tu-radius-lg);padding:var(--tu-space-6);max-width:520px;width:min(520px,94vw);max-height:90vh;overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:
|
|
127
|
+
.tu-modal{background:var(--tu-bg);color:var(--tu-text);border-radius:var(--tu-radius-lg);padding:var(--tu-space-6);max-width:520px;width:min(520px,94vw);max-height:90vh;overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:var(--dsw-shadow-lv3);animation:tu-pop-in .2s ease-out}
|
|
130
128
|
@keyframes tu-fade-in{from{opacity:0}to{opacity:1}}
|
|
131
129
|
@keyframes tu-pop-in{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}
|
|
132
130
|
@media (prefers-reduced-motion: reduce){.tu-modal-mask,.tu-modal{animation:none}.tu-row,.tu-button,.tu-icon,.tu-switch__track,.tu-pc,.tu-pc__chevron{transition:none}}
|
|
@@ -136,9 +134,9 @@ body{
|
|
|
136
134
|
.tu-seg{display:inline-flex;gap:2px;background:var(--tu-bg-muted);border-radius:8px;padding:2px;width:fit-content}
|
|
137
135
|
.tu-seg__opt{border:none;background:transparent;border-radius:6px;padding:5px 11px;font:inherit;font-size:var(--tu-font-sm);font-weight:520;color:var(--tu-text-sub);cursor:pointer;line-height:1.2;white-space:nowrap;transition:background .15s,color .15s}
|
|
138
136
|
.tu-seg__opt:hover{color:var(--tu-text)}
|
|
139
|
-
.tu-seg__opt--on{background:var(--tu-accent);color
|
|
140
|
-
.tu-seg__opt--on:hover{color
|
|
141
|
-
.tu-seg__opt:focus-visible{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%,
|
|
137
|
+
.tu-seg__opt--on{background:var(--tu-accent);color:var(--dsw-alias-bg-base);font-weight:560}
|
|
138
|
+
.tu-seg__opt--on:hover{color:var(--dsw-alias-bg-base)}
|
|
139
|
+
.tu-seg__opt:focus-visible{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%, var(--dsw-alias-bg-base));outline-offset:1px}
|
|
142
140
|
.tu-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--tu-space-3) var(--tu-space-4)}
|
|
143
141
|
.tu-field{display:flex;flex-direction:column;gap:4px;font-size:var(--tu-font-sm);min-width:0;color:var(--tu-text-sub)}
|
|
144
142
|
.tu-field--wide{grid-column:1 / -1}
|
|
@@ -171,9 +169,12 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
171
169
|
function factory(require) {
|
|
172
170
|
let React = null
|
|
173
171
|
let ReactDOMClient = null
|
|
172
|
+
let tools = null
|
|
174
173
|
try {
|
|
175
174
|
React = require('react')
|
|
176
175
|
ReactDOMClient = require('react-dom/client')
|
|
176
|
+
// 公共工具库:bus 订阅是数据刷新唯一通道,缺失即整面板禁用(无轮询回退)
|
|
177
|
+
tools = require('@mzzsfy/dsh-common-tools/client')
|
|
177
178
|
} catch {
|
|
178
179
|
return noopFactory()
|
|
179
180
|
}
|
|
@@ -324,26 +325,28 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
324
325
|
disabled: busy || !nameOk || !portOk, onClick: submit }, '创建')))
|
|
325
326
|
}
|
|
326
327
|
|
|
327
|
-
function TunnelPanel() {
|
|
328
|
+
function TunnelPanel({ bus }) {
|
|
328
329
|
const [rows, setRows] = useState(null)
|
|
329
330
|
const [error, setError] = useState('')
|
|
330
331
|
const [showAdd, setShowAdd] = useState(false)
|
|
331
332
|
const [busyName, setBusyName] = useState('')
|
|
332
|
-
const
|
|
333
|
-
const reload = useCallback(() =>
|
|
333
|
+
const refreshRef = useRef(() => {})
|
|
334
|
+
const reload = useCallback(() => refreshRef.current(), [])
|
|
334
335
|
useEffect(() => {
|
|
335
336
|
let alive = true
|
|
336
337
|
const load = async () => {
|
|
337
338
|
const listOutcome = await request('GET', '/tunnels')
|
|
338
339
|
if (!alive) return
|
|
339
|
-
// 成功即清错误: 瞬态失败(
|
|
340
|
+
// 成功即清错误: 瞬态失败(如插件重载期刷新)恢复后错误条不滞留
|
|
340
341
|
if (listOutcome.ok) { setRows(listOutcome.data.items); setError('') }
|
|
341
342
|
else setError(listOutcome.error)
|
|
342
343
|
}
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
344
|
+
// 数据刷新唯一通道:订阅推送总线(首连 onReset 即首拉,消息到达刷新)
|
|
345
|
+
const snapshot = tools.createSnapshotRefresh(load)
|
|
346
|
+
refreshRef.current = snapshot.refresh
|
|
347
|
+
const unsubscribe = bus.subscribe(snapshot.refresh, { onReset: snapshot.refresh })
|
|
348
|
+
return () => { alive = false; refreshRef.current = () => {}; snapshot.dispose(); unsubscribe() }
|
|
349
|
+
}, [bus])
|
|
347
350
|
const remove = async (row) => {
|
|
348
351
|
if (!window.confirm('删除隧道 ' + row.name + '?')) return
|
|
349
352
|
setBusyName(row.name)
|
|
@@ -384,13 +387,13 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
384
387
|
h('path', { d: 'm10.5 10.5 3 3' }))
|
|
385
388
|
}
|
|
386
389
|
|
|
387
|
-
function registerMainPanel(ctx) {
|
|
390
|
+
function registerMainPanel(ctx, bus) {
|
|
388
391
|
return ctx.effect(() => {
|
|
389
392
|
const injectDisposers = []
|
|
390
393
|
injectDisposers.push(ctx.slots.inject('main', () => ctx.slots.register({
|
|
391
394
|
name: 'main',
|
|
392
395
|
key: PANEL_ID,
|
|
393
|
-
}, () => h('div', { className: 'tu-main' }, h(TunnelPanel)))))
|
|
396
|
+
}, () => h('div', { className: 'tu-main' }, h(TunnelPanel, { bus })))))
|
|
394
397
|
injectDisposers.push(ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
395
398
|
name: 'sidebar.panellist',
|
|
396
399
|
id: PANEL_ID,
|
|
@@ -402,7 +405,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
402
405
|
|
|
403
406
|
// better-sidebar tab:注册即单实例(single);只注册不主动打开,
|
|
404
407
|
// 打开由用户从侧边栏目录手动进入
|
|
405
|
-
function registerBoardTab(ctx, service) {
|
|
408
|
+
function registerBoardTab(ctx, service, bus) {
|
|
406
409
|
return ctx.effect(() => {
|
|
407
410
|
return service.registerTab({
|
|
408
411
|
id: TAB_ID,
|
|
@@ -410,7 +413,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
410
413
|
icon: (size) => h(PanelIcon, { size }),
|
|
411
414
|
order: TAB_ORDER,
|
|
412
415
|
single: true,
|
|
413
|
-
component: (props) => h('div', { className: 'tu-main' }, h(TunnelPanel)),
|
|
416
|
+
component: (props) => h('div', { className: 'tu-main' }, h(TunnelPanel, { bus })),
|
|
414
417
|
})
|
|
415
418
|
}, 'dsh-tunnel sidebar tab')
|
|
416
419
|
}
|
|
@@ -480,13 +483,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
480
483
|
// slots 供面板与设置卡片挂载(缺失则 fiber 未激活, 即干净禁用)
|
|
481
484
|
inject: ['slots'],
|
|
482
485
|
apply(ctx) {
|
|
486
|
+
const bus = tools.setupCommonTools(ctx)
|
|
483
487
|
ensureStyle(document)
|
|
484
488
|
// 挂载权交给用户:默认注册全局面板;偏好开启且侧边栏服务在场才移入扩展槽
|
|
485
489
|
let currentSidebar = ctx.get('betterSidebar')
|
|
486
490
|
let sidebarTabOn = false
|
|
487
491
|
let tabDisposer = null
|
|
488
492
|
let panelDisposer = null
|
|
489
|
-
const attachBoardTab = (sidebar) => registerBoardTab(ctx, sidebar)
|
|
493
|
+
const attachBoardTab = (sidebar) => registerBoardTab(ctx, sidebar, bus)
|
|
490
494
|
const applyPref = () => {
|
|
491
495
|
const wantTab = sidebarTabOn && currentSidebar !== undefined
|
|
492
496
|
if (wantTab && tabDisposer === null) {
|
|
@@ -496,11 +500,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
496
500
|
tabDisposer()
|
|
497
501
|
tabDisposer = null
|
|
498
502
|
}
|
|
499
|
-
if (!wantTab && panelDisposer === null) panelDisposer = registerMainPanel(ctx)
|
|
503
|
+
if (!wantTab && panelDisposer === null) panelDisposer = registerMainPanel(ctx, bus)
|
|
500
504
|
}
|
|
501
|
-
|
|
505
|
+
// 偏好仲裁数据源:订阅总线刷新(ui-settings 变更即 publish),不再周期轮询
|
|
506
|
+
let prefAlive = true
|
|
507
|
+
const syncPref = async () => {
|
|
502
508
|
try {
|
|
503
509
|
const outcome = await request('GET', '/status')
|
|
510
|
+
if (!prefAlive) return
|
|
504
511
|
const next = outcome.ok && outcome.data && outcome.data.ui ? outcome.data.ui.sidebarTab === true : false
|
|
505
512
|
if (next !== sidebarTabOn) {
|
|
506
513
|
const previous = sidebarTabOn
|
|
@@ -508,15 +515,19 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
508
515
|
try {
|
|
509
516
|
applyPref()
|
|
510
517
|
} catch (error) {
|
|
511
|
-
// 形态切换失败回滚偏好,
|
|
518
|
+
// 形态切换失败回滚偏好, 下轮事件重试, 避免入口消失后卡死
|
|
512
519
|
sidebarTabOn = previous
|
|
513
520
|
console.warn('[dsh-tunnel] 看板形态切换失败:', error)
|
|
514
521
|
}
|
|
515
522
|
}
|
|
516
|
-
} catch { /*
|
|
517
|
-
}
|
|
523
|
+
} catch { /* 刷新失败保形态不变, 下轮事件重试 */ }
|
|
524
|
+
}
|
|
525
|
+
const prefRefresh = tools.createSnapshotRefresh(syncPref)
|
|
526
|
+
const unsubscribePref = bus.subscribe(prefRefresh.refresh, { onReset: prefRefresh.refresh })
|
|
518
527
|
ctx.effect(() => () => {
|
|
519
|
-
|
|
528
|
+
prefAlive = false
|
|
529
|
+
prefRefresh.dispose()
|
|
530
|
+
unsubscribePref()
|
|
520
531
|
if (tabDisposer) tabDisposer()
|
|
521
532
|
if (panelDisposer) panelDisposer()
|
|
522
533
|
}, 'dsh-tunnel mount arbitration')
|
package/src/index.js
CHANGED
|
@@ -13,10 +13,13 @@ import { join } from 'node:path'
|
|
|
13
13
|
import z from '@deepseek-ai/schemastery'
|
|
14
14
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
15
15
|
|
|
16
|
+
import { setupCommonTools, createChangeNotifier } from '@mzzsfy/dsh-common-tools/host'
|
|
16
17
|
import { createApi, MESSAGES, ROUTE_PREFIX as API_PREFIX } from './api.mjs'
|
|
17
18
|
|
|
18
19
|
export const name = 'dsh-tunnel'
|
|
19
20
|
|
|
21
|
+
// 推送总线:变更通知统一走公共依赖 common-tools(webServer 形状到位即激活,
|
|
22
|
+
// 客户端轮询已废除不留双路径);插件卸载时总线按在场消费方自动收尾
|
|
20
23
|
export const inject = ['webServer']
|
|
21
24
|
|
|
22
25
|
const PATH_PREFIX = '/p/'
|
|
@@ -280,6 +283,9 @@ function saveStore(dataDir, rows) {
|
|
|
280
283
|
|
|
281
284
|
export function apply(ctx, config) {
|
|
282
285
|
const webServer = ctx.webServer
|
|
286
|
+
const bus = setupCommonTools(ctx)
|
|
287
|
+
const changes = createChangeNotifier(() => bus.publish())
|
|
288
|
+
ctx.effect(() => () => changes.dispose(), '通知变更合并')
|
|
283
289
|
|
|
284
290
|
// 形状守卫: 宿主形态漂移时干净禁用并告警, 绝不破坏宿主
|
|
285
291
|
const shapeOk = webServer.exact instanceof Map
|
|
@@ -392,6 +398,7 @@ export function apply(ctx, config) {
|
|
|
392
398
|
subdomains.set(record.name, record)
|
|
393
399
|
tunnels.set(record.name, record)
|
|
394
400
|
persist()
|
|
401
|
+
changes.refresh()
|
|
395
402
|
return { ok: true, name: record.name, entry, host: `${record.name}.localhost`, port: webServer.port }
|
|
396
403
|
}
|
|
397
404
|
let disposeRoutes
|
|
@@ -402,6 +409,7 @@ export function apply(ctx, config) {
|
|
|
402
409
|
}
|
|
403
410
|
tunnels.set(record.name, { ...record, disposeRoutes })
|
|
404
411
|
persist()
|
|
412
|
+
changes.refresh()
|
|
405
413
|
return { ok: true, name: record.name, entry, path: PATH_PREFIX + record.name, port: webServer.port }
|
|
406
414
|
},
|
|
407
415
|
list() {
|
|
@@ -424,6 +432,7 @@ export function apply(ctx, config) {
|
|
|
424
432
|
subdomains.delete(args.name)
|
|
425
433
|
tunnels.delete(args.name)
|
|
426
434
|
persist()
|
|
435
|
+
changes.refresh()
|
|
427
436
|
return { ok: true, removed: true }
|
|
428
437
|
},
|
|
429
438
|
disposeAll() {
|
|
@@ -656,6 +665,7 @@ export function apply(ctx, config) {
|
|
|
656
665
|
if (Object.keys(patch).length === 0) throw new Error(uiBooleanError(UI_PREF_KEYS))
|
|
657
666
|
if (!(await persistPatch(patch))) return { ok: false, error: SETTINGS_UNAVAILABLE }
|
|
658
667
|
applyPreferences()
|
|
668
|
+
changes.refresh()
|
|
659
669
|
return { ok: true, ui: readUi() }
|
|
660
670
|
}
|
|
661
671
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// common-tools host 桩(经 --import 预载,先于被测模块实例化拦截 ESM):
|
|
2
|
+
// publish 记入 busStub.published(断言点零改);onPresence 注册即报 0,
|
|
3
|
+
// busStub.presence 供测试手动驱动连接计数
|
|
4
|
+
import { mock } from 'node:test'
|
|
5
|
+
|
|
6
|
+
export const busStub = { published: [], presence: new Set() }
|
|
7
|
+
export function resetBusStub() {
|
|
8
|
+
busStub.published = []
|
|
9
|
+
busStub.presence = new Set()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
mock.module('@mzzsfy/dsh-common-tools/host', {
|
|
13
|
+
namedExports: {
|
|
14
|
+
createChangeNotifier: refresh => ({ refresh, dispose() {} }),
|
|
15
|
+
setupCommonTools: () => ({
|
|
16
|
+
publish: (payload = null) => busStub.published.push(payload),
|
|
17
|
+
onPresence(fn) { busStub.presence.add(fn); fn(0); return () => busStub.presence.delete(fn) },
|
|
18
|
+
}),
|
|
19
|
+
},
|
|
20
|
+
})
|
package/test/ai-switch.test.mjs
CHANGED
|
@@ -8,6 +8,8 @@ import { mkdtempSync, rmSync } from 'node:fs'
|
|
|
8
8
|
import { tmpdir } from 'node:os'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
|
|
11
|
+
import { busStub, resetBusStub } from './_mocks.common-tools.mjs'
|
|
12
|
+
|
|
11
13
|
const TOOL_NAMES = ['tunnel_open', 'tunnel_list', 'tunnel_close']
|
|
12
14
|
|
|
13
15
|
class MockWebServer {
|
|
@@ -56,6 +58,9 @@ function createCtx({ tools, settings, configEditor, fiber } = {}) {
|
|
|
56
58
|
if (typeof disposer === 'function') effectDisposers.push(disposer)
|
|
57
59
|
return disposer
|
|
58
60
|
}
|
|
61
|
+
// 总线 publish 桩:桥接到最近 createCtx 的 published 供断言
|
|
62
|
+
resetBusStub()
|
|
63
|
+
const published = busStub.published
|
|
59
64
|
const ctx = {
|
|
60
65
|
webServer: undefined,
|
|
61
66
|
fiber,
|
|
@@ -68,7 +73,7 @@ function createCtx({ tools, settings, configEditor, fiber } = {}) {
|
|
|
68
73
|
effect: runEffect,
|
|
69
74
|
inject(names, cb) { cb({ tools, settings, effect: runEffect }) },
|
|
70
75
|
}
|
|
71
|
-
return { ctx, events, effectDisposers }
|
|
76
|
+
return { ctx, events, effectDisposers, published }
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
// legacy mock settings: 与 tunnel.test.mjs 同款语义(内存存储 + watch 登记 + 手动触发)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
|
|
6
|
+
const PKG = '@mzzsfy/dsh-tunnel'
|
|
7
|
+
const flush = () => new Promise(resolve => setImmediate(resolve))
|
|
8
|
+
|
|
9
|
+
function fixture(sidebarTab) {
|
|
10
|
+
let definition, pendingEffects
|
|
11
|
+
const owners = new Map(), requests = [], clocks = new Set()
|
|
12
|
+
const window = { __ModuleLoader__: { load: value => { definition = value } } }
|
|
13
|
+
const document = { getElementById: () => ({}) }
|
|
14
|
+
const react = {
|
|
15
|
+
createElement: (type, props, ...children) => ({ type, props: { ...props, children } }),
|
|
16
|
+
useState: value => [typeof value === 'function' ? value() : value, () => {}],
|
|
17
|
+
useRef: value => ({ current: value }),
|
|
18
|
+
useCallback: fn => fn,
|
|
19
|
+
useEffect: fn => pendingEffects.push(fn),
|
|
20
|
+
}
|
|
21
|
+
const tools = { createSnapshotRefresh: refresh => ({ refresh, dispose() {} }), setupCommonTools(ctx) {
|
|
22
|
+
assert.equal(ctx.fiber.entry.options.name, PKG)
|
|
23
|
+
const owner = { active: true, subscriptions: new Set() }
|
|
24
|
+
owners.set(ctx, owner)
|
|
25
|
+
ctx.effect(() => () => { owner.active = false; owner.subscriptions.clear() })
|
|
26
|
+
return { subscribe(listener, { onReset } = {}) {
|
|
27
|
+
assert.ok(owner.active, '不能订阅已卸载的 ctx')
|
|
28
|
+
const subscription = { listener, onReset }
|
|
29
|
+
owner.subscriptions.add(subscription)
|
|
30
|
+
return () => owner.subscriptions.delete(subscription)
|
|
31
|
+
} }
|
|
32
|
+
} }
|
|
33
|
+
const fetch = async path => {
|
|
34
|
+
requests.push(path)
|
|
35
|
+
return { ok: true, json: async () => ({ items: [], ui: { sidebarTab } }) }
|
|
36
|
+
}
|
|
37
|
+
new Function('window', 'document', 'fetch', 'setInterval', 'clearInterval', source)(
|
|
38
|
+
window, document, fetch,
|
|
39
|
+
fn => { clocks.add(fn); return fn }, fn => clocks.delete(fn))
|
|
40
|
+
const plugin = definition.factory(name => {
|
|
41
|
+
if (name === 'react') return react
|
|
42
|
+
if (name === 'react-dom/client') return {}
|
|
43
|
+
assert.equal(name, '@mzzsfy/dsh-common-tools/client')
|
|
44
|
+
return tools
|
|
45
|
+
})
|
|
46
|
+
function activate() {
|
|
47
|
+
const cleanups = [], entries = new Set(), tabs = new Set()
|
|
48
|
+
const service = { registerTab(tab) { tabs.add(tab); return () => tabs.delete(tab) } }
|
|
49
|
+
const ctx = {
|
|
50
|
+
fiber: { entry: { options: { name: PKG } } },
|
|
51
|
+
get: name => name === 'betterSidebar' ? service : undefined,
|
|
52
|
+
inject() {},
|
|
53
|
+
effect(fn) {
|
|
54
|
+
const cleanup = fn()
|
|
55
|
+
let active = true
|
|
56
|
+
const dispose = () => { if (active) { active = false; cleanup?.() } }
|
|
57
|
+
cleanups.push(dispose)
|
|
58
|
+
return dispose
|
|
59
|
+
},
|
|
60
|
+
slots: {
|
|
61
|
+
inject(name, fn) { return name === 'main' || name === 'sidebar.panellist' ? fn() : () => {} },
|
|
62
|
+
register(options, component) {
|
|
63
|
+
const entry = { ...options, component }
|
|
64
|
+
entries.add(entry)
|
|
65
|
+
return () => entries.delete(entry)
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
plugin.apply(ctx)
|
|
70
|
+
return {
|
|
71
|
+
get subscriptions() { return owners.get(ctx).subscriptions },
|
|
72
|
+
reset() { for (const item of [...this.subscriptions]) item.onReset?.() },
|
|
73
|
+
emit() { for (const item of [...this.subscriptions]) item.listener(null) },
|
|
74
|
+
mount() {
|
|
75
|
+
const component = sidebarTab ? [...tabs][0]?.component : [...entries].find(item => item.name === 'main')?.component
|
|
76
|
+
assert.equal(typeof component, 'function', '面板应已注册')
|
|
77
|
+
pendingEffects = []
|
|
78
|
+
const render = node => {
|
|
79
|
+
if (Array.isArray(node)) return node.forEach(render)
|
|
80
|
+
if (!node || typeof node !== 'object') return
|
|
81
|
+
if (typeof node.type === 'function') render(node.type(node.props))
|
|
82
|
+
else render(node.props.children)
|
|
83
|
+
}
|
|
84
|
+
render(component({ visible: true }))
|
|
85
|
+
const disposers = pendingEffects.map(effect => effect()).filter(value => typeof value === 'function')
|
|
86
|
+
return () => { for (const dispose of disposers.splice(0).reverse()) dispose() }
|
|
87
|
+
},
|
|
88
|
+
dispose() { for (const dispose of [...cleanups].reverse()) dispose() },
|
|
89
|
+
get registrations() { return entries.size + tabs.size },
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { activate, requests, clocks }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const sidebarTab of [false, true]) {
|
|
96
|
+
const surface = sidebarTab ? '侧栏' : '主面板'
|
|
97
|
+
for (const survivorIndex of [0, 1]) {
|
|
98
|
+
test(`Given 同 factory 的 A/B 已注册${surface} When 卸载 ${survivorIndex === 0 ? 'B' : 'A'} 后挂载存活面板 Then 仍用自己的租约订阅和刷新`, async () => {
|
|
99
|
+
const f = fixture(sidebarTab), instances = [f.activate(), f.activate()]
|
|
100
|
+
for (const instance of instances) instance.reset()
|
|
101
|
+
await flush()
|
|
102
|
+
const survivor = instances[survivorIndex], removed = instances[1 - survivorIndex]
|
|
103
|
+
removed.dispose()
|
|
104
|
+
assert.equal(removed.subscriptions.size, 0)
|
|
105
|
+
assert.equal(removed.registrations, 0)
|
|
106
|
+
const unmount = survivor.mount()
|
|
107
|
+
assert.equal(survivor.subscriptions.size, 2)
|
|
108
|
+
const before = f.requests.length
|
|
109
|
+
survivor.reset()
|
|
110
|
+
await flush()
|
|
111
|
+
assert.equal(f.requests.length - before, 2)
|
|
112
|
+
const afterReset = f.requests.length
|
|
113
|
+
survivor.emit()
|
|
114
|
+
await flush()
|
|
115
|
+
assert.equal(f.requests.length - afterReset, 2)
|
|
116
|
+
unmount()
|
|
117
|
+
assert.equal(survivor.subscriptions.size, 1, '组件卸载仅清自己的数据订阅')
|
|
118
|
+
const unmountAgain = survivor.mount()
|
|
119
|
+
assert.equal(survivor.subscriptions.size, 2)
|
|
120
|
+
survivor.dispose()
|
|
121
|
+
assert.equal(survivor.subscriptions.size, 0)
|
|
122
|
+
unmountAgain()
|
|
123
|
+
survivor.dispose()
|
|
124
|
+
assert.equal(f.clocks.size, 0)
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
test(`Given A/B 的${surface}都已挂载 When 卸载 A Then 只清 A 的订阅并保留 B 的组件订阅`, async () => {
|
|
129
|
+
const f = fixture(sidebarTab), a = f.activate(), b = f.activate()
|
|
130
|
+
a.reset(); b.reset()
|
|
131
|
+
await flush()
|
|
132
|
+
const unmountA = a.mount(), unmountB = b.mount()
|
|
133
|
+
assert.equal(a.subscriptions.size, 2)
|
|
134
|
+
assert.equal(b.subscriptions.size, 2)
|
|
135
|
+
a.dispose()
|
|
136
|
+
assert.equal(a.subscriptions.size, 0)
|
|
137
|
+
assert.equal(b.subscriptions.size, 2)
|
|
138
|
+
unmountA()
|
|
139
|
+
assert.equal(b.subscriptions.size, 2)
|
|
140
|
+
unmountB()
|
|
141
|
+
assert.equal(b.subscriptions.size, 1)
|
|
142
|
+
b.dispose()
|
|
143
|
+
assert.equal(b.subscriptions.size, 0)
|
|
144
|
+
assert.equal(f.clocks.size, 0)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// 开关样式守卫 BDD:裸 checkbox/裸 input 状态选择器会静默损坏 UI 或误伤同 label 的文本输入,
|
|
2
|
-
// 属无报错的 UI 退化, 静态断言锁死 tu-switch 结构(参照 cb-switch/
|
|
2
|
+
// 属无报错的 UI 退化, 静态断言锁死 tu-switch 结构(参照 cb-switch/tn-switch 同款守卫)。
|
|
3
3
|
import { readFileSync } from 'node:fs'
|
|
4
4
|
import { dirname, join } from 'node:path'
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
package/test/tunnel.test.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import { tmpdir } from 'node:os'
|
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
import { once } from 'node:events'
|
|
12
12
|
|
|
13
|
+
import { busStub, resetBusStub } from './_mocks.common-tools.mjs'
|
|
14
|
+
|
|
13
15
|
const DEFAULT_CONNECT_TIMEOUT = 10 * 1000
|
|
14
16
|
const PORT_MAX = 65535
|
|
15
17
|
|
|
@@ -51,6 +53,9 @@ function createCtx(webServer, tools = undefined, settings = undefined) {
|
|
|
51
53
|
if (typeof disposer === 'function') effectDisposers.push(disposer)
|
|
52
54
|
return disposer
|
|
53
55
|
}
|
|
56
|
+
// 总线 publish 桩:桥接到最近 createCtx 的 published 供断言
|
|
57
|
+
resetBusStub()
|
|
58
|
+
const published = busStub.published
|
|
54
59
|
const ctx = {
|
|
55
60
|
webServer,
|
|
56
61
|
get: () => undefined,
|
|
@@ -60,7 +65,7 @@ function createCtx(webServer, tools = undefined, settings = undefined) {
|
|
|
60
65
|
cb({ tools: toolsService, settings, effect: runEffect })
|
|
61
66
|
},
|
|
62
67
|
}
|
|
63
|
-
return { ctx, toolsService, effectDisposers }
|
|
68
|
+
return { ctx, toolsService, effectDisposers, published }
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
// mock settings 服务: 内存存储 + watch 回调登记, 测试手动触发模拟设置页写入
|
|
@@ -92,7 +97,7 @@ process.on('exit', () => {
|
|
|
92
97
|
|
|
93
98
|
async function applyPlugin(webServer, options = {}) {
|
|
94
99
|
const { apply } = await import('../src/index.js')
|
|
95
|
-
const { ctx, toolsService, effectDisposers } = createCtx(webServer, undefined, options.settings)
|
|
100
|
+
const { ctx, toolsService, effectDisposers, published } = createCtx(webServer, undefined, options.settings)
|
|
96
101
|
const dataDir = options.dataDir ?? defaultDataDir()
|
|
97
102
|
const config = { connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT, dataDir }
|
|
98
103
|
const warnings = []
|
|
@@ -110,7 +115,7 @@ async function applyPlugin(webServer, options = {}) {
|
|
|
110
115
|
dispose()
|
|
111
116
|
for (const disposer of effectDisposers.reverse()) disposer()
|
|
112
117
|
}
|
|
113
|
-
return { webServer, tools, dataDir, warnings, dispose: disposeAll, ctx }
|
|
118
|
+
return { webServer, tools, dataDir, warnings, dispose: disposeAll, ctx, published }
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
function toolOf(plugin, name) {
|
|
@@ -262,6 +267,17 @@ test('场景2 open: Given 合法参数与 wsPaths Then prefix 路由与两形态
|
|
|
262
267
|
assert.deepEqual(persisted.map((row) => row.name), ['app'])
|
|
263
268
|
})
|
|
264
269
|
|
|
270
|
+
test('场景2b 总线通知: Given 增删与偏好写路径 Then publish() 契约', async (t) => {
|
|
271
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
272
|
+
assert.deepEqual(plugin.published, [], '装载期无变更不发布')
|
|
273
|
+
await open(plugin, { name: 'app', targetPort: PORT_MAX - 1 })
|
|
274
|
+
await open(plugin, { name: 'app2', targetPort: PORT_MAX - 2, wsPaths: [] })
|
|
275
|
+
await close(plugin, 'app')
|
|
276
|
+
const published = plugin.published
|
|
277
|
+
assert.equal(published.length, 3, 'open 两次 + close 一次')
|
|
278
|
+
for (const payload of published) assert.equal(payload, null)
|
|
279
|
+
})
|
|
280
|
+
|
|
265
281
|
test('场景3 校验: Given 非法名或越界端口 Then 拒收且不注册不落盘', async (t) => {
|
|
266
282
|
const plugin = await applyPlugin(new MockWebServer())
|
|
267
283
|
for (const args of [{ name: 'App', targetPort: 80 }, { name: 'a.b', targetPort: 80 }, { name: 'ok', targetPort: 0 }, { name: 'ok', targetPort: PORT_MAX + 1 }]) {
|