@mzzsfy/dsh-tunnel 0.1.0
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 +39 -0
- package/cordis.patch.yml +9 -0
- package/package.json +42 -0
- package/src/api.mjs +116 -0
- package/src/client.js +548 -0
- package/src/index.js +695 -0
- package/test/ai-switch.test.mjs +272 -0
- package/test/api.test.mjs +145 -0
- package/test/client-id.test.mjs +25 -0
- package/test/switch-guard.test.mjs +81 -0
- package/test/tunnel.test.mjs +921 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// 「允许 ai 使用」开关注册面 BDD: 开关决定 tunnel_open/list/close 是否向 ai 注册,
|
|
2
|
+
// 关闭即注销(干净禁用), GUI/REST 同表不受影响。覆盖双形态读写:
|
|
3
|
+
// legacy 设置节(watch 回写)与 0.1.7+ volatile ref(loader 原地热更 + loader/volatile-update)。
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { EventEmitter } from 'node:events'
|
|
7
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
const TOOL_NAMES = ['tunnel_open', 'tunnel_list', 'tunnel_close']
|
|
12
|
+
|
|
13
|
+
class MockWebServer {
|
|
14
|
+
exact = new Map()
|
|
15
|
+
prefixes = new Map()
|
|
16
|
+
upgrades = new Map()
|
|
17
|
+
fallback = undefined
|
|
18
|
+
host = '127.0.0.1'
|
|
19
|
+
port = 3080
|
|
20
|
+
|
|
21
|
+
register(route) {
|
|
22
|
+
const table = route.kind === 'exact' ? this.exact : this.prefixes
|
|
23
|
+
if (table.has(route.path)) throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`)
|
|
24
|
+
table.set(route.path, route)
|
|
25
|
+
return () => table.delete(route.path)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
registerUpgrade(route) {
|
|
29
|
+
this.upgrades.set(route.path, route)
|
|
30
|
+
return () => this.upgrades.delete(route.path)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
registerFallback(handler) {
|
|
34
|
+
this.fallback = handler
|
|
35
|
+
return () => { this.fallback = undefined }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createToolsService() {
|
|
40
|
+
const registered = new Map()
|
|
41
|
+
return {
|
|
42
|
+
registered,
|
|
43
|
+
names: () => [...registered.keys()],
|
|
44
|
+
register(tool) {
|
|
45
|
+
registered.set(tool.name, tool)
|
|
46
|
+
return () => registered.delete(tool.name)
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createCtx({ tools, settings, configEditor, fiber } = {}) {
|
|
52
|
+
const events = new Map()
|
|
53
|
+
const effectDisposers = []
|
|
54
|
+
const runEffect = (fn) => {
|
|
55
|
+
const disposer = fn()
|
|
56
|
+
if (typeof disposer === 'function') effectDisposers.push(disposer)
|
|
57
|
+
return disposer
|
|
58
|
+
}
|
|
59
|
+
const ctx = {
|
|
60
|
+
webServer: undefined,
|
|
61
|
+
fiber,
|
|
62
|
+
get: (name) => (name === 'configEditor' ? configEditor : undefined),
|
|
63
|
+
on: (event, handler) => {
|
|
64
|
+
if (!events.has(event)) events.set(event, [])
|
|
65
|
+
events.get(event).push(handler)
|
|
66
|
+
return () => { events.set(event, events.get(event).filter((cb) => cb !== handler)) }
|
|
67
|
+
},
|
|
68
|
+
effect: runEffect,
|
|
69
|
+
inject(names, cb) { cb({ tools, settings, effect: runEffect }) },
|
|
70
|
+
}
|
|
71
|
+
return { ctx, events, effectDisposers }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// legacy mock settings: 与 tunnel.test.mjs 同款语义(内存存储 + watch 登记 + 手动触发)
|
|
75
|
+
function createSettingsService(initial = {}) {
|
|
76
|
+
const store = { tunnel: { ...initial } }
|
|
77
|
+
const watchers = []
|
|
78
|
+
return {
|
|
79
|
+
store,
|
|
80
|
+
service: {
|
|
81
|
+
get: (ns) => store[ns],
|
|
82
|
+
update: (ns, patch) => { store[ns] = { ...(store[ns] ?? {}), ...patch } },
|
|
83
|
+
register: (ns) => ({ watch: (cb) => watchers.push(cb) }),
|
|
84
|
+
},
|
|
85
|
+
setTunnel(patch) { store.tunnel = { ...store.tunnel, ...patch }; for (const cb of watchers) cb() },
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function volatileRef(value) {
|
|
90
|
+
return { get: () => (typeof value === 'function' ? value() : value) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const tempDataDirs = new Set()
|
|
94
|
+
const defaultDataDir = () => {
|
|
95
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-tunnel-ai-'))
|
|
96
|
+
tempDataDirs.add(dir)
|
|
97
|
+
return dir
|
|
98
|
+
}
|
|
99
|
+
process.on('exit', () => {
|
|
100
|
+
for (const dir of tempDataDirs) rmSync(dir, { recursive: true, force: true })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
async function applyPlugin(config = {}, services = {}) {
|
|
104
|
+
const { apply } = await import('../src/index.js')
|
|
105
|
+
const tools = services.tools ?? createToolsService()
|
|
106
|
+
const webServer = new MockWebServer()
|
|
107
|
+
const { ctx, events, effectDisposers } = createCtx({ ...services, tools })
|
|
108
|
+
ctx.webServer = webServer
|
|
109
|
+
const full = { connectTimeoutMs: 10 * 1000, dataDir: defaultDataDir(), ...config }
|
|
110
|
+
const dispose = apply(ctx, full)
|
|
111
|
+
const disposeAll = () => {
|
|
112
|
+
dispose()
|
|
113
|
+
for (const disposer of effectDisposers.reverse()) disposer()
|
|
114
|
+
}
|
|
115
|
+
return { webServer, tools, events, dispose: disposeAll }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function callApi(webServer, method, path, body) {
|
|
119
|
+
const route = webServer.prefixes.get('/api/tunnel')
|
|
120
|
+
assert.ok(route, 'REST 前缀路由未注册')
|
|
121
|
+
const req = new EventEmitter()
|
|
122
|
+
req.method = method
|
|
123
|
+
req.url = '/api/tunnel' + path
|
|
124
|
+
req.headers = { host: 'localhost:3080' }
|
|
125
|
+
const res = { status: null, payload: null }
|
|
126
|
+
res.writeHead = (status) => { res.status = status }
|
|
127
|
+
res.end = (text) => {
|
|
128
|
+
try { res.payload = JSON.parse(text) } catch { res.payload = null }
|
|
129
|
+
}
|
|
130
|
+
const done = route.handler(req, res)
|
|
131
|
+
process.nextTick(() => {
|
|
132
|
+
if (body !== undefined) req.emit('data', Buffer.from(JSON.stringify(body)))
|
|
133
|
+
req.emit('end')
|
|
134
|
+
})
|
|
135
|
+
await done
|
|
136
|
+
return res
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const openTunnel = (webServer) => callApi(webServer, 'POST', '/tunnels', { name: 'app', targetPort: 80 })
|
|
140
|
+
|
|
141
|
+
test('默认注册三件工具, status 暴露 aiTools 开', async () => {
|
|
142
|
+
const { webServer, tools, dispose } = await applyPlugin()
|
|
143
|
+
try {
|
|
144
|
+
assert.deepEqual(tools.names().sort(), [...TOOL_NAMES].sort())
|
|
145
|
+
const status = await callApi(webServer, 'GET', '/status')
|
|
146
|
+
assert.equal(status.payload.ui.aiTools, true)
|
|
147
|
+
} finally { dispose() }
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('config.aiTools=false 不注册工具, 看板 REST 增删照常', async () => {
|
|
151
|
+
const { webServer, tools, dispose } = await applyPlugin({ aiTools: false })
|
|
152
|
+
try {
|
|
153
|
+
assert.deepEqual(tools.names(), [])
|
|
154
|
+
const opened = await openTunnel(webServer)
|
|
155
|
+
assert.equal(opened.payload.ok, true)
|
|
156
|
+
const listed = await callApi(webServer, 'GET', '/tunnels')
|
|
157
|
+
assert.deepEqual(listed.payload.items.map((row) => row.name), ['app'])
|
|
158
|
+
const status = await callApi(webServer, 'GET', '/status')
|
|
159
|
+
assert.equal(status.payload.ui.aiTools, false)
|
|
160
|
+
} finally { dispose() }
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('legacy 设置节运行时翻转: 关闭注销, 重开恢复', async () => {
|
|
164
|
+
const settings = createSettingsService()
|
|
165
|
+
const { tools, dispose } = await applyPlugin({}, { settings: settings.service })
|
|
166
|
+
try {
|
|
167
|
+
assert.deepEqual(tools.names().sort(), [...TOOL_NAMES].sort())
|
|
168
|
+
settings.setTunnel({ aiTools: false })
|
|
169
|
+
assert.deepEqual(tools.names(), [])
|
|
170
|
+
settings.setTunnel({ aiTools: true })
|
|
171
|
+
assert.deepEqual(tools.names().sort(), [...TOOL_NAMES].sort())
|
|
172
|
+
} finally { dispose() }
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('legacy 设置节初值 false: 激活后保持注销', async () => {
|
|
176
|
+
const settings = createSettingsService({ aiTools: false })
|
|
177
|
+
const { tools, dispose } = await applyPlugin({}, { settings: settings.service })
|
|
178
|
+
try {
|
|
179
|
+
assert.deepEqual(tools.names(), [])
|
|
180
|
+
} finally { dispose() }
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('0.1.7 形态: volatile ref 初值生效, volatile-update 驱动翻转', async () => {
|
|
184
|
+
let aiValue = false
|
|
185
|
+
const { tools, events, dispose } = await applyPlugin({
|
|
186
|
+
aiTools: volatileRef(() => aiValue),
|
|
187
|
+
sidebarTab: volatileRef(false),
|
|
188
|
+
connectTimeoutMs: volatileRef(5 * 1000),
|
|
189
|
+
})
|
|
190
|
+
try {
|
|
191
|
+
assert.deepEqual(tools.names(), [])
|
|
192
|
+
aiValue = true
|
|
193
|
+
for (const handler of events.get('loader/volatile-update') ?? []) handler()
|
|
194
|
+
assert.deepEqual(tools.names().sort(), [...TOOL_NAMES].sort())
|
|
195
|
+
aiValue = false
|
|
196
|
+
for (const handler of events.get('loader/volatile-update') ?? []) handler()
|
|
197
|
+
assert.deepEqual(tools.names(), [])
|
|
198
|
+
} finally { dispose() }
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('REST ui-settings 翻转 aiTools: 工具注销且回写 ui(legacy 通道)', async () => {
|
|
202
|
+
const settings = createSettingsService()
|
|
203
|
+
const { webServer, tools, dispose } = await applyPlugin({}, { settings: settings.service })
|
|
204
|
+
try {
|
|
205
|
+
const flipped = await callApi(webServer, 'POST', '/ui-settings', { aiTools: false })
|
|
206
|
+
assert.equal(flipped.status, 200)
|
|
207
|
+
assert.equal(flipped.payload.ok, true)
|
|
208
|
+
assert.equal(flipped.payload.ui.aiTools, false)
|
|
209
|
+
assert.deepEqual(settings.store.tunnel.aiTools, false)
|
|
210
|
+
assert.deepEqual(tools.names(), [])
|
|
211
|
+
const restored = await callApi(webServer, 'POST', '/ui-settings', { aiTools: true })
|
|
212
|
+
assert.equal(restored.payload.ui.aiTools, true)
|
|
213
|
+
assert.deepEqual(tools.names().sort(), [...TOOL_NAMES].sort())
|
|
214
|
+
} finally { dispose() }
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('0.1.7 写路径: aiTools 经 configEditor 落 profile 条目, ref 写穿后注册面跟随', async () => {
|
|
218
|
+
// mock loader updateVolatile 语义: edit 落条目 → volatile ref 原地换值 → 偏好重读生效
|
|
219
|
+
const entry = { aiTools: true }
|
|
220
|
+
const configEditor = {
|
|
221
|
+
edit: async (_entry, change) => { Object.assign(entry, change({ ...entry })) },
|
|
222
|
+
}
|
|
223
|
+
const { webServer, tools, dispose } = await applyPlugin({
|
|
224
|
+
aiTools: volatileRef(() => entry.aiTools),
|
|
225
|
+
}, { configEditor, fiber: { entry: { id: 'row-1' } } })
|
|
226
|
+
try {
|
|
227
|
+
const flipped = await callApi(webServer, 'POST', '/ui-settings', { aiTools: false })
|
|
228
|
+
assert.equal(flipped.payload.ok, true)
|
|
229
|
+
assert.deepEqual(entry, { aiTools: false })
|
|
230
|
+
assert.deepEqual(tools.names(), [])
|
|
231
|
+
} finally { dispose() }
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('ui-settings 校验: 非布尔 400, 空补丁 400', async () => {
|
|
235
|
+
const { webServer, dispose } = await applyPlugin()
|
|
236
|
+
try {
|
|
237
|
+
const bad = await callApi(webServer, 'POST', '/ui-settings', { aiTools: 'yes' })
|
|
238
|
+
assert.equal(bad.status, 400)
|
|
239
|
+
assert.match(bad.payload.error, /aiTools 须为布尔/)
|
|
240
|
+
const empty = await callApi(webServer, 'POST', '/ui-settings', {})
|
|
241
|
+
assert.equal(empty.status, 400)
|
|
242
|
+
assert.match(empty.payload.error, /须为布尔/)
|
|
243
|
+
} finally { dispose() }
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('写路径服务缺失降级: updateUi 返回 ok:false', async () => {
|
|
247
|
+
const { webServer, tools, dispose } = await applyPlugin()
|
|
248
|
+
try {
|
|
249
|
+
const res = await callApi(webServer, 'POST', '/ui-settings', { aiTools: false })
|
|
250
|
+
assert.equal(res.status, 200)
|
|
251
|
+
assert.equal(res.payload.ok, false)
|
|
252
|
+
assert.match(res.payload.error, /设置服务不可用/)
|
|
253
|
+
assert.deepEqual(tools.names().sort(), [...TOOL_NAMES].sort(), '写失败不得翻转注册面')
|
|
254
|
+
} finally { dispose() }
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('插件停用回收工具注册', async () => {
|
|
258
|
+
const { tools, dispose } = await applyPlugin()
|
|
259
|
+
assert.deepEqual(tools.names().sort(), [...TOOL_NAMES].sort())
|
|
260
|
+
dispose()
|
|
261
|
+
assert.deepEqual(tools.names(), [])
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('Config volatile 标记面: 三个偏好字段 live, dataDir 不进 UI', async () => {
|
|
265
|
+
const { Config, SETTINGS_SCHEMA } = await import('../src/index.js')
|
|
266
|
+
assert.equal(Config.dict.aiTools.meta.volatile, true)
|
|
267
|
+
assert.equal(Config.dict.sidebarTab.meta.volatile, true)
|
|
268
|
+
assert.equal(Config.dict.connectTimeoutMs.meta.volatile, true)
|
|
269
|
+
assert.equal(Config.dict.dataDir.meta.volatile, undefined)
|
|
270
|
+
assert.equal(SETTINGS_SCHEMA.dict.aiTools.meta.default, true)
|
|
271
|
+
assert.equal(SETTINGS_SCHEMA.dict.dataDir, undefined, 'legacy 设置节不收环境路径')
|
|
272
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// REST 路由测试: CRUD / status / ui-settings / 错误语义(设计 docs/设计-隧道GUI.md §3.1)。
|
|
2
|
+
// 桩形态对齐 cron-board routes.test.mjs: makeReq/call 内存桩, tunnelsApi 业务桩。
|
|
3
|
+
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { EventEmitter } from 'node:events'
|
|
7
|
+
|
|
8
|
+
import { createApi } from '../src/api.mjs'
|
|
9
|
+
|
|
10
|
+
function makeTunnelsApi() {
|
|
11
|
+
const rows = new Map()
|
|
12
|
+
return {
|
|
13
|
+
rows,
|
|
14
|
+
open(args) {
|
|
15
|
+
if (rows.has(args.name)) return { ok: false, error: `隧道名已占用: ${args.name}` }
|
|
16
|
+
rows.set(args.name, { createdAt: 't' })
|
|
17
|
+
return { ok: true, name: args.name }
|
|
18
|
+
},
|
|
19
|
+
list() {
|
|
20
|
+
return { ok: true, tunnels: [...rows.keys()].map((name) => ({ name })) }
|
|
21
|
+
},
|
|
22
|
+
close({ name }) {
|
|
23
|
+
const removed = rows.delete(name)
|
|
24
|
+
return { ok: true, removed }
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeReq(method, pathAndQuery, body) {
|
|
30
|
+
const req = new EventEmitter()
|
|
31
|
+
req.method = method
|
|
32
|
+
req.url = pathAndQuery
|
|
33
|
+
req.headers = { host: 'localhost:3000' }
|
|
34
|
+
if (body !== undefined) {
|
|
35
|
+
const payload = typeof body === 'string' ? body : JSON.stringify(body)
|
|
36
|
+
process.nextTick(() => {
|
|
37
|
+
req.emit('data', Buffer.from(payload))
|
|
38
|
+
req.emit('end')
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
return req
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function call(api, method, pathAndQuery, body) {
|
|
45
|
+
const res = { status: null, payload: null }
|
|
46
|
+
res.writeHead = (status) => { res.status = status }
|
|
47
|
+
res.end = (text) => {
|
|
48
|
+
res.raw = text
|
|
49
|
+
try { res.payload = JSON.parse(text) } catch { res.payload = null }
|
|
50
|
+
}
|
|
51
|
+
await api.handle(makeReq(method, pathAndQuery, body), res)
|
|
52
|
+
return res
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeApi(overrides = {}) {
|
|
56
|
+
const tunnelsApi = makeTunnelsApi()
|
|
57
|
+
const api = createApi({
|
|
58
|
+
tunnelsApi,
|
|
59
|
+
readUi: overrides.readUi ?? (() => ({ sidebarTab: false })),
|
|
60
|
+
updateUi: overrides.updateUi ?? ((body) => {
|
|
61
|
+
if (typeof body?.sidebarTab !== 'boolean') throw new Error('sidebarTab 须为布尔')
|
|
62
|
+
return { ok: true, ui: { sidebarTab: body.sidebarTab } }
|
|
63
|
+
}),
|
|
64
|
+
logSystem: overrides.logSystem ?? (() => {}),
|
|
65
|
+
})
|
|
66
|
+
return { tunnelsApi, api }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('GET tunnels 返回同表列表', async () => {
|
|
70
|
+
const { tunnelsApi, api } = makeApi()
|
|
71
|
+
tunnelsApi.open({ name: 'app', targetPort: 80 })
|
|
72
|
+
const res = await call(api, 'GET', '/api/tunnel/tunnels')
|
|
73
|
+
assert.equal(res.status, 200)
|
|
74
|
+
assert.deepEqual(res.payload.items, [{ name: 'app' }])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('POST tunnels 合法入表, 业务错误 400 中文透传', async () => {
|
|
78
|
+
const { api } = makeApi()
|
|
79
|
+
const ok = await call(api, 'POST', '/api/tunnel/tunnels', { name: 'app', targetPort: 80 })
|
|
80
|
+
assert.equal(ok.status, 200)
|
|
81
|
+
assert.equal(ok.payload.ok, true)
|
|
82
|
+
const clash = await call(api, 'POST', '/api/tunnel/tunnels', { name: 'app', targetPort: 81 })
|
|
83
|
+
assert.equal(clash.status, 400)
|
|
84
|
+
assert.match(clash.payload.error, /已占用/)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('DELETE tunnels/:name 幂等', async () => {
|
|
88
|
+
const { api } = makeApi()
|
|
89
|
+
await call(api, 'POST', '/api/tunnel/tunnels', { name: 'app', targetPort: 80 })
|
|
90
|
+
const first = await call(api, 'DELETE', '/api/tunnel/tunnels/app')
|
|
91
|
+
assert.deepEqual(first.payload, { ok: true, removed: true })
|
|
92
|
+
const again = await call(api, 'DELETE', '/api/tunnel/tunnels/app')
|
|
93
|
+
assert.deepEqual(again.payload, { ok: true, removed: false })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('GET status 携带 ui 与 total', async () => {
|
|
97
|
+
const { tunnelsApi, api } = makeApi()
|
|
98
|
+
tunnelsApi.open({ name: 'a', targetPort: 80 })
|
|
99
|
+
tunnelsApi.open({ name: 'b', targetPort: 81 })
|
|
100
|
+
const res = await call(api, 'GET', '/api/tunnel/status')
|
|
101
|
+
assert.deepEqual(res.payload, { ui: { sidebarTab: false }, total: 2 })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('POST ui-settings 合法回写, 非法 400', async () => {
|
|
105
|
+
const seen = []
|
|
106
|
+
const { api } = makeApi({
|
|
107
|
+
updateUi: (body) => {
|
|
108
|
+
if (typeof body?.sidebarTab !== 'boolean') throw new Error('sidebarTab 须为布尔')
|
|
109
|
+
seen.push(body.sidebarTab)
|
|
110
|
+
return { ok: true, ui: { sidebarTab: body.sidebarTab } }
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
const ok = await call(api, 'POST', '/api/tunnel/ui-settings', { sidebarTab: true })
|
|
114
|
+
assert.deepEqual(ok.payload, { ok: true, ui: { sidebarTab: true } })
|
|
115
|
+
assert.deepEqual(seen, [true])
|
|
116
|
+
const bad = await call(api, 'POST', '/api/tunnel/ui-settings', { sidebarTab: 'yes' })
|
|
117
|
+
assert.equal(bad.status, 400)
|
|
118
|
+
assert.match(bad.payload.error, /布尔/)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('POST ui-settings 服务缺失降级: updateUi 返回 ok:false 时 200 透传', async () => {
|
|
122
|
+
const { api } = makeApi({
|
|
123
|
+
updateUi: () => ({ ok: false, error: '设置服务不可用' }),
|
|
124
|
+
})
|
|
125
|
+
const res = await call(api, 'POST', '/api/tunnel/ui-settings', { sidebarTab: true })
|
|
126
|
+
assert.equal(res.status, 200)
|
|
127
|
+
assert.deepEqual(res.payload, { ok: false, error: '设置服务不可用' })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('DELETE 畸形百分号编码按 404 不抛英文错误', async () => {
|
|
131
|
+
const { api } = makeApi()
|
|
132
|
+
const res = await call(api, 'DELETE', '/api/tunnel/tunnels/%E0%A4%A')
|
|
133
|
+
assert.equal(res.status, 404)
|
|
134
|
+
assert.match(res.payload.error, /接口不存在/)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('未知路径与方法 404, 非法 JSON 400', async () => {
|
|
138
|
+
const { api } = makeApi()
|
|
139
|
+
assert.equal((await call(api, 'GET', '/api/tunnel/nope')).status, 404)
|
|
140
|
+
assert.equal((await call(api, 'PATCH', '/api/tunnel/tunnels')).status, 404)
|
|
141
|
+
assert.equal((await call(api, 'GET', '/other')).status, 404)
|
|
142
|
+
const bad = await call(api, 'POST', '/api/tunnel/tunnels', '{not-json')
|
|
143
|
+
assert.equal(bad.status, 400)
|
|
144
|
+
assert.match(bad.payload.error, /JSON/)
|
|
145
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// client.js 注册 id 与样式标记守卫:loader 按 graph row id(完整包名)匹配注册, 短名即加载失败;
|
|
2
|
+
// 样式注入缺 data-plugin 会被宿主 claimStyles 误归属给后续材质化插件, 其 HMR 重建即整批误删。
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import test from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
|
|
9
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
10
|
+
const { name } = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
|
|
11
|
+
|
|
12
|
+
test('client.js 注册 id 为完整包名', () => {
|
|
13
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
14
|
+
const match = source.match(/__ModuleLoader__\.load\(\{\s*id:\s*'([^']+)'/)
|
|
15
|
+
assert.ok(match, 'client.js 缺少 __ModuleLoader__.load 注册')
|
|
16
|
+
assert.equal(match[1], name)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('client.js 样式注入自带 data-plugin', () => {
|
|
20
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
21
|
+
const injections = source.split("createElement('style')").length - 1
|
|
22
|
+
assert.ok(injections > 0, 'client.js 无样式注入点')
|
|
23
|
+
const marked = source.split(`setAttribute('data-plugin', '${name}')`).length - 1
|
|
24
|
+
assert.equal(marked, injections, '每个样式注入点都必须自带 data-plugin(值=注册包名), 防宿主 claimStyles 误归属后随他插件 HMR 整批误删')
|
|
25
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// 开关样式守卫 BDD:裸 checkbox/裸 input 状态选择器会静默损坏 UI 或误伤同 label 的文本输入,
|
|
2
|
+
// 属无报错的 UI 退化, 静态断言锁死 tu-switch 结构(参照 cb-switch/mce-switch 同款守卫)。
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import test from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
|
|
9
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
10
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
11
|
+
|
|
12
|
+
test('switch 隐藏规则以 input[type="checkbox"] 精确匹配', () => {
|
|
13
|
+
assert.match(source, /\.tu-switch input\[type="checkbox"\] \{ position:absolute/)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('switch 状态选择器禁止裸 input 锚定(防误伤同 label 的文本输入)', () => {
|
|
17
|
+
const bare = source.match(/\.tu-switch input:(?!\[type)[a-z-]+/g)
|
|
18
|
+
assert.equal(bare, null, `裸 input 状态选择器: ${bare}`)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('checkbox 仅允许出现在 switchToggle 工厂内, 禁止裸 checkbox 直出', () => {
|
|
22
|
+
const occurrences = [...source.matchAll(/type: 'checkbox'/g)].map((match) => match.index)
|
|
23
|
+
assert.equal(occurrences.length, 1, `checkbox 字面量出现 ${occurrences.length} 次`)
|
|
24
|
+
const factoryStart = source.indexOf('function switchToggle')
|
|
25
|
+
const factoryEnd = source.indexOf('}', source.indexOf('__thumb', factoryStart))
|
|
26
|
+
assert.ok(occurrences[0] > factoryStart && occurrences[0] < factoryEnd, 'checkbox 字面量不在 switchToggle 工厂内')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('switchToggle 产出顺序为 input 在前 track 在后', () => {
|
|
30
|
+
const factory = source.slice(source.indexOf('function switchToggle'))
|
|
31
|
+
assert.ok(factory.indexOf("h('input'") < factory.indexOf('tu-switch__track'), '工厂内 input 必须先于 track')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('disabled 态 hover 高亮被 :has 守卫排除', () => {
|
|
35
|
+
assert.match(source, /\.tu-switch:not\(:has\(input\[type="checkbox"\]:disabled\)\):hover/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('checked/focus-visible/disabled 三态均以 checkbox 锚定', () => {
|
|
39
|
+
assert.match(source, /\.tu-switch input\[type="checkbox"\]:checked \+/)
|
|
40
|
+
assert.match(source, /\.tu-switch input\[type="checkbox"\]:focus-visible \+/)
|
|
41
|
+
assert.match(source, /\.tu-switch input\[type="checkbox"\]:disabled \+/)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('插件页卡片为官方 PluginCard 形制: li 卡壳 + 头部折叠 + 令牌同源', () => {
|
|
45
|
+
// Given 官方插件卡片 = ul.cards > li.PluginCard(边框卡壳 + 名称/描述头部 + chevron 折叠)
|
|
46
|
+
// When 本插件卡片注册进同一列表(slot 契约: 外观归插件自持, 官方壳未导出)
|
|
47
|
+
// Then 静态锁定 tu-pc 镜像形制, 防止退化回裸 div 平铺
|
|
48
|
+
assert.match(source, /h\('li', \{ className: 'tu-pc/, '卡片根元素必须是 li(官方列表 ul.cards 的合法子元素)')
|
|
49
|
+
assert.match(source, /\.tu-pc\{border:\.5px solid var\(--dsw-alias-border-l4\)/, '卡壳边框/底色令牌必须与官方 PluginCard 同源')
|
|
50
|
+
assert.match(source, /h\('button', \{ type: 'button', className: 'tu-pc__head'/, '缺少官方形制的头部按钮')
|
|
51
|
+
assert.match(source, /aria-expanded/, '头部必须可折叠并暴露 aria-expanded')
|
|
52
|
+
assert.match(source, /\.tu-pc__hint\{[^}]*var\(--dsw-alias-label-tertiary\)/, '提示文本令牌必须与官方 hint 同源')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('client.js 无顶层词法声明(经典 script 书挡内安全)', () => {
|
|
56
|
+
// IIFE 书挡内允许任意声明;守卫确认整文件被书挡包裹
|
|
57
|
+
const trimmed = source.trim()
|
|
58
|
+
assert.ok(trimmed.startsWith('(() => {'), '必须以 IIFE 书挡开头')
|
|
59
|
+
assert.ok(trimmed.endsWith('})()'), '必须以 IIFE 书挡结尾')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('类名前缀全量 tu-(插件样式全局注入, 跨包类名冲突即互相覆盖)', () => {
|
|
63
|
+
const classNames = [...source.matchAll(/className: '([a-z][a-z0-9_-]*)/g)].map((match) => match[1])
|
|
64
|
+
const foreign = classNames.filter((name) => !name.startsWith('tu-'))
|
|
65
|
+
assert.deepEqual(foreign, [], `存在非 tu- 前缀类名: ${[...new Set(foreign)].join(', ')}`)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('插件卡双代槽位并存注册(0.1.2-0.1.5 settings.plugin.item; 0.1.7-rc.1+ plugins.bundle.config)', () => {
|
|
69
|
+
// Given settings>插件 页可配置卡在 rc.1 被宿主移除, 插件管理页包详情配置槽为 rc.1 载体
|
|
70
|
+
// Then 两代注册面同在, 新代 key = 完整包名, 错误世代宿主惰性零成本
|
|
71
|
+
assert.match(source, /ctx\.slots\.inject\('settings\.plugin\.item'/)
|
|
72
|
+
assert.match(source, /key: PANEL_ID, label: PANEL_LABEL/)
|
|
73
|
+
assert.match(source, /ctx\.slots\.inject\('plugins\.bundle\.config'/)
|
|
74
|
+
assert.match(source, /name: 'plugins\.bundle\.config', key: '@mzzsfy\/dsh-tunnel'/)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('设置卡提供 ai 使用开关: aiTools 读写面在场且经 ui-settings 提交', () => {
|
|
78
|
+
assert.match(source, /允许 ai 使用隧道工具/, '缺少 ai 使用开关行标签')
|
|
79
|
+
assert.match(source, /flip\('aiTools', aiOn\)/, 'ai 开关未接 flip 写通道')
|
|
80
|
+
assert.match(source, /outcome\.data\.ui\.aiTools/, '开关态未回读 aiTools')
|
|
81
|
+
})
|