@mzzsfy/dsh-tunnel 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/package.json +9 -3
- package/src/client.js +62 -52
- 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/client-dependency.test.mjs +29 -0
- package/test/switch-guard.test.mjs +1 -1
- package/test/tunnel.test.mjs +19 -3
package/README.md
CHANGED
|
@@ -24,20 +24,24 @@
|
|
|
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
|
+
|
|
32
|
+
## client 依赖
|
|
33
|
+
|
|
34
|
+
看板通过 `@mzzsfy/dsh-common-tools/client` 订阅数据变更。安装时同时启用 `dsh-session-manager`,由其唯一代挂条目装载公共 client,并排在 tunnel 前物化。公共模块缺失时装载层会报告具体依赖错误;宿主 React 模块缺失时控制台报告原因并禁用面板。
|
|
31
35
|
|
|
32
36
|
## 验证
|
|
33
37
|
|
|
34
|
-
- `
|
|
38
|
+
- `npm test`(经 _mocks.common-tools.mjs 预载公共依赖桩): 37 场景 + 8 api 用例 + 11 ai 开关注册面 + 11 守卫断言 + client 端 8 用例(id 守卫 + 总线生命周期;mock webServer + 真实 http/net 端到端)
|
|
35
39
|
- `node scripts/compat/tunnel-fullchain.mjs`: 隔离 dsh 实例全链路(真实激活/恢复/分发面七项探针)
|
|
36
40
|
|
|
37
41
|
## 设计文档
|
|
38
42
|
|
|
39
|
-
`docs/调研-路径穿透插件.md`(「方案设计」两节为本包实施规格, 含已知限制); `docs/设计-隧道GUI.md`(GUI/设置/侧边栏注入实施规格)
|
|
43
|
+
`docs/调研-路径穿透插件.md`(「方案设计」两节为本包实施规格, 含已知限制); `docs/设计-隧道GUI.md`(GUI/设置/侧边栏注入实施规格)。两份均为仓库本地维护文档,不入库,npm 安装读者不可见。
|
|
40
44
|
|
|
41
45
|
## dsh 版本兼容
|
|
42
46
|
|
|
43
|
-
按仓库动态窗口验证(scripts/compat/window.mjs),不预置任何固定版本槽:主测 = 渠道 dist-tag 最新 rc,完全适配口径;基线 = npm latest,不崩溃底线口径;主测与基线同版时收敛单槽。兼容面以窗口实测为准,不逐版本承诺。逐轮实测结论见 `docs/兼容性测试/逐包测试进度.md`
|
|
47
|
+
按仓库动态窗口验证(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.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -14,7 +14,10 @@
|
|
|
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
|
+
]
|
|
18
21
|
},
|
|
19
22
|
"compatibility": {
|
|
20
23
|
"dshReleases": {
|
|
@@ -25,7 +28,7 @@
|
|
|
25
28
|
}
|
|
26
29
|
},
|
|
27
30
|
"scripts": {
|
|
28
|
-
"test": "node --test \"test/*.test.mjs\"",
|
|
31
|
+
"test": "node --experimental-test-module-mocks --import ./test/_mocks.common-tools.mjs --test \"test/*.test.mjs\"",
|
|
29
32
|
"npmPublish": "npm publish --access public --provenance=false --registry=https://registry.npmjs.org"
|
|
30
33
|
},
|
|
31
34
|
"license": "MIT",
|
|
@@ -36,6 +39,9 @@
|
|
|
36
39
|
"engines": {
|
|
37
40
|
"node": ">=22"
|
|
38
41
|
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@mzzsfy/dsh-common-tools": "^0.2.0"
|
|
44
|
+
},
|
|
39
45
|
"peerDependencies": {
|
|
40
46
|
"@deepseek-ai/schemastery": ">=3.18.0",
|
|
41
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}
|
|
@@ -174,9 +172,11 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
174
172
|
try {
|
|
175
173
|
React = require('react')
|
|
176
174
|
ReactDOMClient = require('react-dom/client')
|
|
177
|
-
} catch {
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error('[dsh-tunnel] 宿主 React 模块加载失败', error)
|
|
178
177
|
return noopFactory()
|
|
179
178
|
}
|
|
179
|
+
const tools = require('@mzzsfy/dsh-common-tools/client')
|
|
180
180
|
const { useState, useEffect, useRef, useCallback } = React
|
|
181
181
|
const h = (type, props, ...children) => React.createElement(type, props ?? null, ...children)
|
|
182
182
|
|
|
@@ -324,26 +324,28 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
324
324
|
disabled: busy || !nameOk || !portOk, onClick: submit }, '创建')))
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
function TunnelPanel() {
|
|
327
|
+
function TunnelPanel({ bus }) {
|
|
328
328
|
const [rows, setRows] = useState(null)
|
|
329
329
|
const [error, setError] = useState('')
|
|
330
330
|
const [showAdd, setShowAdd] = useState(false)
|
|
331
331
|
const [busyName, setBusyName] = useState('')
|
|
332
|
-
const
|
|
333
|
-
const reload = useCallback(() =>
|
|
332
|
+
const refreshRef = useRef(() => {})
|
|
333
|
+
const reload = useCallback(() => refreshRef.current(), [])
|
|
334
334
|
useEffect(() => {
|
|
335
335
|
let alive = true
|
|
336
336
|
const load = async () => {
|
|
337
337
|
const listOutcome = await request('GET', '/tunnels')
|
|
338
338
|
if (!alive) return
|
|
339
|
-
// 成功即清错误: 瞬态失败(
|
|
339
|
+
// 成功即清错误: 瞬态失败(如插件重载期刷新)恢复后错误条不滞留
|
|
340
340
|
if (listOutcome.ok) { setRows(listOutcome.data.items); setError('') }
|
|
341
341
|
else setError(listOutcome.error)
|
|
342
342
|
}
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
343
|
+
// 数据刷新唯一通道:订阅推送总线(首连 onReset 即首拉,消息到达刷新)
|
|
344
|
+
const snapshot = tools.createSnapshotRefresh(load)
|
|
345
|
+
refreshRef.current = snapshot.refresh
|
|
346
|
+
const unsubscribe = bus.subscribe(snapshot.refresh, { onReset: snapshot.refresh })
|
|
347
|
+
return () => { alive = false; refreshRef.current = () => {}; snapshot.dispose(); unsubscribe() }
|
|
348
|
+
}, [bus])
|
|
347
349
|
const remove = async (row) => {
|
|
348
350
|
if (!window.confirm('删除隧道 ' + row.name + '?')) return
|
|
349
351
|
setBusyName(row.name)
|
|
@@ -384,13 +386,13 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
384
386
|
h('path', { d: 'm10.5 10.5 3 3' }))
|
|
385
387
|
}
|
|
386
388
|
|
|
387
|
-
function registerMainPanel(ctx) {
|
|
389
|
+
function registerMainPanel(ctx, bus) {
|
|
388
390
|
return ctx.effect(() => {
|
|
389
391
|
const injectDisposers = []
|
|
390
392
|
injectDisposers.push(ctx.slots.inject('main', () => ctx.slots.register({
|
|
391
393
|
name: 'main',
|
|
392
394
|
key: PANEL_ID,
|
|
393
|
-
}, () => h('div', { className: 'tu-main' }, h(TunnelPanel)))))
|
|
395
|
+
}, () => h('div', { className: 'tu-main' }, h(TunnelPanel, { bus })))))
|
|
394
396
|
injectDisposers.push(ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
395
397
|
name: 'sidebar.panellist',
|
|
396
398
|
id: PANEL_ID,
|
|
@@ -402,7 +404,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
402
404
|
|
|
403
405
|
// better-sidebar tab:注册即单实例(single);只注册不主动打开,
|
|
404
406
|
// 打开由用户从侧边栏目录手动进入
|
|
405
|
-
function registerBoardTab(ctx, service) {
|
|
407
|
+
function registerBoardTab(ctx, service, bus) {
|
|
406
408
|
return ctx.effect(() => {
|
|
407
409
|
return service.registerTab({
|
|
408
410
|
id: TAB_ID,
|
|
@@ -410,7 +412,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
410
412
|
icon: (size) => h(PanelIcon, { size }),
|
|
411
413
|
order: TAB_ORDER,
|
|
412
414
|
single: true,
|
|
413
|
-
component: (props) => h('div', { className: 'tu-main' }, h(TunnelPanel)),
|
|
415
|
+
component: (props) => h('div', { className: 'tu-main' }, h(TunnelPanel, { bus })),
|
|
414
416
|
})
|
|
415
417
|
}, 'dsh-tunnel sidebar tab')
|
|
416
418
|
}
|
|
@@ -480,13 +482,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
480
482
|
// slots 供面板与设置卡片挂载(缺失则 fiber 未激活, 即干净禁用)
|
|
481
483
|
inject: ['slots'],
|
|
482
484
|
apply(ctx) {
|
|
485
|
+
const bus = tools.setupCommonTools(ctx)
|
|
483
486
|
ensureStyle(document)
|
|
484
487
|
// 挂载权交给用户:默认注册全局面板;偏好开启且侧边栏服务在场才移入扩展槽
|
|
485
488
|
let currentSidebar = ctx.get('betterSidebar')
|
|
486
489
|
let sidebarTabOn = false
|
|
487
490
|
let tabDisposer = null
|
|
488
491
|
let panelDisposer = null
|
|
489
|
-
const attachBoardTab = (sidebar) => registerBoardTab(ctx, sidebar)
|
|
492
|
+
const attachBoardTab = (sidebar) => registerBoardTab(ctx, sidebar, bus)
|
|
490
493
|
const applyPref = () => {
|
|
491
494
|
const wantTab = sidebarTabOn && currentSidebar !== undefined
|
|
492
495
|
if (wantTab && tabDisposer === null) {
|
|
@@ -496,11 +499,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
496
499
|
tabDisposer()
|
|
497
500
|
tabDisposer = null
|
|
498
501
|
}
|
|
499
|
-
if (!wantTab && panelDisposer === null) panelDisposer = registerMainPanel(ctx)
|
|
502
|
+
if (!wantTab && panelDisposer === null) panelDisposer = registerMainPanel(ctx, bus)
|
|
500
503
|
}
|
|
501
|
-
|
|
504
|
+
// 偏好仲裁数据源:订阅总线刷新(ui-settings 变更即 publish),不再周期轮询
|
|
505
|
+
let prefAlive = true
|
|
506
|
+
const syncPref = async () => {
|
|
502
507
|
try {
|
|
503
508
|
const outcome = await request('GET', '/status')
|
|
509
|
+
if (!prefAlive) return
|
|
504
510
|
const next = outcome.ok && outcome.data && outcome.data.ui ? outcome.data.ui.sidebarTab === true : false
|
|
505
511
|
if (next !== sidebarTabOn) {
|
|
506
512
|
const previous = sidebarTabOn
|
|
@@ -508,15 +514,19 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
508
514
|
try {
|
|
509
515
|
applyPref()
|
|
510
516
|
} catch (error) {
|
|
511
|
-
// 形态切换失败回滚偏好,
|
|
517
|
+
// 形态切换失败回滚偏好, 下轮事件重试, 避免入口消失后卡死
|
|
512
518
|
sidebarTabOn = previous
|
|
513
519
|
console.warn('[dsh-tunnel] 看板形态切换失败:', error)
|
|
514
520
|
}
|
|
515
521
|
}
|
|
516
|
-
} catch { /*
|
|
517
|
-
}
|
|
522
|
+
} catch { /* 刷新失败保形态不变, 下轮事件重试 */ }
|
|
523
|
+
}
|
|
524
|
+
const prefRefresh = tools.createSnapshotRefresh(syncPref)
|
|
525
|
+
const unsubscribePref = bus.subscribe(prefRefresh.refresh, { onReset: prefRefresh.refresh })
|
|
518
526
|
ctx.effect(() => () => {
|
|
519
|
-
|
|
527
|
+
prefAlive = false
|
|
528
|
+
prefRefresh.dispose()
|
|
529
|
+
unsubscribePref()
|
|
520
530
|
if (tabDisposer) tabDisposer()
|
|
521
531
|
if (panelDisposer) panelDisposer()
|
|
522
532
|
}, '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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import vm from 'node:vm'
|
|
5
|
+
|
|
6
|
+
const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
|
|
7
|
+
function materialize(missing) {
|
|
8
|
+
let definition
|
|
9
|
+
const diagnostics = []
|
|
10
|
+
const window = { __ModuleLoader__: { load(value) { definition = value } } }
|
|
11
|
+
vm.runInNewContext(source, { window, console: { error: (...args) => diagnostics.push(args) } })
|
|
12
|
+
const require = name => {
|
|
13
|
+
if (name === missing) throw new Error(`module missing: ${name}`)
|
|
14
|
+
return {}
|
|
15
|
+
}
|
|
16
|
+
return { definition, require, diagnostics }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('Given 缺公共client When 物化隧道模块 Then 向装载层抛出具体依赖错误', () => {
|
|
20
|
+
const f = materialize('@mzzsfy/dsh-common-tools/client')
|
|
21
|
+
assert.throws(() => f.definition.factory(f.require), /module missing: @mzzsfy\/dsh-common-tools\/client/)
|
|
22
|
+
})
|
|
23
|
+
for (const missing of ['react', 'react-dom/client']) test(`Given 缺${missing} When 物化 Then 禁用并报告宿主依赖原因`, () => {
|
|
24
|
+
const f = materialize(missing)
|
|
25
|
+
const module = f.definition.factory(f.require)
|
|
26
|
+
assert.deepEqual(Array.from(module.inject), [])
|
|
27
|
+
assert.equal(f.diagnostics.length, 1)
|
|
28
|
+
assert.match(String(f.diagnostics[0]), new RegExp(missing))
|
|
29
|
+
})
|
|
@@ -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 }]) {
|