@hyzyn/dsh-docker 0.6.4 → 0.7.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.en.md +31 -16
- package/README.md +31 -18
- package/client-src/current-session.js +52 -0
- package/client-src/docker.css +2180 -0
- package/client-src/index.js +6691 -0
- package/client-src/session-target.js +92 -0
- package/client.js +46 -24
- package/lib/docker.d.ts +62 -5
- package/lib/docker.js +287 -48
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +19 -1
- package/lib/index.js +595 -80
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +30 -11
- package/lib/ssh-exec.js +360 -143
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +4 -2
- package/scripts/build-client.mjs +33 -0
- package/scripts/client-smoke.mjs +1851 -0
- package/scripts/route-smoke.mjs +1091 -0
- package/scripts/smoke.mjs +874 -0
|
@@ -0,0 +1,1851 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @hyzyn/dsh-docker — 浏览器半体装载回归(无浏览器,最小 DOM / React / fetch 桩)。
|
|
4
|
+
*
|
|
5
|
+
* 目的:client.js 是随包发布的构建产物,语法或顶层引用一旦出错,只有用户
|
|
6
|
+
* 打开页面时才会暴露。这里在 Node 里用最小桩执行它,验证:
|
|
7
|
+
* - window.__ModuleLoader__.load 的 id 与 factory 形状正确
|
|
8
|
+
* - factory 只 require 平台 seed 提供的模块(react / react/jsx-runtime /
|
|
9
|
+
* react-dom/client),不会引入未提供的依赖
|
|
10
|
+
* - apply 注册了 settings.plugin.item 卡片且 key 为 settings 命名空间 `docker`
|
|
11
|
+
* - 侧边栏入口挂载在「找不到宿主侧边栏」时安静降级,不抛异常
|
|
12
|
+
* - 卸载函数可重复调用(宿主热重载路径)
|
|
13
|
+
* - ttyConnbar 集成:SSH 会话按连接簿名 / host:port 命中目标才插入「容器」
|
|
14
|
+
* 按钮;未命中、非 SSH、tty 未安装三种情况都不报错
|
|
15
|
+
*
|
|
16
|
+
* 用法:pnpm --filter @hyzyn/dsh-docker build && node scripts/client-smoke.mjs
|
|
17
|
+
*/
|
|
18
|
+
import { strict as assert } from 'node:assert'
|
|
19
|
+
import { readFileSync } from 'node:fs'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
|
|
23
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
24
|
+
const code = readFileSync(join(root, 'client.js'), 'utf8')
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 还原 esbuild 的 ASCII 转义,便于断言中文文案:
|
|
28
|
+
* 码位 ≥ 0x100 是 \uXXXX(中文),< 0x100 是 \xHH(间隔号 · 这类)。
|
|
29
|
+
* 只处理 \uXXXX 会漏掉后者,断言里会莫名差一个字符。
|
|
30
|
+
*/
|
|
31
|
+
function decodeBundle(text) {
|
|
32
|
+
return text
|
|
33
|
+
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
34
|
+
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const results = []
|
|
38
|
+
/*
|
|
39
|
+
* 看门狗(D71):任一用例挂起不该把整个脚本永久挂住(CI 会一直转到超时被杀)。
|
|
40
|
+
* 两层:单用例 15s(挂起的用例标失败,其余继续跑)+ 全局 90s(兜底,直接退出 1)。
|
|
41
|
+
*/
|
|
42
|
+
// 25s > ssh 建连超时 20s(D122):否则「目标不回 RST」的环境里,
|
|
43
|
+
// 用例会被看门狗报成「用例超时」,把真因盖住
|
|
44
|
+
const CASE_TIMEOUT_MS = 25_000
|
|
45
|
+
const WATCHDOG_MS = 90_000
|
|
46
|
+
const watchdog = setTimeout(() => {
|
|
47
|
+
const last = [...results].reverse().find((result) => result.ok)
|
|
48
|
+
console.error(`\n[dsh-docker] 看门狗超时(${String(WATCHDOG_MS / 1000)}s),脚本挂起;最后通过的用例:${last === undefined ? '(无)' : last.name}`)
|
|
49
|
+
process.exit(1)
|
|
50
|
+
}, WATCHDOG_MS)
|
|
51
|
+
async function test(name, fn) {
|
|
52
|
+
let timer = null
|
|
53
|
+
try {
|
|
54
|
+
await Promise.race([
|
|
55
|
+
Promise.resolve().then(fn),
|
|
56
|
+
new Promise((_, reject) => {
|
|
57
|
+
timer = setTimeout(() => reject(new Error(`用例超时(${String(CASE_TIMEOUT_MS / 1000)}s)`)), CASE_TIMEOUT_MS)
|
|
58
|
+
}),
|
|
59
|
+
])
|
|
60
|
+
results.push({ name, ok: true })
|
|
61
|
+
} catch (error) {
|
|
62
|
+
results.push({ name, ok: false, message: error instanceof Error ? error.message : String(error) })
|
|
63
|
+
} finally {
|
|
64
|
+
if (timer !== null) clearTimeout(timer)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ------------------------------------------------------------------ *
|
|
69
|
+
* 最小 DOM / React / fetch 桩
|
|
70
|
+
* ------------------------------------------------------------------ */
|
|
71
|
+
|
|
72
|
+
function makeElement(tag) {
|
|
73
|
+
const element = {
|
|
74
|
+
tagName: String(tag).toUpperCase(),
|
|
75
|
+
className: '',
|
|
76
|
+
dataset: {},
|
|
77
|
+
style: {},
|
|
78
|
+
children: [],
|
|
79
|
+
parentElement: null,
|
|
80
|
+
isConnected: false,
|
|
81
|
+
id: '',
|
|
82
|
+
textContent: '',
|
|
83
|
+
innerHTML: '',
|
|
84
|
+
setAttribute() {},
|
|
85
|
+
addEventListener() {},
|
|
86
|
+
removeEventListener() {},
|
|
87
|
+
appendChild(child) {
|
|
88
|
+
child.parentElement = element
|
|
89
|
+
element.children.push(child)
|
|
90
|
+
return child
|
|
91
|
+
},
|
|
92
|
+
remove() {},
|
|
93
|
+
closest() { return null },
|
|
94
|
+
querySelector() { return null },
|
|
95
|
+
querySelectorAll() { return [] },
|
|
96
|
+
contains() { return false },
|
|
97
|
+
matches() { return false },
|
|
98
|
+
}
|
|
99
|
+
return element
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const documentStub = {
|
|
103
|
+
head: makeElement('head'),
|
|
104
|
+
body: makeElement('body'),
|
|
105
|
+
createElement: (tag) => makeElement(tag),
|
|
106
|
+
getElementById: () => null,
|
|
107
|
+
querySelector: () => null,
|
|
108
|
+
querySelectorAll: () => [],
|
|
109
|
+
addEventListener() {},
|
|
110
|
+
removeEventListener() {},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ReactStub = {
|
|
114
|
+
useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
|
|
115
|
+
useEffect: () => {},
|
|
116
|
+
useRef: (initial) => ({ current: initial }),
|
|
117
|
+
useCallback: (fn) => fn,
|
|
118
|
+
/*
|
|
119
|
+
* 上下文(S3 的「面板是否可见」用):桩不做真实渲染,所以 useContext 只能拿到
|
|
120
|
+
* createContext 的**默认值**。对本文件够用——既有用例全走「默认可见」的模态路径,
|
|
121
|
+
* 而门控真正的行为(visible=false → 断流)发生在 effect 里,本就不在这个桩的射程内,
|
|
122
|
+
* 那条路径由手工验收覆盖。
|
|
123
|
+
*/
|
|
124
|
+
createContext: (initial) => ({ __context: true, initial }),
|
|
125
|
+
useContext: (context) => (context === null || context === undefined ? undefined : context.initial),
|
|
126
|
+
/*
|
|
127
|
+
* 类组件基类(探针的错误边界用):桩里缺这一项时 `class X extends React.Component`
|
|
128
|
+
* **直接死在工厂体**(extends undefined),整套用例一起挂——和当初漏 createContext 同因。
|
|
129
|
+
* 只提供构造与 props/state 的最小语义,不做真实挂载。
|
|
130
|
+
*/
|
|
131
|
+
Component: class {
|
|
132
|
+
constructor(props) {
|
|
133
|
+
this.props = props
|
|
134
|
+
this.state = {}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setState() {}
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
const jsx = (type, props, key) => ({ type, props: { ...(props ?? {}), key } })
|
|
141
|
+
const jsxs = (type, props, key) => ({ type, props: { ...(props ?? {}), key } })
|
|
142
|
+
/** 记录每次 createRoot().render() 的调用,供断言面板入参。 */
|
|
143
|
+
const renders = []
|
|
144
|
+
const createRoot = () => ({ render(element) { renders.push(element) }, unmount() {} })
|
|
145
|
+
|
|
146
|
+
const SEED = {
|
|
147
|
+
react: ReactStub,
|
|
148
|
+
'react/jsx-runtime': { jsx, jsxs },
|
|
149
|
+
'react-dom/client': { createRoot },
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 假 /config 与 /targets:让连接栏匹配逻辑拿到真实形状的数据。 */
|
|
153
|
+
const FAKE_CONFIG = {
|
|
154
|
+
enabled: true,
|
|
155
|
+
announceToAgent: true,
|
|
156
|
+
dockerBin: 'docker',
|
|
157
|
+
allowMutations: false,
|
|
158
|
+
allowExec: false,
|
|
159
|
+
execTimeoutSec: 30,
|
|
160
|
+
pollIntervalSec: 5,
|
|
161
|
+
logTailDefault: 200,
|
|
162
|
+
maxOutputKb: 512,
|
|
163
|
+
targets: [
|
|
164
|
+
{ name: '本机', kind: 'local', book: '', host: '', port: 22, username: '', auth: 'agent', keyPath: '', agentForward: false, passwordSet: false, passphraseSet: false },
|
|
165
|
+
{ name: 'prod', kind: 'ssh', book: 'prod-a', host: '', port: 22, username: '', auth: 'agent', keyPath: '', agentForward: false, passwordSet: false, passphraseSet: false },
|
|
166
|
+
{ name: 'inline', kind: 'ssh', book: '', host: '10.0.0.9', port: 2200, username: 'ops', auth: 'agent', keyPath: '', agentForward: false, passwordSet: false, passphraseSet: false },
|
|
167
|
+
],
|
|
168
|
+
hostKeys: [],
|
|
169
|
+
ttyBooks: ['prod-a', 'prod-legacy'],
|
|
170
|
+
/*
|
|
171
|
+
* 连接簿条目 → host:port(宿主只回 name/host/port)。`prod-legacy` 不被任何目标的
|
|
172
|
+
* book 引用,但和目标是同一台主机:用它钉住「连接失败时按 host 兜底」这条路径。
|
|
173
|
+
*/
|
|
174
|
+
ttyBookHosts: [
|
|
175
|
+
{ name: 'prod-a', host: '10.0.0.5', port: 2222 },
|
|
176
|
+
{ name: 'prod-legacy', host: '10.0.0.5', port: 2222 },
|
|
177
|
+
],
|
|
178
|
+
ttyAvailable: true,
|
|
179
|
+
toolsRegistered: [],
|
|
180
|
+
}
|
|
181
|
+
const FAKE_TARGETS = [
|
|
182
|
+
{ name: '本机', kind: 'local', label: '本机' },
|
|
183
|
+
{ name: 'prod', kind: 'ssh', label: 'root@10.0.0.5:2222' },
|
|
184
|
+
{ name: 'inline', kind: 'ssh', label: 'ops@10.0.0.9:2200' },
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
const fetchStub = (url) => {
|
|
188
|
+
const path = String(url)
|
|
189
|
+
const payload = path.endsWith('/config')
|
|
190
|
+
? { ok: true, config: FAKE_CONFIG }
|
|
191
|
+
: path.endsWith('/targets')
|
|
192
|
+
? { ok: true, targets: FAKE_TARGETS }
|
|
193
|
+
: { ok: false, error: 'unexpected ' + path }
|
|
194
|
+
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(payload) })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let registration = null
|
|
198
|
+
const windowStub = {
|
|
199
|
+
__ModuleLoader__: {
|
|
200
|
+
load: (entry) => {
|
|
201
|
+
registration = entry
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ------------------------------------------------------------------ *
|
|
207
|
+
* 执行 bundle
|
|
208
|
+
* ------------------------------------------------------------------ */
|
|
209
|
+
|
|
210
|
+
await test('client.js 可执行并注册 @hyzyn/dsh-docker', () => {
|
|
211
|
+
const run = new Function('window', 'document', 'MutationObserver', 'fetch', code)
|
|
212
|
+
run(windowStub, documentStub, class { observe() {} disconnect() {} }, fetchStub)
|
|
213
|
+
assert.ok(registration !== null, '未调用 __ModuleLoader__.load')
|
|
214
|
+
assert.equal(registration.id, '@hyzyn/dsh-docker')
|
|
215
|
+
assert.equal(typeof registration.factory, 'function')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
await test('factory 只 require 平台 seed 提供的模块', () => {
|
|
219
|
+
const required = []
|
|
220
|
+
const requireStub = (spec) => {
|
|
221
|
+
required.push(spec)
|
|
222
|
+
const module = SEED[spec]
|
|
223
|
+
if (module === undefined) throw new Error(`client-modules: require("${spec}") missed the module table`)
|
|
224
|
+
return module
|
|
225
|
+
}
|
|
226
|
+
const exports_ = registration.factory(requireStub)
|
|
227
|
+
assert.deepEqual(required.sort(), ['react', 'react-dom/client', 'react/jsx-runtime'])
|
|
228
|
+
assert.deepEqual(exports_.inject, ['slots'])
|
|
229
|
+
assert.equal(typeof exports_.apply, 'function')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
/** 造一个最小 client ctx:slots 恒有,ttyConnbar 可选。 */
|
|
233
|
+
function makeClientCtx(options = {}) {
|
|
234
|
+
const state = {
|
|
235
|
+
cards: [],
|
|
236
|
+
injected: [],
|
|
237
|
+
connbarFactory: null,
|
|
238
|
+
renders: 0,
|
|
239
|
+
unmounted: 0,
|
|
240
|
+
execCalls: [],
|
|
241
|
+
mountCalls: [],
|
|
242
|
+
paneCalls: [],
|
|
243
|
+
openTabs: [],
|
|
244
|
+
// 会话桥的桩状态:默认按 DSH 0.1.6(快照无 current,靠 retainedBy.mainView 标记选中)
|
|
245
|
+
sessionCurrent: 'session-smoke',
|
|
246
|
+
sessionCwd: '/repo/current',
|
|
247
|
+
// 宿主形状:默认按 0.1.6(无 current,靠 mainView 标记);'0.1.5' 走老读法
|
|
248
|
+
sessionShape: options.sessionShape ?? '0.1.6',
|
|
249
|
+
sessionListeners: [],
|
|
250
|
+
sentPrompts: [],
|
|
251
|
+
drafts: [],
|
|
252
|
+
notices: [],
|
|
253
|
+
/** 宿主侧的标签关闭钩子(registerCloseHandler):用例用它模拟用户点标签栏的 ✕。 */
|
|
254
|
+
closeHandlers: new Map(),
|
|
255
|
+
}
|
|
256
|
+
const ctx = {
|
|
257
|
+
slots: {
|
|
258
|
+
inject: (slot, callback) => {
|
|
259
|
+
callback()
|
|
260
|
+
return () => {}
|
|
261
|
+
},
|
|
262
|
+
register: (options_, component) => {
|
|
263
|
+
state.cards.push({ options: options_, component })
|
|
264
|
+
return () => {}
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
inject: (names, callback) => {
|
|
268
|
+
state.injected.push(...names)
|
|
269
|
+
if (names.includes('ttyConnbar') && options.ttyConnbar === true) {
|
|
270
|
+
callback({
|
|
271
|
+
ttyConnbar: {
|
|
272
|
+
addAction(factory) {
|
|
273
|
+
state.connbarFactory = factory
|
|
274
|
+
return () => { state.connbarFactory = null }
|
|
275
|
+
},
|
|
276
|
+
requestRender() {
|
|
277
|
+
state.renders += 1
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
if (names.includes('ttyPanel') && options.ttyPanel === true) {
|
|
283
|
+
// tty ≥ 0.16 的右侧挂载位;传 ttyPanelOpen: false 可模拟「面板没开」
|
|
284
|
+
const service = {
|
|
285
|
+
version: 1,
|
|
286
|
+
isOpen: () => options.ttyPanelOpen !== false,
|
|
287
|
+
mountPane(call) {
|
|
288
|
+
state.paneCalls.push(call)
|
|
289
|
+
return { element: { tagName: 'DIV', appendChild() {}, remove() {} }, dispose() {} }
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
callback({ ttyPanel: service })
|
|
293
|
+
}
|
|
294
|
+
if (names.includes('ttyTerminal') && options.ttyTerminal === true) {
|
|
295
|
+
// 默认按最新契约(version 2 = 有 mount);传 ttyTerminalVersion: 1 可模拟老版本
|
|
296
|
+
const version = options.ttyTerminalVersion ?? 2
|
|
297
|
+
const service = {
|
|
298
|
+
version,
|
|
299
|
+
open(call) {
|
|
300
|
+
state.execCalls.push(call)
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
if (version >= 2) {
|
|
304
|
+
service.mount = (host, call) => {
|
|
305
|
+
state.mountCalls.push({ host, call })
|
|
306
|
+
return () => {}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
callback({ ttyTerminal: service })
|
|
310
|
+
}
|
|
311
|
+
if (names.includes('sessions') && options.sessions !== false) {
|
|
312
|
+
/*
|
|
313
|
+
* 会话桥:日志右键「问 Agent」经它拿**会话作用域**里的 conversation。
|
|
314
|
+
* 传 sessions: false 可模拟宿主未提供该服务(apply 不能崩,菜单运行期置灰)。
|
|
315
|
+
*
|
|
316
|
+
* 快照形状必须跟真实宿主对齐,否则桩会把 bug 遮住:DSH 0.1.6 起会话列表快照
|
|
317
|
+
* **没有 current 字段**(视图选中项搬到了 workspace 域),客户端要用
|
|
318
|
+
* `retainedBy.mainView > 0` 判「主视图正在展示哪个会话」。默认就按 0.1.6 建模,
|
|
319
|
+
* `sessionShape: '0.1.5'` 才带 current —— 老宿主的兼容路径也要能过。
|
|
320
|
+
*/
|
|
321
|
+
const sessionSnapshot = () => {
|
|
322
|
+
const current = typeof state.sessionCurrent === 'string' && state.sessionCurrent !== '' ? state.sessionCurrent : null
|
|
323
|
+
const ids = current === null ? [] : [current]
|
|
324
|
+
const byId = {}
|
|
325
|
+
if (current !== null) {
|
|
326
|
+
byId[current] = {
|
|
327
|
+
id: current,
|
|
328
|
+
cwd: state.sessionCwd,
|
|
329
|
+
running: false,
|
|
330
|
+
// ≤0.1.5 的宿主没有 retainedBy 这套引用计数
|
|
331
|
+
retainedBy: state.sessionShape === '0.1.5' ? {} : { mainView: 1 },
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (state.sessionShape === '0.1.5') {
|
|
335
|
+
return { ids, byId, phase: 'ready', current: current ?? undefined }
|
|
336
|
+
}
|
|
337
|
+
return { ids, byId, phase: 'ready', subagentsByParent: {}, jobsBySession: {} }
|
|
338
|
+
}
|
|
339
|
+
const conversation = {
|
|
340
|
+
send: (text) => { state.sentPrompts.push(text); return Promise.resolve() },
|
|
341
|
+
input: { for: () => ({ setDraft: (text) => { state.drafts.push(text) }, notify: (kind, text) => { state.notices.push({ kind, text }) } }) },
|
|
342
|
+
}
|
|
343
|
+
callback({
|
|
344
|
+
sessions: {
|
|
345
|
+
// 选中哪个会话走 state 便于用例模拟「切会话」;subscribe 收集订阅者,由用例手动通报
|
|
346
|
+
list: {
|
|
347
|
+
getSnapshot: () => sessionSnapshot(),
|
|
348
|
+
subscribe: (fn) => { state.sessionListeners.push(fn); return () => {} },
|
|
349
|
+
},
|
|
350
|
+
scope: () => ({ get: (name) => (name === 'conversation' ? conversation : undefined) }),
|
|
351
|
+
},
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
// 默认**不提供**右侧栏服务,于是缺省路径仍是模态——这与「老版本 DSH」同形,
|
|
355
|
+
// 既有用例因此不必改。传 sidebarRightTabs: true 才模拟「有标签宿主」的新宿主。
|
|
356
|
+
if (names.includes('sidebarRightTabs') && options.sidebarRightTabs === true) {
|
|
357
|
+
callback({
|
|
358
|
+
slots: ctx.slots,
|
|
359
|
+
sidebarRightTabs: { register: () => () => {} },
|
|
360
|
+
sidebarRight: {
|
|
361
|
+
openTab: (kind, openOptions) => { state.openTabs.push({ kind, options: openOptions ?? {} }) },
|
|
362
|
+
/*
|
|
363
|
+
* 宿主关闭标签时先回收插件资源(sidebarRight 的 closeIn → removeAfterCleanup)。
|
|
364
|
+
* 用例用它模拟「用户点了标签栏上的 ✕」——真实宿主里这是**唯一**能把关闭
|
|
365
|
+
* 通知到插件的地方:面板自己的 onClose 收不到这个动作。
|
|
366
|
+
* 传 sidebarRightCloseHook: false 可模拟没有该 API 的老宿主(≤0.1.5)。
|
|
367
|
+
*/
|
|
368
|
+
...(options.sidebarRightCloseHook === false
|
|
369
|
+
? {}
|
|
370
|
+
: { registerCloseHandler: (kind, handler) => { state.closeHandlers.set(kind, handler); return () => { state.closeHandlers.delete(kind) } } }),
|
|
371
|
+
},
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
return () => {}
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
return { ctx, state }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
await test('apply 注册设置卡片与配置入口(settings.plugin.item / settings.kit.item / plugins.row.config)并挂载侧边栏入口', () => {
|
|
381
|
+
const requireStub = (spec) => SEED[spec]
|
|
382
|
+
const exports_ = registration.factory(requireStub)
|
|
383
|
+
const { ctx, state } = makeClientCtx()
|
|
384
|
+
const dispose = exports_.apply(ctx)
|
|
385
|
+
/*
|
|
386
|
+
* 卡片注册面:三个入口是**并存**的,不是其一替代其一——
|
|
387
|
+
* - settings.plugin.item:DSH ≤0.1.5 的设置页插件卡片;
|
|
388
|
+
* - settings.kit.item:0.1.6 起设置里的「插件配置」行(kit-settings 提供的子槽);
|
|
389
|
+
* - plugins.row.config:0.1.6 侧边栏插件页的行详情(两个 key:独立包与聚合包)。
|
|
390
|
+
* 数量写死过 1,于是加了后两条注册之后就一直是红的——这里按名字/键列清单,
|
|
391
|
+
* 既锁全又不必每加一个入口改一次数字。
|
|
392
|
+
*/
|
|
393
|
+
const names = state.cards.map((card) => card.options.name).sort()
|
|
394
|
+
assert.deepEqual(names, ['plugins.row.config', 'plugins.row.config', 'settings.kit.item', 'settings.plugin.item'])
|
|
395
|
+
const settingsCard = state.cards.find((card) => card.options.name === 'settings.plugin.item')
|
|
396
|
+
assert.equal(settingsCard.options.key, 'docker', 'settings 卡片 key 必须等于命名空间')
|
|
397
|
+
assert.equal(state.cards.find((card) => card.options.name === 'settings.kit.item').options.id, 'docker', '插件配置行 id')
|
|
398
|
+
assert.deepEqual(
|
|
399
|
+
state.cards.filter((card) => card.options.name === 'plugins.row.config').map((card) => card.options.key).sort(),
|
|
400
|
+
['@hyzyn/dsh-all#docker', '@hyzyn/dsh-docker#docker'],
|
|
401
|
+
'插件页行配置要同时挂独立包与聚合包两个 key',
|
|
402
|
+
)
|
|
403
|
+
for (const card of state.cards) assert.equal(typeof card.component, 'function', card.options.name + ' 未暴露组件')
|
|
404
|
+
// 宿主侧边栏找不到时安静降级(不抛异常),卸载可重复调用
|
|
405
|
+
assert.equal(typeof dispose, 'function')
|
|
406
|
+
dispose()
|
|
407
|
+
dispose()
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
await test('tty 未安装时:可选注入不触发、apply 仍成功', () => {
|
|
411
|
+
const requireStub = (spec) => SEED[spec]
|
|
412
|
+
const exports_ = registration.factory(requireStub)
|
|
413
|
+
const { ctx, state } = makeClientCtx({ ttyConnbar: false, ttyTerminal: false })
|
|
414
|
+
const dispose = exports_.apply(ctx)
|
|
415
|
+
// 这里只断言「那几个可选注入被尝试过」;*全集*清单在下面单独一条用例里锁,
|
|
416
|
+
// 于是以后新增一个可选注入只需改那一处,不再连带打断这条例外路径用例。
|
|
417
|
+
for (const name of ['ttyConnbar', 'ttyPanel', 'ttyTerminal']) {
|
|
418
|
+
assert.ok(state.injected.includes(name), '缺少可选注入 ' + name)
|
|
419
|
+
}
|
|
420
|
+
assert.equal(state.connbarFactory, null)
|
|
421
|
+
assert.equal(state.execCalls.length, 0)
|
|
422
|
+
dispose()
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
await test('日志 → 会话桥:宿主未提供 sessions 时 apply 仍成功(菜单运行期置灰)', () => {
|
|
426
|
+
const requireStub = (spec) => SEED[spec]
|
|
427
|
+
const exports_ = registration.factory(requireStub)
|
|
428
|
+
const { ctx, state } = makeClientCtx({ sessions: false })
|
|
429
|
+
const dispose = exports_.apply(ctx)
|
|
430
|
+
// 可选注入:服务缺失不能让 apply 抛错,面板其余功能(设置卡片)照常注册
|
|
431
|
+
assert.ok(state.cards.length > 0, 'sessions 缺失时设置卡片仍应注册')
|
|
432
|
+
assert.equal(state.connbarFactory, null)
|
|
433
|
+
dispose()
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
await test('ttyConnbar:SSH 标签一律显示「容器」,目标在点击时解析', async () => {
|
|
437
|
+
const requireStub = (spec) => SEED[spec]
|
|
438
|
+
const exports_ = registration.factory(requireStub)
|
|
439
|
+
const { ctx, state } = makeClientCtx({ ttyConnbar: true })
|
|
440
|
+
const dispose = exports_.apply(ctx)
|
|
441
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
442
|
+
assert.equal(typeof state.connbarFactory, 'function', 'apply 未注册连接栏工厂')
|
|
443
|
+
|
|
444
|
+
const buttons = []
|
|
445
|
+
const addAction = (icon, label, title, onClick) => buttons.push({ icon, label, title, onClick })
|
|
446
|
+
// 1) 连接簿条目名命中 → 按钮标题带目标名
|
|
447
|
+
state.connbarFactory({ spec: { t: 'ssh', name: 'prod-a', host: '10.0.0.5', port: 2222 }, bookName: 'prod-a', addAction })
|
|
448
|
+
assert.equal(buttons.length, 1)
|
|
449
|
+
assert.equal(buttons[0].label, '容器')
|
|
450
|
+
assert.match(buttons[0].title, /目标:prod/)
|
|
451
|
+
|
|
452
|
+
// 2) 内联会话按 host:port 命中(label 解析路径)
|
|
453
|
+
state.connbarFactory({ spec: { t: 'ssh', host: '10.0.0.9', port: 2200 }, bookName: '', addAction })
|
|
454
|
+
assert.equal(buttons.length, 2)
|
|
455
|
+
assert.match(buttons[1].title, /目标:inline/)
|
|
456
|
+
|
|
457
|
+
// 3) 未配置的主机 → 仍然显示按钮(注册即显示),标题提示去配置
|
|
458
|
+
state.connbarFactory({ spec: { t: 'ssh', host: '192.168.1.2', port: 22 }, bookName: '', addAction })
|
|
459
|
+
assert.equal(buttons.length, 3)
|
|
460
|
+
assert.match(buttons[2].title, /尚未配置/)
|
|
461
|
+
|
|
462
|
+
// 4) 本地标签(非 SSH)→ 不插入按钮
|
|
463
|
+
state.connbarFactory({ spec: { t: 'local' }, bookName: '', addAction })
|
|
464
|
+
assert.equal(buttons.length, 3)
|
|
465
|
+
|
|
466
|
+
// 5) 点击未命中目标的按钮:异步解析后打开面板(body 上出现挂载容器)
|
|
467
|
+
const before = documentStub.body.children.length
|
|
468
|
+
buttons[2].onClick()
|
|
469
|
+
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
470
|
+
assert.ok(documentStub.body.children.length > before, '点击后未挂载面板容器')
|
|
471
|
+
const hinted = renders[renders.length - 1]
|
|
472
|
+
assert.equal(hinted.props.initialTarget, '', '未命中时不应默认选中任何目标')
|
|
473
|
+
assert.equal(typeof hinted.props.sessionHint, 'object', '未命中时应带会话提示')
|
|
474
|
+
|
|
475
|
+
dispose()
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
await test('插件禁用(config.enabled=false):连接栏不再提供「容器」按钮', async () => {
|
|
479
|
+
// 重新执行 bundle:模块级缓存 / 显隐状态归零;fetch 返回禁用态 config,
|
|
480
|
+
// /targets 按宿主禁用行为返回 403 形状的失败载荷
|
|
481
|
+
let reg = null
|
|
482
|
+
const disabledFetch = (url) => {
|
|
483
|
+
const payload = String(url).endsWith('/config')
|
|
484
|
+
? { ok: true, config: { ...FAKE_CONFIG, enabled: false } }
|
|
485
|
+
: { ok: false, error: 'forbidden: disabled' }
|
|
486
|
+
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(payload) })
|
|
487
|
+
}
|
|
488
|
+
const run = new Function('window', 'document', 'MutationObserver', 'fetch', code)
|
|
489
|
+
run({ __ModuleLoader__: { load: (entry) => { reg = entry } } }, documentStub, class { observe() {} disconnect() {} }, disabledFetch)
|
|
490
|
+
const exports_ = reg.factory((spec) => SEED[spec])
|
|
491
|
+
const { ctx, state } = makeClientCtx({ ttyConnbar: true })
|
|
492
|
+
const dispose = exports_.apply(ctx)
|
|
493
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
494
|
+
const buttons = []
|
|
495
|
+
state.connbarFactory({ spec: { t: 'ssh', name: 'prod-a', host: '10.0.0.5', port: 2222 }, bookName: 'prod-a', addAction: (icon, label, title, onClick) => buttons.push({ label, onClick }) })
|
|
496
|
+
assert.equal(buttons.length, 0, '禁用后连接栏不应插入「容器」按钮')
|
|
497
|
+
// 卸载后再触发迟到的一次缓存刷新:不允许把入口状态挂回已卸载的实例
|
|
498
|
+
dispose()
|
|
499
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
await test('缓存尚未就绪时点击:现场重拉并锁定当前会话对应的目标', async () => {
|
|
503
|
+
// 重新执行 bundle,得到干净的模块级缓存(不 await → 挂载时的刷新还没落地)
|
|
504
|
+
let reg = null
|
|
505
|
+
const run = new Function('window', 'document', 'MutationObserver', 'fetch', code)
|
|
506
|
+
run({ __ModuleLoader__: { load: (entry) => { reg = entry } } }, documentStub, class { observe() {} disconnect() {} }, fetchStub)
|
|
507
|
+
const exports_ = reg.factory((spec) => SEED[spec])
|
|
508
|
+
const { ctx, state } = makeClientCtx({ ttyConnbar: true })
|
|
509
|
+
exports_.apply(ctx)
|
|
510
|
+
|
|
511
|
+
const buttons = []
|
|
512
|
+
state.connbarFactory({ spec: { t: 'ssh', name: 'prod-a', host: '10.0.0.5', port: 2222 }, bookName: 'prod-a', addAction: (icon, label, title, onClick) => buttons.push({ title, onClick }) })
|
|
513
|
+
// 此刻缓存为空 → 标题是中性的,不应断言「未配置」
|
|
514
|
+
assert.match(buttons[0].title, /打开当前会话主机/)
|
|
515
|
+
|
|
516
|
+
const before = renders.length
|
|
517
|
+
buttons[0].onClick()
|
|
518
|
+
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
519
|
+
assert.ok(renders.length > before, '点击后未打开面板')
|
|
520
|
+
const panel = renders[renders.length - 1]
|
|
521
|
+
assert.equal(panel.props.initialTarget, 'prod', '应锁定当前会话对应的目标')
|
|
522
|
+
assert.equal(panel.props.sessionHint, undefined, '命中目标时不应再带未配置提示')
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
await test('连接失败(没有 tab.target):用连接簿自带的 host 兜底命中目标', async () => {
|
|
526
|
+
// 干净的模块级缓存:重新执行 bundle,再把挂载时那次 /config + /targets 放落地
|
|
527
|
+
let reg = null
|
|
528
|
+
const run = new Function('window', 'document', 'MutationObserver', 'fetch', code)
|
|
529
|
+
run({ __ModuleLoader__: { load: (entry) => { reg = entry } } }, documentStub, class { observe() {} disconnect() {} }, fetchStub)
|
|
530
|
+
const exports_ = reg.factory((spec) => SEED[spec])
|
|
531
|
+
const { ctx, state } = makeClientCtx({ ttyConnbar: true })
|
|
532
|
+
exports_.apply(ctx)
|
|
533
|
+
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
534
|
+
|
|
535
|
+
const buttons = []
|
|
536
|
+
/*
|
|
537
|
+
* 用户实际场景:会话来自连接簿「prod-legacy」(目标 prod 引用的是另一条 prod-a),
|
|
538
|
+
* 而这台机器的 SSH **握手超时**——宿主只在连接成功后才回显 tab.target,所以这里
|
|
539
|
+
* 是空的。旧实现没有第二层兜底,会判成「该主机没配目标」,面板随后沿用上次记住的
|
|
540
|
+
* 目标,把另一台主机的容器显示出来。
|
|
541
|
+
*/
|
|
542
|
+
state.connbarFactory({
|
|
543
|
+
spec: { t: 'ssh', name: 'prod-legacy', command: '' },
|
|
544
|
+
bookName: 'prod-legacy',
|
|
545
|
+
tab: { target: '' },
|
|
546
|
+
addAction: (icon, label, title, onClick) => buttons.push({ title, onClick }),
|
|
547
|
+
})
|
|
548
|
+
assert.match(buttons[0].title, /目标:prod/, '连接簿 host 应能把会话对到目标')
|
|
549
|
+
|
|
550
|
+
buttons[0].onClick()
|
|
551
|
+
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
552
|
+
const panel = renders[renders.length - 1]
|
|
553
|
+
assert.equal(panel.props.initialTarget, 'prod', '应直接锁定按 host 命中的目标')
|
|
554
|
+
assert.equal(panel.props.sessionHint, undefined, '命中目标时不应再带「未配置」提示')
|
|
555
|
+
|
|
556
|
+
// 反例:连接簿条目和任何目标都不相干时,仍然判「未配置」,并带上解析出的主机地址
|
|
557
|
+
buttons.length = 0
|
|
558
|
+
state.connbarFactory({
|
|
559
|
+
spec: { t: 'ssh', name: '未知条目', command: '' },
|
|
560
|
+
bookName: '未知条目',
|
|
561
|
+
tab: { target: '' },
|
|
562
|
+
addAction: (icon, label, title, onClick) => buttons.push({ title, onClick }),
|
|
563
|
+
})
|
|
564
|
+
assert.match(buttons[0].title, /尚未配置为 Docker 目标/)
|
|
565
|
+
buttons[0].onClick()
|
|
566
|
+
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
567
|
+
const unmatched = renders[renders.length - 1]
|
|
568
|
+
assert.equal(unmatched.props.initialTarget, '')
|
|
569
|
+
assert.equal(unmatched.props.sessionHint.book, '未知条目')
|
|
570
|
+
assert.equal(unmatched.props.sessionHint.host, '')
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
await test('承载分发(S2):框架侧入口开右侧栏标签(带目标),不再弹模态', () => {
|
|
574
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
575
|
+
const { ctx, state } = makeClientCtx({ sidebarRightTabs: true })
|
|
576
|
+
exports_.apply(ctx)
|
|
577
|
+
|
|
578
|
+
const before = renders.length
|
|
579
|
+
// 走测试缝直接驱动分发:DOM 桩的 querySelector 返回 null,点不到页边栏那个入口
|
|
580
|
+
exports_.__carrier.open({ target: 'prod' })
|
|
581
|
+
assert.equal(state.openTabs.length, 1, '有右侧栏服务时应开标签')
|
|
582
|
+
assert.equal(state.openTabs[0].kind, 'docker', 'kind 必须与注册的 kind 一致')
|
|
583
|
+
assert.equal(state.openTabs[0].options.params.target, 'prod', '目标应随 navigation params 带进标签')
|
|
584
|
+
assert.equal(renders.length, before, '开标签时不应再渲染模态')
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
await test('承载分发(S2):终端连接栏入口走 dock、不开标签(弹窗会挡住标签)', async () => {
|
|
588
|
+
let reg = null
|
|
589
|
+
const run = new Function('window', 'document', 'MutationObserver', 'fetch', code)
|
|
590
|
+
run({ __ModuleLoader__: { load: (entry) => { reg = entry } } }, documentStub, class { observe() {} disconnect() {} }, fetchStub)
|
|
591
|
+
const exports_ = reg.factory((spec) => SEED[spec])
|
|
592
|
+
// 连接栏按钮长在 tty 面板上 → 点击时弹窗一定开着,且它盖满视口
|
|
593
|
+
const { ctx, state } = makeClientCtx({ ttyConnbar: true, ttyPanel: true, sidebarRightTabs: true })
|
|
594
|
+
exports_.apply(ctx)
|
|
595
|
+
|
|
596
|
+
const buttons = []
|
|
597
|
+
state.connbarFactory({ spec: { t: 'ssh', name: 'prod-a', host: '10.0.0.5', port: 2222 }, bookName: 'prod-a', addAction: (icon, label, title, onClick) => buttons.push({ title, onClick }) })
|
|
598
|
+
buttons[0].onClick()
|
|
599
|
+
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
600
|
+
assert.equal(state.openTabs.length, 0, '连接栏入口不该开标签 —— 它会被 tty 弹窗整个挡住')
|
|
601
|
+
assert.ok(state.paneCalls.length > 0, '应停靠到 tty 面板右侧的 dock')
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
await test('连接栏:本插件自己开的 exec 标签不再提供「容器」入口', async () => {
|
|
605
|
+
// 纯函数部分:只认 spawnSpec.command(API 开的命令标签),用户手敲的不算
|
|
606
|
+
const carrier = carrierApi()
|
|
607
|
+
assert.equal(carrier.isOwnExec(carrier.buildExec('ems-service-test')), true, '自家生成的命令必须认得出来')
|
|
608
|
+
assert.equal(carrier.isOwnExec("docker exec -it 'x' sh"), true)
|
|
609
|
+
assert.equal(carrier.isOwnExec(undefined), false, '普通 SSH 标签没有 command → 照旧带按钮')
|
|
610
|
+
assert.equal(carrier.isOwnExec(''), false)
|
|
611
|
+
assert.equal(carrier.isOwnExec('docker ps'), false)
|
|
612
|
+
|
|
613
|
+
// 端到端:连接栏工厂拿到的 spec 带 command 时不再插按钮
|
|
614
|
+
let reg = null
|
|
615
|
+
const run = new Function('window', 'document', 'MutationObserver', 'fetch', code)
|
|
616
|
+
run({ __ModuleLoader__: { load: (entry) => { reg = entry } } }, documentStub, class { observe() {} disconnect() {} }, fetchStub)
|
|
617
|
+
const exports_ = reg.factory((spec) => SEED[spec])
|
|
618
|
+
const { ctx, state } = makeClientCtx({ ttyConnbar: true })
|
|
619
|
+
exports_.apply(ctx)
|
|
620
|
+
const buttons = []
|
|
621
|
+
const push = (icon, label, title, onClick) => buttons.push({ label, onClick })
|
|
622
|
+
const base = { t: 'ssh', name: 'prod-a', host: '10.0.0.5', port: 2222 }
|
|
623
|
+
state.connbarFactory({ spec: base, bookName: 'prod-a', addAction: push })
|
|
624
|
+
assert.equal(buttons.length, 1, '普通 SSH 标签应带「容器」按钮')
|
|
625
|
+
buttons.length = 0
|
|
626
|
+
state.connbarFactory({ spec: { ...base, command: "docker exec -it 'ems-service-test' sh" }, bookName: 'prod-a', addAction: push })
|
|
627
|
+
assert.equal(buttons.length, 0, 'exec 标签不该再给「容器」入口')
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
await test('承载分发(S2):localStorage 置 modal 时退回模态(灰度回滚开关)', () => {
|
|
631
|
+
let reg = null
|
|
632
|
+
const run = new Function('window', 'document', 'MutationObserver', 'fetch', code)
|
|
633
|
+
// 偏好读的是 window.localStorage;harness 的 window 桩默认没有它(越界读取会被 try/catch 吃掉)
|
|
634
|
+
run({
|
|
635
|
+
__ModuleLoader__: { load: (entry) => { reg = entry } },
|
|
636
|
+
localStorage: { getItem: () => 'modal' },
|
|
637
|
+
}, documentStub, class { observe() {} disconnect() {} }, fetchStub)
|
|
638
|
+
const exports_ = reg.factory((spec) => SEED[spec])
|
|
639
|
+
const { ctx, state } = makeClientCtx({ sidebarRightTabs: true })
|
|
640
|
+
exports_.apply(ctx)
|
|
641
|
+
|
|
642
|
+
const before = renders.length
|
|
643
|
+
exports_.__carrier.open({ target: 'prod' })
|
|
644
|
+
assert.equal(state.openTabs.length, 0, '置 modal 后不应开标签')
|
|
645
|
+
assert.ok(renders.length > before, '置 modal 后应走模态(渲染面板)')
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
await test('粘性:切会话时自动把容器标签带过去(判定抽成纯函数)', () => {
|
|
649
|
+
const carrier = carrierApi()
|
|
650
|
+
assert.equal(carrier.shouldReopen(true, 'a', 'b', true), true, '切会话 + 有意图 + 有服务 → 重开')
|
|
651
|
+
assert.equal(carrier.shouldReopen(true, 'a', 'a', true), false, '当前会话没换(只是列表变了)→ 不动作')
|
|
652
|
+
assert.equal(carrier.shouldReopen(false, 'a', 'b', true), false, '用户没要过 / 已点 ✕ 关掉 → 不重开')
|
|
653
|
+
assert.equal(carrier.shouldReopen(true, 'a', 'b', false), false, '没有右侧栏服务 → 不重开')
|
|
654
|
+
assert.equal(carrier.shouldReopen(true, 'a', null, true), false, '没有当前会话 → 不重开')
|
|
655
|
+
assert.equal(carrier.shouldReopen(true, null, 'b', true), true, '首个会话 id 到达也算一次切换')
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
await test('粘性:订阅接线(打开 → 切会话重开 → 同会话不重复开)', () => {
|
|
659
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
660
|
+
const { ctx, state } = makeClientCtx({ sidebarRightTabs: true })
|
|
661
|
+
exports_.apply(ctx)
|
|
662
|
+
assert.equal(state.sessionListeners.length, 1, '应订阅一次会话列表')
|
|
663
|
+
exports_.__carrier.open({})
|
|
664
|
+
assert.equal(state.openTabs.length, 1, '打开时应开标签')
|
|
665
|
+
// 模拟切会话:改 current 再通报订阅者
|
|
666
|
+
state.sessionCurrent = 'session-two'
|
|
667
|
+
for (const notify of state.sessionListeners) notify()
|
|
668
|
+
assert.equal(state.openTabs.length, 2, '切会话应在新的会话里重开标签')
|
|
669
|
+
for (const notify of state.sessionListeners) notify()
|
|
670
|
+
assert.equal(state.openTabs.length, 2, '同一会话重复通报不该再开')
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
await test('粘性:关掉标签后切会话不再把它带回来(标签 ✕ 由宿主关闭,插件收不到 onClose)', () => {
|
|
674
|
+
/*
|
|
675
|
+
* 实测的回归:用户点标签栏上的 ✕ 关掉 Docker,切会话它又冒出来;再关、切回原会话
|
|
676
|
+
* 又冒一次。原因是「已关闭」只写进了面板自己的 onClose,而标签栏的 ✕ 是宿主直接
|
|
677
|
+
* 摘标签的——插件必须登记 registerCloseHandler 才知道用户真的关掉了。
|
|
678
|
+
*/
|
|
679
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
680
|
+
const { ctx, state } = makeClientCtx({ sidebarRightTabs: true })
|
|
681
|
+
exports_.apply(ctx)
|
|
682
|
+
exports_.__carrier.open({})
|
|
683
|
+
assert.equal(state.openTabs.length, 1, '打开时应开标签')
|
|
684
|
+
|
|
685
|
+
const closeHook = state.closeHandlers.get('docker')
|
|
686
|
+
assert.equal(
|
|
687
|
+
typeof closeHook,
|
|
688
|
+
'function',
|
|
689
|
+
'没登记关闭钩子:关掉标签后粘性意图还在,切会话会把它带回来',
|
|
690
|
+
)
|
|
691
|
+
closeHook('session-smoke', { id: 'tab', kind: 'docker' })
|
|
692
|
+
|
|
693
|
+
state.sessionCurrent = 'session-two'
|
|
694
|
+
for (const notify of state.sessionListeners) notify()
|
|
695
|
+
assert.equal(state.openTabs.length, 1, '用户已经关掉了,切会话不该再开')
|
|
696
|
+
// 再切一次也不该复活
|
|
697
|
+
state.sessionCurrent = 'session-three'
|
|
698
|
+
for (const notify of state.sessionListeners) notify()
|
|
699
|
+
assert.equal(state.openTabs.length, 1, '切第三次也不该复活')
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
await test('粘性:老宿主(没有 registerCloseHandler)不因缺 API 报错,行为保持原样', () => {
|
|
703
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
704
|
+
const { ctx, state } = makeClientCtx({ sidebarRightTabs: true, sidebarRightCloseHook: false })
|
|
705
|
+
assert.doesNotThrow(() => exports_.apply(ctx))
|
|
706
|
+
exports_.__carrier.open({})
|
|
707
|
+
assert.equal(state.openTabs.length, 1, '打开照常')
|
|
708
|
+
state.sessionCurrent = 'session-two'
|
|
709
|
+
for (const notify of state.sessionListeners) notify()
|
|
710
|
+
assert.equal(state.openTabs.length, 2, '拿不到钩子时保持既有行为(切会话仍带过去)')
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
await test('日志过滤对齐:级别门槛的「续行继承」对行对象与纯文本行是同一套', () => {
|
|
714
|
+
const pick = aggLogsApi()
|
|
715
|
+
const rows = [
|
|
716
|
+
{ text: '16:11:34,150 |INFO boot' },
|
|
717
|
+
{ text: ' at foo(Bar.java:1)' },
|
|
718
|
+
{ text: '16:11:35,200 |ERROR boom' },
|
|
719
|
+
{ text: ' at baz(Qux.java:2)' },
|
|
720
|
+
{ text: 'plain continuation' },
|
|
721
|
+
]
|
|
722
|
+
const warnPlus = pick.filterByLevel(rows, 3)
|
|
723
|
+
assert.deepEqual(
|
|
724
|
+
warnPlus.map((row) => row.text),
|
|
725
|
+
['16:11:35,200 |ERROR boom', ' at baz(Qux.java:2)', 'plain continuation'],
|
|
726
|
+
'WARN+ 必须保留 ERROR 头行**及其续行**——否则堆栈被拦腰截断,筛选形同虚设',
|
|
727
|
+
)
|
|
728
|
+
const lines = rows.map((row) => row.text)
|
|
729
|
+
assert.deepEqual(pick.filterLinesByLevel(lines, 3), warnPlus.map((row) => row.text), '两种形状必须给出同一结果(共用内核)')
|
|
730
|
+
assert.equal(pick.filterByLevel(rows, 0), rows, '门槛 0 原样返回,不做无谓拷贝')
|
|
731
|
+
assert.equal(pick.filterLinesByLevel(lines, 0), lines)
|
|
732
|
+
assert.deepEqual(pick.filterLinesByLevel([' at orphan(X.java:9)'], 4), [' at orphan(X.java:9)'], '窗口开头就是续行 → 无从判断,保留')
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
await test('日志级别档位:共用一份定义,含 INFO+(真实日志里最常用的那一档)', () => {
|
|
736
|
+
const agg = aggLogsApi()
|
|
737
|
+
const options = agg.LEVEL_OPTIONS
|
|
738
|
+
assert.ok(Array.isArray(options), '缺少 LEVEL_OPTIONS 测试缝')
|
|
739
|
+
// 档位:全部 / INFO+ / WARN+ / ERROR+(rank 2 = INFO)。没有 DEBUG+/TRACE+ 的理由见源码注释:
|
|
740
|
+
// 噪音几乎都在 DEBUG 及以下,而 INFO 往往正是要看的那档。
|
|
741
|
+
assert.deepEqual(options.map((option) => option.value), [0, 2, 3, 4])
|
|
742
|
+
assert.deepEqual(options.map((option) => option.label), ['全部级别', 'INFO+', 'WARN+', 'ERROR+'])
|
|
743
|
+
|
|
744
|
+
// INFO+ 的**行为**:留下 INFO 及以上,丢掉 DEBUG —— 这正是它存在的意义
|
|
745
|
+
const lines = ['[DEBUG] noise', '[INFO] useful', '[ERROR] boom']
|
|
746
|
+
assert.deepEqual(agg.filterLinesByLevel(lines, 2), ['[INFO] useful', '[ERROR] boom'])
|
|
747
|
+
|
|
748
|
+
// 「共用一份定义」不是口号:每个标签在产物里只能出现一次——两个视图各写一套必然出现两次
|
|
749
|
+
const decoded = code.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
750
|
+
for (const option of options) {
|
|
751
|
+
assert.equal(decoded.split(option.label).length - 1, 1, option.label + ' 在产物里出现了多次(视图各写了一套选项?)')
|
|
752
|
+
}
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
await test('日志导出对齐:两种格式共用构建器,只有标题与作用域不同', () => {
|
|
756
|
+
const pick = aggLogsApi()
|
|
757
|
+
const rows = [{ service: 'web', ts: 1755000000000, text: 'ERROR boom' }]
|
|
758
|
+
const plain = pick.buildLogExport(rows, { format: 'log', target: '目标1' })
|
|
759
|
+
assert.ok(!plain.includes('# '), '.log 只是正文')
|
|
760
|
+
assert.match(plain, /\[web\] .*ERROR boom/)
|
|
761
|
+
const one = pick.buildLogExport(rows, { format: 'md', scope: '容器日志', target: '目标1', targetLabel: '目标1', items: [{ name: 'web' }] })
|
|
762
|
+
const many = pick.buildLogExport(rows, { format: 'md', target: '目标1', items: [{ name: 'web' }, { name: 'db' }] })
|
|
763
|
+
assert.match(one, /^# 容器日志/)
|
|
764
|
+
assert.match(many, /^# 聚合日志/)
|
|
765
|
+
for (const md of [one, many]) {
|
|
766
|
+
assert.match(md, /- 行数:1/)
|
|
767
|
+
assert.match(md, /```text/)
|
|
768
|
+
}
|
|
769
|
+
assert.match(many, /容器(2):web、db/)
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
await test('渲染期守卫:面板组件体直接跑一遍不能抛(TDZ 那类错误曾让面板整个空白)', () => {
|
|
773
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
774
|
+
assert.ok(exports_.__render !== undefined, '缺少 __render 测试缝')
|
|
775
|
+
const cases = [
|
|
776
|
+
['DockerTabBody', {}],
|
|
777
|
+
['ContainerPanel', { carrier: 'tab', onClose: () => {}, initialTarget: '' }],
|
|
778
|
+
['ContainerPanel', { onClose: () => {}, initialTarget: '' }], // 模态 / docked 形态(carrier 缺省)
|
|
779
|
+
]
|
|
780
|
+
for (const [name, props] of cases) {
|
|
781
|
+
const component = exports_.__render[name]
|
|
782
|
+
assert.equal(typeof component, 'function', name + ' 未暴露')
|
|
783
|
+
let element = null
|
|
784
|
+
assert.doesNotThrow(() => { element = component(props) }, name + ' 渲染期抛错')
|
|
785
|
+
assert.ok(element !== null && element !== undefined, name + ' 返回了空')
|
|
786
|
+
}
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
await test('会话桥(DSH 0.1.6 形状):右键「问 Agent」能解出当前会话,不再恒判「没有打开的会话」', async () => {
|
|
790
|
+
/*
|
|
791
|
+
* 实测的回归:0.1.6 起 `sessions.list` 快照不再带 `current`(视图选中项搬到了
|
|
792
|
+
* workspace 域),只读老字段的实现把「有会话」判成「当前没有打开的会话」——
|
|
793
|
+
* 日志右键的两个菜单项全灰,功能整体失效且不报错。
|
|
794
|
+
* 这里两种宿主形状都要解得出,且投递真的落到作用域里的 conversation 上。
|
|
795
|
+
*/
|
|
796
|
+
for (const shape of ['0.1.6', '0.1.5']) {
|
|
797
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
798
|
+
const { ctx, state } = makeClientCtx({ sessionShape: shape })
|
|
799
|
+
assert.equal(state.sessionShape, shape)
|
|
800
|
+
exports_.apply(ctx)
|
|
801
|
+
|
|
802
|
+
const sent = await exports_.__carrier.deliver('诊断包', 'send')
|
|
803
|
+
assert.equal(sent.ok, true, shape + ':当前会话在场时不该判成「没有打开的会话」')
|
|
804
|
+
assert.deepEqual(state.sentPrompts, ['诊断包'], shape + ':应经会话作用域的 conversation 发出')
|
|
805
|
+
|
|
806
|
+
const drafted = await exports_.__carrier.deliver('诊断包', 'draft')
|
|
807
|
+
assert.equal(drafted.ok, true, shape + ':草稿档也应可用')
|
|
808
|
+
assert.deepEqual(state.drafts, ['诊断包'], shape + ':应落到会话输入门面的 setDraft')
|
|
809
|
+
}
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
await test('会话桥:没有打开的会话时给出明确原因(菜单据此置灰,而不是崩)', async () => {
|
|
813
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
814
|
+
const { ctx, state } = makeClientCtx({})
|
|
815
|
+
exports_.apply(ctx)
|
|
816
|
+
state.sessionCurrent = null // 列表空:0.1.6 形状下 byId 里没有任何一行带 mainView
|
|
817
|
+
const failed = await exports_.__carrier.deliver('诊断包', 'send')
|
|
818
|
+
assert.equal(failed.ok, false)
|
|
819
|
+
assert.match(failed.message, /当前没有打开的会话/)
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
await test('投递回执:成功也在视口级发声(以前只有失败才提示)', async () => {
|
|
823
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
824
|
+
const { ctx } = makeClientCtx({ sidebarRightTabs: true })
|
|
825
|
+
exports_.apply(ctx)
|
|
826
|
+
// 成功:应追加一枚绿色 toast。这条锁的是「模态 / docked 下面板盖着会话时,
|
|
827
|
+
// 用户至少能在视口右下角看到『已发送』」——以前成功是静默的,体感是「点了没反应」。
|
|
828
|
+
const before = documentStub.body.children.length
|
|
829
|
+
const ok = await exports_.__carrier.deliver('诊断包', 'send')
|
|
830
|
+
assert.equal(ok.ok, true, '会话桩应接受投递')
|
|
831
|
+
assert.equal(documentStub.body.children.length, before + 1, '成功要有视口级回执')
|
|
832
|
+
const toast = documentStub.body.children[documentStub.body.children.length - 1]
|
|
833
|
+
assert.equal(toast.dataset.kind, 'ok', '成功用绿色那套')
|
|
834
|
+
assert.match(toast.textContent, /已发送/)
|
|
835
|
+
// 「会话在面板后面」那半句由面板按承载设置(tab 下不该出现),不是写死在文案里;
|
|
836
|
+
// 这里面板没挂载,所以后缀应为空。
|
|
837
|
+
assert.ok(!toast.textContent.includes('面板后面'), '承载相关的后缀不该硬编码在投递层')
|
|
838
|
+
|
|
839
|
+
// 失败:deliver 只回结果,提示由调用方(右键菜单)负责,不该自己乱发 toast
|
|
840
|
+
const fresh = registration.factory((spec) => SEED[spec])
|
|
841
|
+
const { ctx: bare } = makeClientCtx({ sessions: false })
|
|
842
|
+
fresh.apply(bare)
|
|
843
|
+
const beforeFail = documentStub.body.children.length
|
|
844
|
+
const failed = await fresh.__carrier.deliver('诊断包', 'send')
|
|
845
|
+
assert.equal(failed.ok, false, '没有 sessions 服务应失败')
|
|
846
|
+
assert.match(failed.message, /sessions/)
|
|
847
|
+
assert.equal(documentStub.body.children.length, beforeFail, '失败不该由投递层自己发 toast')
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
await test('注入清单快照:本插件注入过的服务全集(加可选注入时只改这一处)', () => {
|
|
851
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
852
|
+
const { ctx, state } = makeClientCtx({ ttyConnbar: false, ttyTerminal: false })
|
|
853
|
+
const dispose = exports_.apply(ctx)
|
|
854
|
+
assert.deepEqual(state.injected.sort(), ['sessions', 'sidebarRight', 'sidebarRightTabs', 'ttyConnbar', 'ttyPanel', 'ttyTerminal'])
|
|
855
|
+
dispose()
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
await test('右侧栏标签承载(S1):类型 / body / 外壳样式装配进 bundle', () => {
|
|
859
|
+
assert.ok(code.includes('sidebar.right.pane.tab'), '缺少右侧栏标签 body 槽名')
|
|
860
|
+
assert.ok(code.includes('sidebarRightTabs'), '缺少右侧栏类型注册')
|
|
861
|
+
assert.ok(code.includes('dk_panelTab'), '缺少标签承载的面板外壳样式')
|
|
862
|
+
// 契约要求 body 注册在**实现 id** 下(不是 kind):写错的表现是「标签能开、body 空白」
|
|
863
|
+
assert.ok(code.includes('"@hyzyn/dsh-docker"') || code.includes("'@hyzyn/dsh-docker'"), '缺少标签实现 id')
|
|
864
|
+
// 卡片「终端」按钮要按右侧栏是否铺满分流,fullscreen 得透传进面板(属性名不会被 minify)
|
|
865
|
+
assert.ok(code.includes('tabFullscreen'), '缺少 sidebar.fullscreen 的透传')
|
|
866
|
+
// esbuild 默认 charset=ascii:中文在 bundle 里是 \uXXXX,先解码再断言
|
|
867
|
+
const decoded = code.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
868
|
+
assert.ok(decoded.includes('本机与 SSH 主机的容器、镜像、Compose、网络与卷'), '缺少指南页描述文案')
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
await test('ttyTerminal:可选注入成功,且 bundle 内含 exec 命令与复制兜底', () => {
|
|
872
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
873
|
+
const { ctx, state } = makeClientCtx({ ttyConnbar: false, ttyTerminal: true })
|
|
874
|
+
const dispose = exports_.apply(ctx)
|
|
875
|
+
assert.ok(state.injected.includes('ttyTerminal'), '缺少 ttyTerminal 可选注入')
|
|
876
|
+
// 命令构造与兜底路径:静态断言(点击路径在真实应用里由端到端脚本覆盖)
|
|
877
|
+
// esbuild 默认 charset=ascii:中文在 bundle 里是 \uXXXX,先解码再断言
|
|
878
|
+
const decoded = code.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
879
|
+
assert.ok(decoded.includes('docker exec -it'), '缺少 exec 命令构造')
|
|
880
|
+
assert.ok(decoded.includes('未安装 dsh-tty 终端面板'), '缺少 tty 未安装时的复制兜底提示')
|
|
881
|
+
assert.ok(decoded.includes('终端面板版本过旧'), '缺少 tty 版本过旧时的提示')
|
|
882
|
+
dispose()
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
await test('ttyTerminal:按契约版本选择 mount(就地嵌入)/ open(开标签),旧版本不报错', () => {
|
|
886
|
+
const decoded = code.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
887
|
+
// 静态断言锁住契约:能力判断走 version,且两条路径与抽屉 DOM 都在 bundle 里。
|
|
888
|
+
// 点击路径(点卡片 → 抽屉里出现真终端)由 tty 的预览夹具端到端覆盖:
|
|
889
|
+
// packages/tty/scripts/preview.mjs 的 docker-exec / embed / embed-panel 场景
|
|
890
|
+
assert.ok(code.includes('version'), '缺少 ttyTerminal 契约版本判断')
|
|
891
|
+
assert.ok(code.includes('.mount(') || code.includes('mount('), '缺少 mount 调用路径')
|
|
892
|
+
assert.ok(code.includes('dk_drawerBody'), '缺少终端抽屉挂载点')
|
|
893
|
+
assert.ok(decoded.includes('由终端面板承载'), '缺少抽屉说明文案')
|
|
894
|
+
// 老版本(只有 open)注入不应抛异常:apply 全程不碰 mount
|
|
895
|
+
const old = makeClientCtx({ ttyTerminal: true, ttyTerminalVersion: 1 })
|
|
896
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
897
|
+
const dispose = exports_.apply(old.ctx)
|
|
898
|
+
assert.equal(typeof dispose, 'function')
|
|
899
|
+
dispose()
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
await test('ttyPanel:面板开着时挂进右侧侧栏(docked),没开则回退弹窗', async () => {
|
|
903
|
+
const requireStub = (spec) => SEED[spec]
|
|
904
|
+
// 1) 面板开着(isOpen → true):走 ttyPanel.mountPane,ContainerPanel 拿到 docked
|
|
905
|
+
const exports_ = registration.factory(requireStub)
|
|
906
|
+
const { ctx, state } = makeClientCtx({ ttyConnbar: true, ttyPanel: true })
|
|
907
|
+
const dispose = exports_.apply(ctx)
|
|
908
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
909
|
+
const buttons = []
|
|
910
|
+
state.connbarFactory({ spec: { t: 'ssh', name: 'prod-a', host: '10.0.0.5', port: 2222 }, bookName: 'prod-a', addAction: (icon, label, title, onClick) => buttons.push({ onClick }) })
|
|
911
|
+
buttons[0].onClick()
|
|
912
|
+
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
913
|
+
assert.equal(state.paneCalls.length, 1, '面板开着时应挂进 ttyPanel')
|
|
914
|
+
assert.equal(state.paneCalls[0].title, 'Docker 容器')
|
|
915
|
+
const docked = renders[renders.length - 1]
|
|
916
|
+
assert.equal(docked.props.docked, true, 'dock 模式必须显式传给 ContainerPanel')
|
|
917
|
+
// 2) 面板没开(isOpen → false):仍然挂 body 级模态
|
|
918
|
+
const modal = makeClientCtx({ ttyConnbar: true, ttyPanel: true, ttyPanelOpen: false })
|
|
919
|
+
const exports2 = registration.factory(requireStub)
|
|
920
|
+
const dispose2 = exports2.apply(modal.ctx)
|
|
921
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
922
|
+
const buttons2 = []
|
|
923
|
+
modal.state.connbarFactory({ spec: { t: 'ssh', name: 'prod-a', host: '10.0.0.5', port: 2222 }, bookName: 'prod-a', addAction: (icon, label, title, onClick) => buttons2.push({ onClick }) })
|
|
924
|
+
const before = documentStub.body.children.length
|
|
925
|
+
buttons2[0].onClick()
|
|
926
|
+
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
927
|
+
assert.equal(modal.state.paneCalls.length, 0, '面板没开时不该挂 pane')
|
|
928
|
+
assert.ok(documentStub.body.children.length > before, '没开面板时应回退到 body 级模态')
|
|
929
|
+
const modalPanel = renders[renders.length - 1]
|
|
930
|
+
assert.notEqual(modalPanel.props.docked, true)
|
|
931
|
+
dispose()
|
|
932
|
+
dispose2()
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
await test('按条件一键选择:preset 判定 / 计数截断 / 上限语义', () => {
|
|
936
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
937
|
+
const pick = exports_.__pick
|
|
938
|
+
assert.ok(typeof pick.apply === 'function' && typeof pick.presetCounts === 'function', '缺少 __pick 条件选择测试缝')
|
|
939
|
+
const visible = [
|
|
940
|
+
{ id: 'a', name: 'web', image: 'nginx:1.27', state: 'running', health: 'unhealthy', composeProject: 'shop', exitCode: null },
|
|
941
|
+
{ id: 'b', name: 'api', image: 'app:1', state: 'running', health: null, composeProject: 'shop', exitCode: null },
|
|
942
|
+
{ id: 'c', name: 'cron', image: 'app:1', state: 'exited', health: null, composeProject: 'ops', exitCode: 137 },
|
|
943
|
+
{ id: 'd', name: 'clean', image: 'app:2', state: 'exited', health: null, composeProject: 'ops', exitCode: 0 },
|
|
944
|
+
]
|
|
945
|
+
|
|
946
|
+
// 不健康 → 只挑 a
|
|
947
|
+
assert.deepEqual(pick.apply(visible, [], 'unhealthy', null, 8).ids, ['a'])
|
|
948
|
+
// 需关注(摘要口径:unhealthy + 非零退出)→ a、c
|
|
949
|
+
assert.deepEqual(pick.apply(visible, [], 'abnormal', null, 8).ids, ['a', 'c'])
|
|
950
|
+
// 已停止 → c、d
|
|
951
|
+
assert.deepEqual(pick.apply(visible, [], 'stopped', null, 8).ids, ['c', 'd'])
|
|
952
|
+
// 已勾选的不重复选
|
|
953
|
+
assert.deepEqual(pick.apply(visible, ['a'], 'unhealthy', null, 8).ids, ['a'])
|
|
954
|
+
// 上限截断:全部可见但只剩 2 个名额 → 加 2 个、略过 2 个
|
|
955
|
+
const capped = pick.apply(visible, ['a', 'b'], 'all', null, 4)
|
|
956
|
+
assert.equal(capped.added, 2)
|
|
957
|
+
assert.equal(capped.skipped, 0)
|
|
958
|
+
const tight = pick.apply(visible, ['a'], 'all', null, 3)
|
|
959
|
+
assert.equal(tight.added, 2)
|
|
960
|
+
assert.equal(tight.skipped, 1)
|
|
961
|
+
assert.equal(tight.ids.length, 3)
|
|
962
|
+
|
|
963
|
+
// 同镜像 / 同项目:以第一个勾选的容器为基准
|
|
964
|
+
assert.deepEqual(pick.apply(visible, [], 'sameImage', visible[1], 8).ids, ['b', 'c'])
|
|
965
|
+
assert.deepEqual(pick.apply(visible, [], 'sameProject', visible[0], 8).ids, ['a', 'b'])
|
|
966
|
+
// 基准为非 compose 容器 → 同项目不选任何东西
|
|
967
|
+
assert.deepEqual(pick.apply(visible, [], 'sameProject', { id: 'x', composeProject: null }, 8).ids, [])
|
|
968
|
+
|
|
969
|
+
// 计数按剩余名额截断;over 如实报出被略过的数量
|
|
970
|
+
// 4 个可见、上限 2 → chip 显示 2、另有 2 个超出上限
|
|
971
|
+
const counts = pick.presetCounts(visible, [], null, 2)
|
|
972
|
+
const all = counts.find((item) => item.key === 'all')
|
|
973
|
+
assert.equal(all.count, 2)
|
|
974
|
+
assert.equal(all.over, 2)
|
|
975
|
+
// 已勾 1 个、上限 3 → 只剩 2 个名额
|
|
976
|
+
const tightCounts = pick.presetCounts(visible, ['a'], null, 3).find((item) => item.key === 'all')
|
|
977
|
+
assert.equal(tightCounts.count, 2)
|
|
978
|
+
assert.equal(tightCounts.over, 1)
|
|
979
|
+
// 基准类预设在没有勾选时不出现
|
|
980
|
+
assert.equal(pick.presetCounts(visible, [], null, 8).some((item) => item.key === 'sameImage'), false)
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
await test('聚合日志:暂停期间的缓冲合并(环形上限、不丢行、越界带 dropped 标志)', () => {
|
|
984
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
985
|
+
const agg = exports_.__aggLogs
|
|
986
|
+
assert.ok(agg !== undefined && typeof agg.mergeBuffered === 'function', '缺少 __aggLogs 测试缝')
|
|
987
|
+
const entries = [{ service: 'a', text: '1' }, { service: 'a', text: '2' }]
|
|
988
|
+
// 无缓冲:原样返回(同一引用,避免无谓重渲染)且无截断
|
|
989
|
+
assert.deepEqual(agg.mergeBuffered(entries, [], 5000), { entries, dropped: false })
|
|
990
|
+
// 有缓冲:按到达顺序追加在后
|
|
991
|
+
const merged = agg.mergeBuffered(entries, [{ service: 'b', text: '3' }], 5000)
|
|
992
|
+
assert.deepEqual(merged.entries.map((row) => row.text), ['1', '2', '3'])
|
|
993
|
+
assert.equal(merged.dropped, false)
|
|
994
|
+
// 超过上限:丢最旧、保最新(与 FOLLOW 的环形语义一致),并置 dropped(D57)
|
|
995
|
+
const many = Array.from({ length: 5 }, (_, index) => ({ service: 'a', text: String(index) }))
|
|
996
|
+
// 旧 1 行 + 新 5 行 = 6 行,上限 3 → 保留最新 3 行([2,3,4])
|
|
997
|
+
const capped = agg.mergeBuffered([{ service: 'a', text: 'old' }], many, 3)
|
|
998
|
+
assert.equal(capped.entries.length, 3)
|
|
999
|
+
assert.deepEqual(capped.entries.map((row) => row.text), ['2', '3', '4'])
|
|
1000
|
+
assert.equal(capped.dropped, true)
|
|
1001
|
+
})
|
|
1002
|
+
|
|
1003
|
+
await test('聚合日志增强:时间戳解析 / 级别过滤 / 按时间合并 / 导出', () => {
|
|
1004
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
1005
|
+
const agg = exports_.__aggLogs
|
|
1006
|
+
assert.ok(agg !== undefined && typeof agg.orderByTs === 'function', '缺少 __aggLogs 增强测试缝')
|
|
1007
|
+
|
|
1008
|
+
// 时间戳:解析出毫秒并从正文里剥掉;无前缀的行保持原样
|
|
1009
|
+
const a = agg.splitTs('2026-09-09T16:11:34.150123456Z hello')
|
|
1010
|
+
assert.equal(a.text, 'hello')
|
|
1011
|
+
assert.equal(a.ts, Date.parse('2026-09-09T16:11:34.150Z'))
|
|
1012
|
+
const b = agg.splitTs('no-prefix line')
|
|
1013
|
+
assert.equal(b.ts, null)
|
|
1014
|
+
assert.equal(b.text, 'no-prefix line')
|
|
1015
|
+
// 带时区偏移的写法
|
|
1016
|
+
assert.equal(agg.splitTs('2026-09-09T16:11:34+08:00 x').ts, Date.parse('2026-09-09T16:11:34+08:00'))
|
|
1017
|
+
|
|
1018
|
+
// 级别:两种常见前缀都认
|
|
1019
|
+
assert.equal(agg.levelName('[INFO] [2026-09-09 16:11:34] [main] x'), 'INFO')
|
|
1020
|
+
assert.equal(agg.levelName('16:11:34,150 |ERROR in x'), 'ERROR')
|
|
1021
|
+
assert.equal(agg.levelName('plain text'), null)
|
|
1022
|
+
|
|
1023
|
+
// 按时间合并:两个容器的行交错成一条真时间线
|
|
1024
|
+
const rows = [
|
|
1025
|
+
{ service: 'a', text: 'a2', ts: 200 },
|
|
1026
|
+
{ service: 'b', text: 'b1', ts: 100 },
|
|
1027
|
+
{ service: 'a', text: 'a3', ts: 300 },
|
|
1028
|
+
{ service: 'b', text: 'b2', ts: 150 },
|
|
1029
|
+
]
|
|
1030
|
+
assert.deepEqual(agg.orderByTs(rows).map((row) => row.text), ['b1', 'b2', 'a2', 'a3'])
|
|
1031
|
+
// 尾部回填重排:先到的 A 历史批次、后到的 B 历史批次,能在最近 N 行内被纠正
|
|
1032
|
+
const batch1 = [{ service: 'a', text: 'a-late', ts: 900 }, { service: 'a', text: 'a-early', ts: 100 }]
|
|
1033
|
+
const afterBatch1 = agg.orderByTs(batch1)
|
|
1034
|
+
assert.deepEqual(afterBatch1.map((r) => r.text), ['a-early', 'a-late'])
|
|
1035
|
+
const batch2 = [{ service: 'b', text: 'b-mid', ts: 500 }]
|
|
1036
|
+
const merged = agg.reorderTail(afterBatch1, batch2, agg.REORDER_TAIL)
|
|
1037
|
+
assert.deepEqual(merged.map((r) => r.text), ['a-early', 'b-mid', 'a-late'], '后到的批次应把尾部重新排好')
|
|
1038
|
+
// 超出尾部窗口的旧行保持不动(只回填最近 N 行)
|
|
1039
|
+
const longTail = Array.from({ length: 5 }, (_, i) => ({ service: 'a', text: 'old' + String(i), ts: i }))
|
|
1040
|
+
const kept = agg.reorderTail(longTail, [{ service: 'b', text: 'new', ts: 100 }], 2)
|
|
1041
|
+
assert.deepEqual(kept.slice(0, 3).map((r) => r.text), ['old0', 'old1', 'old2'], '窗口外的旧行保持原序')
|
|
1042
|
+
|
|
1043
|
+
// 无时间戳的行沿用前一行的时间(不会被甩到最前/最后)
|
|
1044
|
+
const withNull = [
|
|
1045
|
+
{ service: 'a', text: 'x', ts: 500 },
|
|
1046
|
+
{ service: 'b', text: 'no-ts', ts: null },
|
|
1047
|
+
{ service: 'a', text: 'y', ts: 100 },
|
|
1048
|
+
]
|
|
1049
|
+
assert.deepEqual(agg.orderByTs(withNull).map((row) => row.text), ['y', 'x', 'no-ts'])
|
|
1050
|
+
// 窗口首行是无前缀续行时,继承**窗口外**前一行的时序种子(D56):
|
|
1051
|
+
// 它属于 400 那条记录,不能因为 carried 初值 0 被排到 ts=300 的行前面
|
|
1052
|
+
const windowHead = [
|
|
1053
|
+
{ service: 'b', text: 'no-ts-head', ts: null },
|
|
1054
|
+
{ service: 'a', text: 'x', ts: 300 },
|
|
1055
|
+
]
|
|
1056
|
+
assert.deepEqual(agg.orderByTs(windowHead, 400).map((row) => row.text), ['x', 'no-ts-head'])
|
|
1057
|
+
assert.deepEqual(agg.orderByTs(windowHead).map((row) => row.text), ['no-ts-head', 'x'], '不传种子时是旧缺陷形态(续行被甩到窗口最前)')
|
|
1058
|
+
|
|
1059
|
+
// 级别过滤:ERROR+ 只留 ERROR/FATAL 与**无级别前缀**的行(未知级别不误杀)
|
|
1060
|
+
const mixed = [
|
|
1061
|
+
{ service: 'a', text: '[INFO] i', ts: 1 },
|
|
1062
|
+
{ service: 'a', text: '[WARN] w', ts: 2 },
|
|
1063
|
+
{ service: 'a', text: '[ERROR] e', ts: 3 },
|
|
1064
|
+
{ service: 'a', text: 'plain continuation', ts: 4 },
|
|
1065
|
+
]
|
|
1066
|
+
// 续行继承上一条的级别:ERROR 后面的续行跟着留下,INFO 后面的续行被滤掉
|
|
1067
|
+
assert.deepEqual(agg.filterByLevel(mixed, 4).map((row) => row.text), ['[ERROR] e', 'plain continuation'])
|
|
1068
|
+
assert.deepEqual(agg.filterByLevel(mixed, 3).map((row) => row.text), ['[WARN] w', '[ERROR] e', 'plain continuation'])
|
|
1069
|
+
assert.equal(agg.filterByLevel(mixed, 0).length, 4)
|
|
1070
|
+
// Spring Boot 形态:时间戳在前、级别用竖线;其后的堆栈续行应继承 ERROR
|
|
1071
|
+
const spring = [
|
|
1072
|
+
{ service: 'a', text: '16:11:34,150 |INFO in x', ts: 1 },
|
|
1073
|
+
{ service: 'a', text: '16:11:35,150 |ERROR in y', ts: 2 },
|
|
1074
|
+
{ service: 'a', text: ' at com.foo.Bar(baz.java:1)', ts: 3 },
|
|
1075
|
+
{ service: 'a', text: ' at com.foo.Qux(baz.java:2)', ts: 4 },
|
|
1076
|
+
{ service: 'a', text: '16:11:36,150 |DEBUG in z', ts: 5 },
|
|
1077
|
+
]
|
|
1078
|
+
assert.deepEqual(agg.filterByLevel(spring, 4).map((row) => row.text), [
|
|
1079
|
+
'16:11:35,150 |ERROR in y',
|
|
1080
|
+
' at com.foo.Bar(baz.java:1)',
|
|
1081
|
+
' at com.foo.Qux(baz.java:2)',
|
|
1082
|
+
])
|
|
1083
|
+
// 窗口开头的续行(记录头在窗口外)无从判断 → 保留
|
|
1084
|
+
assert.deepEqual(agg.filterByLevel([{ service: 'a', text: 'orphan continuation', ts: 1 }], 4).map((r) => r.text), ['orphan continuation'])
|
|
1085
|
+
|
|
1086
|
+
// 导出:.log 是纯行;.md 带来源表头且正文进围栏
|
|
1087
|
+
const items = [{ name: 'web' }, { name: 'api' }]
|
|
1088
|
+
const logText = agg.exportText(mixed.slice(0, 2), { format: 'log' })
|
|
1089
|
+
assert.equal(logText.split('\n').length, 2)
|
|
1090
|
+
assert.ok(logText.startsWith('[a] '), '每行应带服务前缀')
|
|
1091
|
+
assert.ok(logText.includes(new Date(1).toISOString()), '.log 行内含 ISO 时间戳')
|
|
1092
|
+
const md = agg.exportText(mixed.slice(0, 2), { format: 'md', target: '目标1', targetLabel: 'root@10.0.0.5', items })
|
|
1093
|
+
assert.ok(md.startsWith('# 聚合日志'), 'md 应有标题')
|
|
1094
|
+
assert.ok(md.includes('容器(2):web、api'), 'md 应列出源容器')
|
|
1095
|
+
assert.ok(md.includes('```text'), 'md 正文应在代码围栏里')
|
|
1096
|
+
assert.ok(md.includes('- 行数:2'))
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
await test('记住上次选的目标:优先级与失效回退', () => {
|
|
1100
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
1101
|
+
const panel = exports_.__panel
|
|
1102
|
+
assert.ok(panel !== undefined && typeof panel.chooseInitialTarget === 'function', '缺少 __panel 测试缝')
|
|
1103
|
+
const list = [{ name: '本机' }, { name: 'prod' }, { name: 'lab' }]
|
|
1104
|
+
|
|
1105
|
+
// 连接栏指定(current 非空)优先于一切
|
|
1106
|
+
assert.equal(panel.chooseInitialTarget(list, 'lab', 'prod', false), 'lab')
|
|
1107
|
+
// 记住的目标仍在列表里 → 用它
|
|
1108
|
+
assert.equal(panel.chooseInitialTarget(list, '', 'lab', false), 'lab')
|
|
1109
|
+
// 记住的目标已被删/改名 → 退回第一个,而不是停在一个不存在的目标上
|
|
1110
|
+
assert.equal(panel.chooseInitialTarget(list, '', 'gone', false), '本机')
|
|
1111
|
+
// 没有记忆 → 第一个;列表为空 → 空串
|
|
1112
|
+
assert.equal(panel.chooseInitialTarget(list, '', '', false), '本机')
|
|
1113
|
+
assert.equal(panel.chooseInitialTarget([], '', 'prod', false), '')
|
|
1114
|
+
// 连接栏进来但会话主机没匹配到目标(sessionScoped)→ 不自动选
|
|
1115
|
+
assert.equal(panel.chooseInitialTarget(list, '', 'prod', true), '')
|
|
1116
|
+
/*
|
|
1117
|
+
* 同上,但面板初值已经是「上次记住的目标」:**也不能沿用**。current 优先于
|
|
1118
|
+
* sessionScoped 正是用户报的那个 bug —— 面板会显示另一台主机的容器,只留一条
|
|
1119
|
+
* 浅色横幅,比空着更误导(空态文案明确说「不会自动切到其他目标」)。
|
|
1120
|
+
*/
|
|
1121
|
+
assert.equal(panel.chooseInitialTarget(list, 'prod', 'prod', true), '')
|
|
1122
|
+
|
|
1123
|
+
// 侧边栏入口传空串(不是 undefined):必须当「没指定」处理,否则会盖掉记忆值
|
|
1124
|
+
assert.equal(''.trim() !== '' ? '' : 'prod', 'prod')
|
|
1125
|
+
// 存储不可用(Node 桩里没有 localStorage / 隐私模式)时必须静默降级,不抛异常
|
|
1126
|
+
assert.equal(panel.readLastTarget(), '')
|
|
1127
|
+
panel.writeLastTarget('prod')
|
|
1128
|
+
assert.equal(panel.readLastTarget(), '')
|
|
1129
|
+
assert.equal(panel.LAST_TARGET_KEY, 'dsh-docker:last-target')
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
await test('切目标的过渡:过期标注 / 内容锁定 / 淡入 / 失败清空', () => {
|
|
1133
|
+
const decoded = decodeBundle(code)
|
|
1134
|
+
// 归属不一致时出横幅并锁住正文;数据落地才解锁
|
|
1135
|
+
// 可见文案:状态 + 归属两段,不写成一句话;完整解释在 title 里
|
|
1136
|
+
assert.ok(decoded.includes('正在切换到'), '缺少切换状态文案')
|
|
1137
|
+
assert.ok(decoded.includes('当前显示:'), '缺少数据归属文案')
|
|
1138
|
+
assert.ok(decoded.includes('切换完成前不可操作'), '缺少 title 里的完整说明')
|
|
1139
|
+
assert.ok(!decoded.includes('暂时点不动'), '不该再出现口语化的「点不动」')
|
|
1140
|
+
assert.ok(code.includes('dk_switchOverlay'), '缺少切换过渡浮层钩子')
|
|
1141
|
+
assert.ok(code.includes('dk_switchPill'), '缺少切换提示胶囊钩子')
|
|
1142
|
+
assert.ok(code.includes('@keyframes dk_indeterminate'), '缺少顶部不定长进度条动画')
|
|
1143
|
+
// esbuild 会把对象键统一成双引号,所以断言产出形态而不是源码写法
|
|
1144
|
+
assert.ok(code.includes('data-stale":'), '缺少正文过期标记(JS 侧设置 data-stale 属性)')
|
|
1145
|
+
const staleRule = /\.dk_body\[data-stale="1"\] \.dk_main \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1146
|
+
assert.ok(staleRule.includes('pointer-events: none'), '过期内容必须锁住指针(否则会把命令发到错的主机)')
|
|
1147
|
+
assert.ok(staleRule.includes('opacity'), '过期内容要与当前数据区分')
|
|
1148
|
+
// 过渡浮层必须是绝对定位(不推版:横幅会把内容整块推下去)
|
|
1149
|
+
const overlayRule = /\.dk_switchOverlay \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1150
|
+
assert.ok(overlayRule.includes('position: absolute'), '过渡浮层要绝对定位,避免顶部跳动')
|
|
1151
|
+
assert.ok(overlayRule.includes('pointer-events: none'), '浮层自身不能吃掉滚动事件')
|
|
1152
|
+
// 浮层要水平居中(与 dsh-rss 的加载胶囊同一位置语言:内容区顶部居中)
|
|
1153
|
+
assert.ok(overlayRule.includes('justify-content: center'), '胶囊应在内容区顶部居中')
|
|
1154
|
+
const pillRule = /\.dk_switchPill \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1155
|
+
assert.ok(pillRule.includes('color-mix(in srgb, var(--dk-accent)'), '胶囊底色/描边走强调色(蓝色)')
|
|
1156
|
+
assert.ok(pillRule.includes('color: var(--dk-accent)'), '胶囊文字走强调色')
|
|
1157
|
+
assert.ok(pillRule.includes('backdrop-filter'), '压在旧内容上仍要可读')
|
|
1158
|
+
// 淡入动画 + 尊重 reduced-motion
|
|
1159
|
+
assert.ok(code.includes('@keyframes dk_dataIn'), '缺少数据落地淡入动画')
|
|
1160
|
+
assert.ok(/\.dk_grid \{[^}]*dk_dataIn/.test(code), '网格应带淡入动画')
|
|
1161
|
+
assert.ok(code.includes('prefers-reduced-motion'), '动效要尊重 reduced-motion')
|
|
1162
|
+
/*
|
|
1163
|
+
* 切换失败时清空上个目标的列表,而不是继续张冠李戴。
|
|
1164
|
+
* 这条断言看**源码**而不是 bundle:bundle 里局部变量名已被压缩,grep 不到;
|
|
1165
|
+
* 而「四个加载器的 catch 都清空自己那份列表」是需要被防回归的结构。
|
|
1166
|
+
*/
|
|
1167
|
+
const source = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
|
|
1168
|
+
for (const clearer of ['setContainers([])', 'setImages([])', 'setNetworks([])', 'setVolumes([])']) {
|
|
1169
|
+
assert.ok(source.includes('if (listTargetRef.current !== target) ' + clearer), '切换失败要清空:' + clearer)
|
|
1170
|
+
}
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
await test('样式表:折叠态(data-sidebar-collapsed)隐藏入口标签', () => {
|
|
1174
|
+
// DSH 外壳(dsh-client-ui-layout)在侧边栏折叠时打这个属性;tty 入口同款规则。
|
|
1175
|
+
// 少了它,窄栏里会溢出一条竖排文字(真实踩过的回归)
|
|
1176
|
+
assert.ok(code.includes('[data-sidebar-collapsed] [data-dsh-docker-entry]'), '缺少折叠态入口规则')
|
|
1177
|
+
assert.ok(code.includes('[data-sidebar-collapsed] .dk_entryLabel'), '缺少折叠态标签隐藏规则')
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
await test('样式表:字段标签锁死 line-height(中英混排的行框不等高会让输入框错开)', () => {
|
|
1181
|
+
// 中文走 PingFang SC 回退字体,行框比纯拉丁文本高 ~2px;.dk_fieldGrid 是 align-items:start,
|
|
1182
|
+
// 同一行里「docker CLI」和「统计刷新间隔(秒)」的输入框就会差 2px(真实踩过的回归)。
|
|
1183
|
+
// 行高必须写死,不能留给字体度量决定 —— 这条只在无浏览器环境里守「规则还在」。
|
|
1184
|
+
const rule = /\.dk_label \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1185
|
+
assert.notEqual(rule, '', '找不到 .dk_label 规则')
|
|
1186
|
+
assert.ok(/line-height:\s*[0-9]/.test(rule), '.dk_label 缺少数值 line-height')
|
|
1187
|
+
})
|
|
1188
|
+
|
|
1189
|
+
await test('样式表:聚合日志的过滤行不参与纵向伸缩(否则过滤后整条工具条被撑高)', () => {
|
|
1190
|
+
// 同一条 .dk_filterBar 挂在两处:单容器视图在 .dk_tabs(横向行)里,flex:1 1 auto 是
|
|
1191
|
+
// 「横向占满剩余宽度」,正确;聚合日志(ComposeLogs)里它是 .dk_logs(纵向 flex)的直接
|
|
1192
|
+
// 子项,同一个 flex:1 1 auto 变成**纵向撑高**——过滤到少量行时日志体基准高度变小、剩余
|
|
1193
|
+
// 空间出现,工具条与 .dk_logBody 各分一半,整条工具条长成两百多像素的横条(输入框垂直
|
|
1194
|
+
// 居中、状态行被顶到下面),也就是「输入文字后界面变形」(真实踩过的回归)。
|
|
1195
|
+
// 这里只守「规则还在」,真实布局需浏览器。
|
|
1196
|
+
const rule = /\.dk_logs > \.dk_filterBar \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1197
|
+
assert.notEqual(rule, '', '缺少 .dk_logs > .dk_filterBar 规则')
|
|
1198
|
+
assert.ok(/flex:\s*0\s+0\s+auto/.test(rule), '.dk_logs > .dk_filterBar 必须 flex:0 0 auto(否则工具条被纵向撑高)')
|
|
1199
|
+
})
|
|
1200
|
+
|
|
1201
|
+
await test('样式表:分段选中态不得与分段条同底色(暗色下会完全看不见)', () => {
|
|
1202
|
+
/*
|
|
1203
|
+
* 真实踩过的回归:分段条 `.dk_seg` 底色用 --dk-surface-2,选中态以前用 --dk-surface-solid ——
|
|
1204
|
+
* 两者都解析到宿主同一个令牌 `--dsw-alias-bg-layer-2`,暗色主题里**完全同色**,选中态只剩一层
|
|
1205
|
+
* 1px 阴影可辨(浅色下能看见,靠的也只是阴影)。改成强调色淡染后,暗色下才有可辨的色彩差。
|
|
1206
|
+
*
|
|
1207
|
+
* 这条只在无浏览器环境里守「规则形态」:底色差异必须来自色彩,不是来自阴影。
|
|
1208
|
+
*/
|
|
1209
|
+
const barRule = /\.dk_seg \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1210
|
+
const onRule = /\.dk_segBtn\[data-on="1"\] \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1211
|
+
assert.notEqual(barRule, '', '找不到 .dk_seg 规则')
|
|
1212
|
+
assert.notEqual(onRule, '', '找不到 .dk_segBtn[data-on="1"] 规则')
|
|
1213
|
+
assert.ok(!onRule.includes('--dk-surface-solid'), '选中态不得用与分段条同源的 --dk-surface-solid(暗色下会与底色同色)')
|
|
1214
|
+
assert.ok(onRule.includes('color-mix(in srgb, var(--dk-accent)'), '选中态要走强调色淡染,靠色彩差而非阴影')
|
|
1215
|
+
// 分段条自己仍然可以走 surface-2(它是浅底槽),但两者不得落在同一个令牌上
|
|
1216
|
+
const barToken = /background:\s*var\((--[a-z0-9-]+)/.exec(barRule)?.[1] ?? ''
|
|
1217
|
+
const onToken = /background:\s*var\((--[a-z0-9-]+)/.exec(onRule)?.[1] ?? ''
|
|
1218
|
+
assert.notEqual(barToken, onToken, `分段条与选中态不得同底色令牌(都是 ${barToken})`)
|
|
1219
|
+
})
|
|
1220
|
+
|
|
1221
|
+
await test('日志 FOLLOW:bundle 内含 SSE 订阅与「回到底部」交互', () => {
|
|
1222
|
+
// 静态断言锁住契约(真实点击路径需要浏览器,由手工清单覆盖):
|
|
1223
|
+
assert.ok(code.includes('/logs/stream'), '缺少 SSE 订阅 URL')
|
|
1224
|
+
assert.ok(code.includes('EventSource'), '缺少 EventSource 订阅')
|
|
1225
|
+
assert.ok(code.includes('dk_pillFollow'), '缺少 FOLLOW 激活态样式钩子')
|
|
1226
|
+
assert.ok(code.includes('dk_backToBottom'), '缺少「回到底部」浮层')
|
|
1227
|
+
// esbuild 默认 charset=ascii:中文在 bundle 里是 \uXXXX,先解码再断言
|
|
1228
|
+
const decoded = code.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
1229
|
+
assert.ok(decoded.includes('实时跟随'), '缺少 FOLLOW 提示文案')
|
|
1230
|
+
assert.ok(decoded.includes('回到底部'), '缺少回到底部文案')
|
|
1231
|
+
assert.ok(decoded.includes('容器已退出'), '缺少流结束(容器退出)提示')
|
|
1232
|
+
})
|
|
1233
|
+
|
|
1234
|
+
await test('日志 → 会话桥:右键菜单 / 诊断包 / 投递面装配进 bundle', () => {
|
|
1235
|
+
// 静态断言只锁**字符串字面量**:局部函数名会被 esbuild minify 改名,断在函数名
|
|
1236
|
+
// 上必假(实测产物里查不到 buildAskPrompt / onLogContextMenu / askTarget)。
|
|
1237
|
+
assert.ok(code.includes('data-log-ts'), '聚合日志行缺时间戳 dataset(诊断包时间窗的唯一来源)')
|
|
1238
|
+
assert.ok(code.includes('dk_menu'), '缺少右键菜单样式钩子')
|
|
1239
|
+
// 菜单收成两项:卡片去掉后,编辑面收敛到会话输入框(卡片曾是"多开一个更弱的编辑器")
|
|
1240
|
+
assert.ok(!code.includes('dk_askCard'), '预览卡片样式应已删除')
|
|
1241
|
+
assert.ok(code.includes('dk_askToast'), '缺少失败提示样式钩子')
|
|
1242
|
+
// 投递必须经**会话作用域**取 conversation:写成根上的会在运行期抛
|
|
1243
|
+
// (conversation.send requires a session scope)
|
|
1244
|
+
assert.ok(code.includes('"conversation"') || code.includes("'conversation'"), '缺少 scope-addressed 的 conversation 解析')
|
|
1245
|
+
assert.ok(code.includes('setDraft'), '缺少「只填输入框」的 setDraft 调用')
|
|
1246
|
+
assert.ok(code.includes('.send('), '缺少「直接发送」的 send 调用')
|
|
1247
|
+
assert.ok(code.includes('inject(["sessions"]') || code.includes("inject(['sessions']"), 'sessions 未按可选注入挂载')
|
|
1248
|
+
// esbuild 默认 charset=ascii:中文在 bundle 里是 \uXXXX,先解码再断言
|
|
1249
|
+
const decoded = code.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
1250
|
+
for (const label of ['问 Agent', '直接发送到当前会话', '填入输入框,我先改改', '不构成对你的指令']) {
|
|
1251
|
+
assert.ok(decoded.includes(label), '缺少文案:' + label)
|
|
1252
|
+
}
|
|
1253
|
+
// 拿不到会话时菜单项必须能置灰:降级原因要有可显示文案
|
|
1254
|
+
assert.ok(decoded.includes('当前没有打开的会话'), '缺少「无当前会话」的降级原因')
|
|
1255
|
+
assert.ok(decoded.includes('宿主未提供 sessions 服务'), '缺少「宿主无 sessions」的降级原因')
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
await test('样式表内联进了 bundle(dk_ 前缀 + 侧边栏入口属性)', () => {
|
|
1259
|
+
assert.ok(code.includes('dk_backdrop'))
|
|
1260
|
+
assert.ok(code.includes('data-dsh-docker-entry'))
|
|
1261
|
+
// dock 模式下的刷新 / 只读徽标容器:必须靠右(margin-left:auto),不能回到工具条最左
|
|
1262
|
+
assert.ok(code.includes('dk_toolbarEnd'), '缺少 dock 工具条尾部动作容器')
|
|
1263
|
+
const toolbarEndRule = /\.dk_toolbarEnd \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1264
|
+
assert.ok(toolbarEndRule.includes('margin-left: auto'), '.dk_toolbarEnd 缺少 margin-left:auto(刷新必须靠右)')
|
|
1265
|
+
// 工具条末尾两个开关必须成组:分开排时末尾那个会被单独挤到第二行(一行一个复选框)
|
|
1266
|
+
assert.ok(code.includes('dk_toolbarToggles'), '缺少「含已停止 / 自动刷新」开关组容器')
|
|
1267
|
+
const togglesRule = /\.dk_toolbarToggles \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1268
|
+
assert.ok(togglesRule.includes('inline-flex'), '.dk_toolbarToggles 必须是不可拆的 inline-flex 组')
|
|
1269
|
+
// 搜索框是工具条里唯一可伸缩的项:min-width 太大时它会宁可把末尾开关挤到第二行
|
|
1270
|
+
const searchRule = /\.dk_search \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1271
|
+
const searchMin = Number(/min-width:\s*(\d+)px/.exec(searchRule)?.[1] ?? '999')
|
|
1272
|
+
assert.ok(searchMin <= 130, '.dk_search 的 min-width 太大(' + String(searchMin) + 'px):工具条会多出一行只放开关')
|
|
1273
|
+
// esbuild 默认 charset=ascii,中文被写成 \uXXXX:解码后再断言文案
|
|
1274
|
+
const decoded = code.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
1275
|
+
assert.ok(decoded.includes('Docker 容器'))
|
|
1276
|
+
assert.ok(decoded.includes('只读模式'))
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1279
|
+
await test('镜像管理:bundle 内含详情 / 拉取 SSE / 删除 / prune 入口', () => {
|
|
1280
|
+
// 静态断言锁住契约:路由与关键 DOM 钩子必须在 bundle 里(真实点击路径需浏览器)
|
|
1281
|
+
for (const path of ['/images/inspect', '/images/remove', '/images/prune', '/images/pull/stream']) {
|
|
1282
|
+
assert.ok(code.includes(path), '缺少路由 ' + path)
|
|
1283
|
+
}
|
|
1284
|
+
assert.ok(code.includes('dk_pullBox'), '缺少拉取进度容器')
|
|
1285
|
+
// 拉取入口是图标按钮(文字会把窄栏挤换行):用 ICON_PULL 独有的路径片段锁住
|
|
1286
|
+
assert.ok(code.includes('M5.3 6.5L8 9.2l2.7-2.7'), '缺少拉取镜像图标')
|
|
1287
|
+
assert.ok(code.includes('dk_historyTable'), '缺少构建历史表')
|
|
1288
|
+
assert.ok(code.includes('dk_layerList'), '缺少层列表')
|
|
1289
|
+
const decoded = code.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
1290
|
+
assert.ok(decoded.includes('构建历史'), '缺少构建历史文案')
|
|
1291
|
+
assert.ok(decoded.includes('拉取镜像'), '缺少拉取镜像文案')
|
|
1292
|
+
assert.ok(decoded.includes('清理 dangling 镜像'), '缺少 dangling 清理文案')
|
|
1293
|
+
assert.ok(decoded.includes('删除镜像'), '缺少删除镜像文案')
|
|
1294
|
+
})
|
|
1295
|
+
|
|
1296
|
+
await test('stats 实时跟随:SSE 订阅 + 60 点 sparkline', () => {
|
|
1297
|
+
assert.ok(code.includes('/stats/stream'), '缺少统计流订阅 URL')
|
|
1298
|
+
assert.ok(code.includes('dk_spark'), '缺少 sparkline 样式钩子')
|
|
1299
|
+
assert.ok(code.includes('dk_sparkEmpty'), '缺少 sparkline 占位样式钩子')
|
|
1300
|
+
assert.ok(code.includes('dk_statsBar'), '缺少统计工具条样式钩子')
|
|
1301
|
+
const decoded = code.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
1302
|
+
assert.ok(decoded.includes('实时跟随资源占用'), '缺少 stats FOLLOW 提示文案')
|
|
1303
|
+
assert.ok(decoded.includes('60 点'), '缺少 60 点窗口说明')
|
|
1304
|
+
assert.ok(decoded.includes('已切回快照轮询'), '缺少统计流结束后的回落文案')
|
|
1305
|
+
})
|
|
1306
|
+
|
|
1307
|
+
await test('Compose 项目视图:按项目分组 + 客户端聚合日志混流', () => {
|
|
1308
|
+
assert.ok(code.includes('dk_project'), '缺少项目卡片样式钩子')
|
|
1309
|
+
assert.ok(code.includes('dk_logSvc'), '缺少聚合日志来源前缀钩子')
|
|
1310
|
+
assert.ok(code.includes('dk_projectRows'), '缺少项目容器行样式钩子')
|
|
1311
|
+
assert.ok(code.includes('dk_composeTable'), '缺少项目服务表')
|
|
1312
|
+
const decoded = code.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
1313
|
+
assert.ok(decoded.includes('聚合日志'), '缺少聚合日志页签')
|
|
1314
|
+
assert.ok(decoded.includes('个项目'), '缺少项目计数文案')
|
|
1315
|
+
assert.ok(decoded.includes('自动滚动'), '缺少聚合日志自动滚动开关')
|
|
1316
|
+
})
|
|
1317
|
+
|
|
1318
|
+
/* ------------------------------------------------------------------ *
|
|
1319
|
+
* 容器列表多选 → 临时聚合日志
|
|
1320
|
+
* ------------------------------------------------------------------ */
|
|
1321
|
+
|
|
1322
|
+
/** 取当前 bundle 里的选择态纯逻辑(factory 返回值上的 __pick 测试缝)。 */
|
|
1323
|
+
function pickApi() {
|
|
1324
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
1325
|
+
assert.ok(exports_.__pick !== undefined, '缺少 __pick 测试缝')
|
|
1326
|
+
return exports_.__pick
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function aggLogsApi() {
|
|
1330
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
1331
|
+
assert.ok(exports_.__aggLogs !== undefined, '缺少 __aggLogs 测试缝')
|
|
1332
|
+
return exports_.__aggLogs
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function carrierApi() {
|
|
1336
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
1337
|
+
assert.ok(exports_.__carrier !== undefined, '缺少 __carrier 测试缝')
|
|
1338
|
+
return exports_.__carrier
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
await test('聚合选择:入口 / 勾选框 / 操作条装配进 bundle', () => {
|
|
1342
|
+
// 交互契约用静态断言锁住(真实点击路径需要浏览器,由手工清单覆盖):
|
|
1343
|
+
// 同一个按钮在两种标签间切换、操作条计数与提示、聚合视图标题、Esc 退出。
|
|
1344
|
+
const decoded = decodeBundle(code)
|
|
1345
|
+
assert.ok(decoded.includes('聚合选择'), '缺少「聚合选择」入口')
|
|
1346
|
+
assert.ok(decoded.includes('退出选择'), '缺少选择态下的退出标签(同一个按钮切换)')
|
|
1347
|
+
assert.ok(decoded.includes('已选 '), '缺少「已选 N 个容器」计数')
|
|
1348
|
+
assert.ok(decoded.includes('聚合日志 · '), '缺少聚合视图标题')
|
|
1349
|
+
assert.ok(decoded.includes('至少选择 2 个容器'), '缺少 N<2 的提示')
|
|
1350
|
+
assert.ok(decoded.includes('连接数较多,浏览器并发长连接有限制'), '缺少软上限提示')
|
|
1351
|
+
// 硬上限文案由「数字 + 后缀」拼出来(数字不写死),且 SSH 与本地后缀不同,
|
|
1352
|
+
// 所以这里分开锁静态部分;具体数字由下面 pickDecide 的用例断言。
|
|
1353
|
+
assert.ok(decoded.includes('最多 '), '缺少硬上限的「最多」前缀')
|
|
1354
|
+
assert.ok(decoded.includes('个容器'), '缺少硬上限的「个容器」文案')
|
|
1355
|
+
assert.ok(decoded.includes(',浏览器并发长连接有限制'), '缺少本地目标的硬上限后缀')
|
|
1356
|
+
assert.ok(decoded.includes('(SSH 目标上一条连接要同时装实时流与刷新等短命令)'), '缺少 SSH 目标的硬上限后缀')
|
|
1357
|
+
assert.ok(decoded.includes('Escape'), '缺少 Esc 退出选择态')
|
|
1358
|
+
assert.ok(code.includes('dk_pick'), '缺少勾选框样式钩子')
|
|
1359
|
+
assert.ok(code.includes('dk_pickBar'), '缺少聚合操作条样式钩子')
|
|
1360
|
+
assert.ok(code.includes('dk_pillPick'), '缺少「聚合选择」激活态样式钩子')
|
|
1361
|
+
})
|
|
1362
|
+
|
|
1363
|
+
await test('聚合选择:N<2 置灰、2~6 可聚合、7~8 软提示、>8 置灰(SSH 目标上限收到 6)', () => {
|
|
1364
|
+
const pick = pickApi()
|
|
1365
|
+
assert.equal(pick.MAX, 8)
|
|
1366
|
+
assert.equal(pick.SOFT_MAX, 6)
|
|
1367
|
+
assert.deepEqual(pick.decide(0), { canRun: false, hint: '' })
|
|
1368
|
+
assert.deepEqual(pick.decide(1), { canRun: false, hint: '至少选择 2 个容器' })
|
|
1369
|
+
assert.deepEqual(pick.decide(2), { canRun: true, hint: '' })
|
|
1370
|
+
assert.deepEqual(pick.decide(6), { canRun: true, hint: '' })
|
|
1371
|
+
assert.deepEqual(pick.decide(7), { canRun: true, hint: '连接数较多,浏览器并发长连接有限制' })
|
|
1372
|
+
assert.deepEqual(pick.decide(8), { canRun: true, hint: '连接数较多,浏览器并发长连接有限制' })
|
|
1373
|
+
const over = pick.decide(9)
|
|
1374
|
+
assert.equal(over.canRun, false, '超过硬上限必须置灰')
|
|
1375
|
+
assert.match(over.hint, /最多 8 个容器/)
|
|
1376
|
+
|
|
1377
|
+
// SSH 目标更紧:一条 TCP 连接的通道额度(MaxSessions 默认 10)要同时装下聚合流、
|
|
1378
|
+
// 统计流、事件流与「刷新列表」这类短命令,所以聚合上限收到 6(= 软提示线,于是 SSH
|
|
1379
|
+
// 上不再有「可点但已偏多」的区间)。本地目标走子进程,没有这个约束。
|
|
1380
|
+
assert.equal(pick.SSH_MAX, 6)
|
|
1381
|
+
assert.deepEqual(pick.decide(6, true), { canRun: true, hint: '' })
|
|
1382
|
+
const sshOver = pick.decide(7, true)
|
|
1383
|
+
assert.equal(sshOver.canRun, false, 'SSH 目标上 7 个容器必须置灰')
|
|
1384
|
+
assert.match(sshOver.hint, /最多 6 个容器/)
|
|
1385
|
+
assert.match(sshOver.hint, /SSH 目标/, '提示必须说明 SSH 上为什么更紧')
|
|
1386
|
+
assert.equal(pick.decide(7, false).canRun, true, '本地目标不受 SSH 上限影响')
|
|
1387
|
+
})
|
|
1388
|
+
|
|
1389
|
+
await test('聚合选择:勾选增删保序 + 列表刷新按 id 对账', () => {
|
|
1390
|
+
const pick = pickApi()
|
|
1391
|
+
let ids = []
|
|
1392
|
+
ids = pick.toggle(ids, 'a')
|
|
1393
|
+
ids = pick.toggle(ids, 'b')
|
|
1394
|
+
assert.deepEqual(ids, ['a', 'b'], '新勾选追加在末尾(= 流的打开顺序)')
|
|
1395
|
+
ids = pick.toggle(ids, 'a')
|
|
1396
|
+
assert.deepEqual(ids, ['b'], '再点一次 = 取消,剩余顺序不变')
|
|
1397
|
+
assert.deepEqual(pick.toggle(ids, 'c'), ['b', 'c'])
|
|
1398
|
+
// 列表刷新:c 已消失 → 自动剔除;没变化时必须返回原引用(自动刷新 5s 一次,
|
|
1399
|
+
// 否则每次都会白触发一轮渲染)
|
|
1400
|
+
const containers = [{ id: 'b' }, { id: 'd' }]
|
|
1401
|
+
assert.deepEqual(pick.reconcile(['b', 'c'], containers), ['b'])
|
|
1402
|
+
const untouched = ['b']
|
|
1403
|
+
assert.equal(pick.reconcile(untouched, containers), untouched)
|
|
1404
|
+
})
|
|
1405
|
+
|
|
1406
|
+
await test('聚合选择:聚合视图的 items = 所选容器集合(按勾选顺序)', () => {
|
|
1407
|
+
const pick = pickApi()
|
|
1408
|
+
const containers = [
|
|
1409
|
+
{ id: 'a', name: 'web' },
|
|
1410
|
+
{ id: 'b', name: 'api' },
|
|
1411
|
+
{ id: 'c', name: 'db' },
|
|
1412
|
+
]
|
|
1413
|
+
assert.deepEqual(pick.items(containers, ['c', 'a']).map((item) => item.name), ['db', 'web'])
|
|
1414
|
+
// 对账兜底:勾选里混进已消失的 id 时跳过(不抛错、不在结果里留空位)
|
|
1415
|
+
assert.deepEqual(pick.items(containers, ['a', 'gone']).map((item) => item.name), ['web'])
|
|
1416
|
+
assert.deepEqual(pick.items(containers, []), [])
|
|
1417
|
+
})
|
|
1418
|
+
|
|
1419
|
+
/* ------------------------------------------------------------------ *
|
|
1420
|
+
* 容器列表「活动」条(docker events)
|
|
1421
|
+
* ------------------------------------------------------------------ */
|
|
1422
|
+
|
|
1423
|
+
/** 取当前 bundle 里事件活动条的纯逻辑(factory 返回值上的 __events 测试缝)。 */
|
|
1424
|
+
function eventsApi() {
|
|
1425
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
1426
|
+
assert.ok(exports_.__events !== undefined, '缺少 __events 测试缝')
|
|
1427
|
+
return exports_.__events
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
await test('活动条:bundle 内含 /events/stream + 活动条装配', () => {
|
|
1431
|
+
assert.ok(code.includes('/events/stream'), '缺少事件流订阅 URL')
|
|
1432
|
+
assert.ok(code.includes('dk_activity'), '缺少活动条样式钩子')
|
|
1433
|
+
assert.ok(code.includes('dk_activityChevron'), '缺少折叠箭头样式钩子')
|
|
1434
|
+
assert.ok(code.includes('data-action'), '缺少动作着色键')
|
|
1435
|
+
const decoded = decodeBundle(code)
|
|
1436
|
+
assert.ok(decoded.includes('活动'), '缺少「活动」标题')
|
|
1437
|
+
assert.ok(decoded.includes('实时接收中(docker events)'), '缺少事件流连接态文案')
|
|
1438
|
+
assert.ok(decoded.includes('暂无事件'), '缺少空态文案')
|
|
1439
|
+
assert.ok(decoded.includes('事件流已结束'), '缺少事件流结束提示')
|
|
1440
|
+
assert.ok(decoded.includes('正在自动重连'), '缺少重连文案')
|
|
1441
|
+
})
|
|
1442
|
+
|
|
1443
|
+
await test('活动条:环形缓冲(新的在前、超限丢最旧)与动作标签', () => {
|
|
1444
|
+
const api = eventsApi()
|
|
1445
|
+
assert.equal(api.LIMIT, 50)
|
|
1446
|
+
assert.equal(api.RECENT, 8)
|
|
1447
|
+
let list = []
|
|
1448
|
+
list = api.append(list, { action: 'start', name: 'a' }, api.LIMIT)
|
|
1449
|
+
list = api.append(list, { action: 'die', name: 'b', exitCode: 137 }, api.LIMIT)
|
|
1450
|
+
assert.deepEqual(list.map((event) => event.name), ['b', 'a'], '新事件排在最前')
|
|
1451
|
+
// 超限丢最旧:塞 LIMIT + 5 条,留下的应是最新 LIMIT 条
|
|
1452
|
+
let many = []
|
|
1453
|
+
for (let index = 0; index < api.LIMIT + 5; index += 1) many = api.append(many, { action: 'start', name: 'c' + String(index) }, api.LIMIT)
|
|
1454
|
+
assert.equal(many.length, api.LIMIT)
|
|
1455
|
+
assert.equal(many[0].name, 'c' + String(api.LIMIT + 4))
|
|
1456
|
+
assert.equal(many[many.length - 1].name, 'c5')
|
|
1457
|
+
// die 带上退出码更好认;其它动作原样
|
|
1458
|
+
assert.equal(api.actionText({ action: 'die', exitCode: 137 }), 'die(137)')
|
|
1459
|
+
assert.equal(api.actionText({ action: 'die' }), 'die')
|
|
1460
|
+
assert.equal(api.actionText({ action: 'start' }), 'start')
|
|
1461
|
+
assert.equal(api.actionText({ action: 'health_status: healthy' }), 'health_status: healthy')
|
|
1462
|
+
assert.equal(api.actionText({ action: '' }), '?')
|
|
1463
|
+
})
|
|
1464
|
+
|
|
1465
|
+
await test('活动条:时间格式化(本地时区)与防抖(连续事件只刷一次列表)', async () => {
|
|
1466
|
+
const api = eventsApi()
|
|
1467
|
+
assert.equal(api.DEBOUNCE_MS, 500)
|
|
1468
|
+
const at = new Date(1700000000 * 1000)
|
|
1469
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
1470
|
+
const expected = pad(at.getHours()) + ':' + pad(at.getMinutes()) + ':' + pad(at.getSeconds())
|
|
1471
|
+
assert.equal(api.timeText(1700000000), expected)
|
|
1472
|
+
assert.equal(api.timeText(null), '--:--:--')
|
|
1473
|
+
|
|
1474
|
+
// 防抖:一帧事件一次列表重取会把刚省掉的轮询成本原样搬回来,所以必须合并
|
|
1475
|
+
let runs = 0
|
|
1476
|
+
const debounced = api.debounce(20, () => { runs += 1 })
|
|
1477
|
+
debounced.schedule()
|
|
1478
|
+
debounced.schedule()
|
|
1479
|
+
debounced.schedule()
|
|
1480
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
1481
|
+
assert.equal(runs, 1, '连续 3 次事件只应触发 1 次刷新')
|
|
1482
|
+
debounced.schedule()
|
|
1483
|
+
debounced.cancel()
|
|
1484
|
+
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
1485
|
+
assert.equal(runs, 1, 'cancel 后不应再触发(切 target / 关面板的清理路径)')
|
|
1486
|
+
})
|
|
1487
|
+
|
|
1488
|
+
await test('变更操作执行中态:按钮转圈 + 卡片锁组 + 确认框不关', () => {
|
|
1489
|
+
// 三处过渡态钩子:按钮 data-busy(图标换转圈)、卡片 data-pending(整组变更按钮压暗锁住)、
|
|
1490
|
+
// 确认框 k_confirmBusy(保持打开 + 「执行中…」)。真实点击路径需要浏览器,这里锁契约。
|
|
1491
|
+
assert.ok(code.includes('data-busy'), '缺少按钮执行中钩子')
|
|
1492
|
+
assert.ok(code.includes('data-pending'), '缺少卡片执行中钩子')
|
|
1493
|
+
assert.ok(code.includes('dk_confirmBusy'), '缺少确认框忙碌样式钩子')
|
|
1494
|
+
const decoded = decodeBundle(code)
|
|
1495
|
+
assert.ok(decoded.includes('执行中…'), '缺少确认框忙碌文案')
|
|
1496
|
+
assert.ok(decoded.includes('正在执行 '), '缺少按钮「正在执行 …」提示')
|
|
1497
|
+
assert.ok(decoded.includes('…请稍候'), '缺少「请稍候」提示')
|
|
1498
|
+
// busy 按钮同时也是 disabled:必须把 disabled 的 opacity .45 顶回 1,否则转圈跟着变淡
|
|
1499
|
+
const busyRule = /\.dk_iconBtn\[data-busy="1"\]:disabled \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1500
|
+
assert.ok(busyRule.includes('opacity: 1'), '.dk_iconBtn[data-busy] 没顶掉 disabled 的透明度')
|
|
1501
|
+
const busyConfirmRule = /\.dk_confirm\[data-busy="1"\] \.dk_btnDanger \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1502
|
+
assert.ok(busyConfirmRule.includes('opacity: 1'), '忙碌确认键没顶掉 disabled 的透明度')
|
|
1503
|
+
})
|
|
1504
|
+
|
|
1505
|
+
/* ------------------------------------------------------------------ *
|
|
1506
|
+
* 网络 / 卷(列表 + 详情)
|
|
1507
|
+
* ------------------------------------------------------------------ */
|
|
1508
|
+
|
|
1509
|
+
await test('网络 / 卷:五段切换 + 两个列表 + 详情装配进 bundle', () => {
|
|
1510
|
+
// 静态断言锁契约(真实点击路径需要浏览器,由手工清单覆盖)
|
|
1511
|
+
const decoded = decodeBundle(code)
|
|
1512
|
+
// 分段从三段长到五段
|
|
1513
|
+
for (const label of ['容器', '镜像', 'Compose', '网络', '卷']) {
|
|
1514
|
+
assert.ok(decoded.includes(label), '缺少分段标签 ' + label)
|
|
1515
|
+
}
|
|
1516
|
+
// 8 个新端点都在客户端有调用点
|
|
1517
|
+
for (const path of ['/networks', '/networks/inspect', '/networks/remove', '/networks/prune', '/volumes', '/volumes/inspect', '/volumes/remove', '/volumes/prune']) {
|
|
1518
|
+
assert.ok(code.includes(path), '缺少端点调用 ' + path)
|
|
1519
|
+
}
|
|
1520
|
+
// 样式钩子:可点击行 + 路径列省略
|
|
1521
|
+
assert.ok(code.includes('dk_rowClickable'), '缺少可点击行样式钩子')
|
|
1522
|
+
assert.ok(code.includes('dk_pathCell'), '缺少挂载点列样式钩子')
|
|
1523
|
+
// 图标(ICON_NETWORK / ICON_VOLUME 独有的路径片段)
|
|
1524
|
+
assert.ok(code.includes('M5.1 12.2h5.8'), '缺少网络图标')
|
|
1525
|
+
assert.ok(code.includes('M3.4 4.2v7.6'), '缺少卷图标')
|
|
1526
|
+
// 列表列名与详情页文案
|
|
1527
|
+
assert.ok(decoded.includes('接入的容器'), '缺少「接入的容器」页签')
|
|
1528
|
+
assert.ok(decoded.includes('子网'), '缺少子网一行')
|
|
1529
|
+
assert.ok(decoded.includes('挂载点'), '缺少挂载点一行')
|
|
1530
|
+
assert.ok(decoded.includes('搜索网络'), '缺少网络搜索框')
|
|
1531
|
+
assert.ok(decoded.includes('搜索卷'), '缺少卷搜索框')
|
|
1532
|
+
// 分段容器允许横向滚动(五段在 520px 窄栏里可能放不下)
|
|
1533
|
+
const segRule = /\.dk_seg \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1534
|
+
assert.ok(segRule.includes('overflow-x: auto'), '.dk_seg 没允许横向滚动')
|
|
1535
|
+
})
|
|
1536
|
+
|
|
1537
|
+
await test('网络 / 卷:删除与 prune 的 gated 文案 + 卷清理的数据警告', () => {
|
|
1538
|
+
const decoded = decodeBundle(code)
|
|
1539
|
+
// 每个 prune / 删除都有「需要打开允许变更操作」的两态文案(图标化后 title 是唯一入口)
|
|
1540
|
+
assert.ok(decoded.includes('清理未使用的网络'), '缺少网络 prune 文案')
|
|
1541
|
+
assert.ok(decoded.includes('清理未使用的卷'), '缺少卷 prune 文案')
|
|
1542
|
+
assert.ok(decoded.includes('清理网络需要打开「允许变更操作」'), '缺少网络 prune 门控文案')
|
|
1543
|
+
assert.ok(decoded.includes('清理卷需要打开「允许变更操作」'), '缺少卷 prune 门控文案')
|
|
1544
|
+
assert.ok(decoded.includes('删除网络'), '缺少删除网络文案')
|
|
1545
|
+
assert.ok(decoded.includes('删除卷'), '缺少删除卷文案')
|
|
1546
|
+
// 卷 prune 是唯一会删数据的:确认文案必须写明
|
|
1547
|
+
assert.ok(decoded.includes('卷里的数据会一起删除'), '缺少卷 prune 的数据丢失警告')
|
|
1548
|
+
// 网络删除失败时的可执行提示
|
|
1549
|
+
assert.ok(decoded.includes('还有容器接着'), '缺少网络被占用时的提示')
|
|
1550
|
+
})
|
|
1551
|
+
|
|
1552
|
+
/* ------------------------------------------------------------------ *
|
|
1553
|
+
* 多目标总览(不选目标,一屏看全部主机)
|
|
1554
|
+
* ------------------------------------------------------------------ */
|
|
1555
|
+
|
|
1556
|
+
/** 取当前 bundle 里的总览纯逻辑 / 正文渲染(factory 返回值上的 __overview 测试缝)。 */
|
|
1557
|
+
function overviewApi() {
|
|
1558
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
1559
|
+
assert.ok(exports_.__overview !== undefined, '缺少 __overview 测试缝')
|
|
1560
|
+
return exports_.__overview
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/** 造一格总览数据(默认一个 SSH 目标,且已经答完)。 */
|
|
1564
|
+
function ovGroup(name, containers, extra) {
|
|
1565
|
+
return { name, kind: 'ssh', label: 'root@10.0.0.5:22', containers, error: '', loaded: true, ...(extra ?? {}) }
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* 遍历 bundle 返回的 jsx 树:它只是 { type, props } 的普通对象(见上面的 jsx/jsxs 桩),
|
|
1570
|
+
* 所以离线冒烟真能「渲染」总览正文并断言,而不是只 grep bundle 里的字符串。
|
|
1571
|
+
*/
|
|
1572
|
+
function treeFind(node, predicate, out) {
|
|
1573
|
+
const found = out ?? []
|
|
1574
|
+
if (node === null || node === undefined || node === false) return found
|
|
1575
|
+
if (Array.isArray(node)) {
|
|
1576
|
+
for (const child of node) treeFind(child, predicate, found)
|
|
1577
|
+
return found
|
|
1578
|
+
}
|
|
1579
|
+
if (typeof node !== 'object') return found
|
|
1580
|
+
if (predicate(node)) found.push(node)
|
|
1581
|
+
treeFind(node.props === undefined ? undefined : node.props.children, predicate, found)
|
|
1582
|
+
return found
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/** 树里所有文本拼起来(断言文案用)。 */
|
|
1586
|
+
function treeText(node) {
|
|
1587
|
+
if (node === null || node === undefined || node === false) return ''
|
|
1588
|
+
if (Array.isArray(node)) return node.map(treeText).join('')
|
|
1589
|
+
if (typeof node === 'string') return node
|
|
1590
|
+
if (typeof node === 'number') return String(node)
|
|
1591
|
+
if (typeof node !== 'object') return ''
|
|
1592
|
+
return treeText(node.props === undefined ? undefined : node.props.children)
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
await test('总览:入口与文案装配进 bundle(只读页,不做跨目标操作)', () => {
|
|
1596
|
+
const decoded = decodeBundle(code)
|
|
1597
|
+
assert.ok(decoded.includes('总览'), '缺少「总览」入口')
|
|
1598
|
+
assert.ok(decoded.includes('(总览 · 全部目标)'), '缺少总览态的目标选择器说明项')
|
|
1599
|
+
assert.ok(decoded.includes('个目标不可达'), '缺少不可达横幅标题')
|
|
1600
|
+
assert.ok(decoded.includes('一切正常'), '缺少空态文案')
|
|
1601
|
+
assert.ok(decoded.includes('所有目标上都没有需要关注的容器'), '缺少空态说明')
|
|
1602
|
+
assert.ok(decoded.includes('需关注容器'), '缺少异常表标题')
|
|
1603
|
+
assert.ok(code.includes('dk_ovCards'), '缺少计数卡行样式钩子')
|
|
1604
|
+
assert.ok(code.includes('dk_ovCard'), '缺少计数卡钩子')
|
|
1605
|
+
assert.ok(code.includes('dk_ovCount'), '缺少计数钩子')
|
|
1606
|
+
assert.ok(code.includes('dk_pillOverview'), '缺少总览 pill 样式钩子')
|
|
1607
|
+
assert.ok(code.includes('dk_ovTable'), '缺少异常表样式钩子')
|
|
1608
|
+
assert.ok(code.includes('dk_rowClickable'), '缺少异常表的可点击行钩子')
|
|
1609
|
+
const cardsRule = /\.dk_ovCards \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1610
|
+
assert.ok(cardsRule.includes('grid'), '.dk_ovCards 应该是网格(一屏多个目标)')
|
|
1611
|
+
const errRule = /\.dk_ovCard\[data-state="error"\] \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1612
|
+
assert.ok(errRule.includes('--dk-danger'), '不可达的计数卡缺少危险色提示')
|
|
1613
|
+
assert.ok(code.includes('dk_ovCardLoading'), '缺少计数卡读取态钩子')
|
|
1614
|
+
const loadingRule = /\.dk_ovCard\[data-state="loading"\] \{[^}]*\}/.exec(code)?.[0] ?? ''
|
|
1615
|
+
assert.ok(loadingRule.includes('opacity'), '读取中的计数卡应压暗,与已有结果区分')
|
|
1616
|
+
/*
|
|
1617
|
+
* 总览里不再渲染「操作失败」那条单目标失败横幅:它讲的是当前这一个目标,而总览自己按
|
|
1618
|
+
* 目标归因(红卡 + 上方不可达横幅)——两条一起出现,同一个 SSH 超时会被看成两个故障。
|
|
1619
|
+
* 这条正则贴着压缩后的形状(和其它静态断言一样),minifier 一变就会显式失败。
|
|
1620
|
+
*/
|
|
1621
|
+
assert.match(decoded, /[\w$]+===""\|\|[\w$]+==="overview"\?null:[\s\S]{0,60}操作失败/, '总览里不该再渲染单目标失败横幅')
|
|
1622
|
+
})
|
|
1623
|
+
|
|
1624
|
+
await test('总览:计数口径与列表页「运行中」一致,异常表只收 unhealthy / restarting', () => {
|
|
1625
|
+
const api = overviewApi()
|
|
1626
|
+
assert.deepEqual(api.counts([
|
|
1627
|
+
{ state: 'running', health: null },
|
|
1628
|
+
{ state: 'paused', health: null },
|
|
1629
|
+
{ state: 'restarting', health: null },
|
|
1630
|
+
{ state: 'exited', health: null },
|
|
1631
|
+
{ state: 'created', health: null },
|
|
1632
|
+
{ state: 'running', health: 'unhealthy' },
|
|
1633
|
+
]), { running: 4, stopped: 2, unhealthy: 1 })
|
|
1634
|
+
assert.deepEqual(api.abnormal([
|
|
1635
|
+
{ name: 'a', state: 'running', health: 'unhealthy' },
|
|
1636
|
+
{ name: 'b', state: 'restarting', health: null },
|
|
1637
|
+
{ name: 'c', state: 'exited', health: null },
|
|
1638
|
+
{ name: 'd', state: 'running', health: 'starting' },
|
|
1639
|
+
]).map((item) => item.name), ['a', 'b'], 'exited 不算异常:没有 exitCode,无法区分崩溃退出与人工停掉')
|
|
1640
|
+
})
|
|
1641
|
+
|
|
1642
|
+
await test('总览:不健康优先于重启中,同级按目标配置顺序;不可达目标独立成清单', () => {
|
|
1643
|
+
const api = overviewApi()
|
|
1644
|
+
const data = api.data([
|
|
1645
|
+
ovGroup('prod', [
|
|
1646
|
+
{ id: 'p1', name: 'web', state: 'running', health: 'unhealthy', image: 'nginx:1.27', status: 'Up (unhealthy)' },
|
|
1647
|
+
{ id: 'p2', name: 'api', state: 'restarting', health: null, image: 'app:1', status: 'Restarting' },
|
|
1648
|
+
{ id: 'p3', name: 'ok', state: 'running', health: null, image: 'redis:7', status: 'Up' },
|
|
1649
|
+
]),
|
|
1650
|
+
ovGroup('stg', [
|
|
1651
|
+
{ id: 's1', name: 'worker', state: 'restarting', health: null, image: 'app:1', status: 'Restarting' },
|
|
1652
|
+
{ id: 's2', name: 'db', state: 'running', health: 'unhealthy', image: 'pg:16', status: 'Up (unhealthy)' },
|
|
1653
|
+
]),
|
|
1654
|
+
ovGroup('lab', [], { error: 'connect ETIMEDOUT 10.0.0.7:22' }),
|
|
1655
|
+
])
|
|
1656
|
+
assert.deepEqual(data.rows.map((row) => row.target + '/' + row.item.name), ['prod/web', 'stg/db', 'prod/api', 'stg/worker'], '不健康置顶,同级按目标顺序')
|
|
1657
|
+
assert.deepEqual(data.cards.map((card) => [card.name, card.running, card.stopped, card.unhealthy]), [
|
|
1658
|
+
['prod', 3, 0, 1],
|
|
1659
|
+
['stg', 2, 0, 1],
|
|
1660
|
+
['lab', 0, 0, 0],
|
|
1661
|
+
])
|
|
1662
|
+
assert.deepEqual(data.unreachable.map((card) => card.name), ['lab'])
|
|
1663
|
+
assert.equal(data.loading, false)
|
|
1664
|
+
})
|
|
1665
|
+
|
|
1666
|
+
await test('总览:渐进式落地(一格失败不牵连其余)+ 失败文案压成一行', () => {
|
|
1667
|
+
const api = overviewApi()
|
|
1668
|
+
let groups = [
|
|
1669
|
+
{ name: 'prod', kind: 'ssh', label: 'root@10.0.0.5:22', containers: [], error: '', loaded: false },
|
|
1670
|
+
{ name: 'lab', kind: 'ssh', label: 'root@10.0.0.7:22', containers: [], error: '', loaded: false },
|
|
1671
|
+
]
|
|
1672
|
+
groups = api.patch(groups, 'prod', { containers: [{ id: 'a', name: 'web', state: 'running', health: null }], loaded: true })
|
|
1673
|
+
const untouched = api.patch(groups, 'nobody', { loaded: true })
|
|
1674
|
+
assert.equal(untouched, groups, '目标名不匹配时必须返回原引用(自动刷新每轮都 setState 会白渲染)')
|
|
1675
|
+
groups = api.patch(groups, 'lab', { error: ('BOOM ' + 'x'.repeat(400)), loaded: true })
|
|
1676
|
+
const data = api.data(groups)
|
|
1677
|
+
assert.equal(data.cards[0].error, '', 'prod 的成功结果不能被 lab 的失败污染')
|
|
1678
|
+
assert.equal(data.cards[0].running, 1)
|
|
1679
|
+
assert.equal(data.cards[1].error.length, api.ERROR_MAX + 1, '失败文案应截断到上限 + 省略号')
|
|
1680
|
+
assert.equal(data.unreachable.length, 1)
|
|
1681
|
+
assert.equal(data.loading, false)
|
|
1682
|
+
assert.equal(api.errorText('first\nsecond'), 'first', 'SSH 报错只留首行')
|
|
1683
|
+
assert.equal(api.errorText(''), '未知错误')
|
|
1684
|
+
})
|
|
1685
|
+
|
|
1686
|
+
await test('总览:渲染多目标计数卡 + 异常置顶表 + 单目标失败横幅(遍历 jsx 树)', () => {
|
|
1687
|
+
const api = overviewApi()
|
|
1688
|
+
const opened = []
|
|
1689
|
+
const tree = api.body(api.data([
|
|
1690
|
+
ovGroup('本机', [{ id: 'l1', name: 'web', state: 'running', health: null, image: 'nginx:1.27', status: 'Up' }], { kind: 'local', label: '本机' }),
|
|
1691
|
+
ovGroup('prod', [
|
|
1692
|
+
{ id: 'p1', name: 'api', state: 'restarting', health: null, image: 'app:1', status: 'Restarting' },
|
|
1693
|
+
{ id: 'p2', name: 'db', state: 'running', health: 'unhealthy', image: 'pg:16', status: 'Up (unhealthy)' },
|
|
1694
|
+
]),
|
|
1695
|
+
ovGroup('lab', [], { error: 'connect ETIMEDOUT 10.0.0.7:22' }),
|
|
1696
|
+
]), {
|
|
1697
|
+
onOpenTarget: (name) => opened.push(['target', name]),
|
|
1698
|
+
onOpenContainer: (name, item) => opened.push(['container', name, item.name]),
|
|
1699
|
+
})
|
|
1700
|
+
const cards = treeFind(tree, (el) => el.props?.className === 'dk_ovCard')
|
|
1701
|
+
assert.equal(cards.length, 3, '每个目标一张计数卡')
|
|
1702
|
+
const texts = treeText(tree)
|
|
1703
|
+
for (const expected of ['本机', '运行中', '已停止', '不健康', '需关注容器(2)', 'api', 'db', '不可达']) {
|
|
1704
|
+
assert.ok(texts.includes(expected), '渲染结果缺少 ' + expected)
|
|
1705
|
+
}
|
|
1706
|
+
// 原因徽标(摘要兜底口径):restarting → 反复重启、unhealthy → 不健康
|
|
1707
|
+
const reasons = treeFind(tree, (el) => el.props?.className === 'dk_reason')
|
|
1708
|
+
assert.deepEqual(reasons.map((el) => el.props['data-reason']).sort(), ['restarting', 'unhealthy'], '异常行应带原因徽标')
|
|
1709
|
+
// 横幅是组件元素(className 由 Banner 自己给),所以断言它的入参而不是 className
|
|
1710
|
+
assert.equal(treeFind(tree, (el) => typeof el.props?.title === 'string' && el.props.title.includes('个目标不可达')).length, 1, '单个目标失败应出一条横幅')
|
|
1711
|
+
assert.equal(cards.filter((card) => card.props['data-state'] === 'error').length, 1)
|
|
1712
|
+
const rows = treeFind(tree, (el) => el.props?.className === 'dk_rowClickable')
|
|
1713
|
+
assert.equal(rows.length, 2)
|
|
1714
|
+
assert.equal(treeFind(tree, (el) => el.props?.className === 'dk_actionBar').length, 0, '总览是只读页,不该出现变更入口')
|
|
1715
|
+
assert.equal(treeFind(tree, (el) => el.props?.className === 'dk_iconBtn').length, 0, '总览不该出现任何操作按钮')
|
|
1716
|
+
rows[0].props.onClick()
|
|
1717
|
+
assert.deepEqual(opened, [['container', 'prod', 'db']], '点第一行 = 不健康那条,且带的是它自己的 target')
|
|
1718
|
+
cards[1].props.onClick()
|
|
1719
|
+
assert.deepEqual(opened[1], ['target', 'prod'], '点计数卡 = 切到该目标的常规列表')
|
|
1720
|
+
const badges = treeFind(tree, (el) => el.props !== undefined && Object.prototype.hasOwnProperty.call(el.props, 'health'))
|
|
1721
|
+
assert.deepEqual(badges.map((el) => el.props.health), ['unhealthy', null], '状态格复用 Badge,拿到规范化后的 health')
|
|
1722
|
+
})
|
|
1723
|
+
|
|
1724
|
+
await test('总览:/attention 权威结果优先于摘要兜底(OOM 可识别)', () => {
|
|
1725
|
+
const api = overviewApi()
|
|
1726
|
+
const tree = api.body(api.data([
|
|
1727
|
+
ovGroup('prod', [
|
|
1728
|
+
// 摘要层看不出问题(running + healthy),只有 inspect 才知道它刚被 OOM 杀过
|
|
1729
|
+
{ id: 'x1', name: 'worker', state: 'running', health: null, image: 'app:1', status: 'Up 3 seconds' },
|
|
1730
|
+
], {
|
|
1731
|
+
attention: [{
|
|
1732
|
+
id: 'x1',
|
|
1733
|
+
name: 'worker',
|
|
1734
|
+
state: 'running',
|
|
1735
|
+
health: null,
|
|
1736
|
+
status: 'Up 3 seconds',
|
|
1737
|
+
image: 'app:1',
|
|
1738
|
+
reasons: ['oom'],
|
|
1739
|
+
oomKilled: true,
|
|
1740
|
+
exitCode: 137,
|
|
1741
|
+
restartCount: 9,
|
|
1742
|
+
}],
|
|
1743
|
+
}),
|
|
1744
|
+
]), { onOpenTarget: () => {}, onOpenContainer: () => {} })
|
|
1745
|
+
const texts = treeText(tree)
|
|
1746
|
+
assert.ok(texts.includes('被 OOM 杀'), '权威原因应渲染出「被 OOM 杀」')
|
|
1747
|
+
assert.ok(texts.includes('需关注容器(1)'), '异常表应包含这条权威结果')
|
|
1748
|
+
const reasons = treeFind(tree, (el) => el.props?.className === 'dk_reason')
|
|
1749
|
+
assert.deepEqual(reasons.map((el) => el.props['data-reason']), ['oom'])
|
|
1750
|
+
|
|
1751
|
+
// 同一份容器,没有 attention 时摘要兜底认不出 → 一切正常(这是精度差异,不是 bug)
|
|
1752
|
+
const fallbackTree = api.body(api.data([
|
|
1753
|
+
ovGroup('prod', [{ id: 'x1', name: 'worker', state: 'running', health: null, image: 'app:1', status: 'Up 3 seconds' }]),
|
|
1754
|
+
]), { onOpenTarget: () => {}, onOpenContainer: () => {} })
|
|
1755
|
+
assert.ok(treeText(fallbackTree).includes('一切正常'), '没有 attention 且摘要正常时应显示一切正常')
|
|
1756
|
+
})
|
|
1757
|
+
|
|
1758
|
+
await test('总览:空态分三种——一切正常 / 还没答完 / 没有目标', () => {
|
|
1759
|
+
const api = overviewApi()
|
|
1760
|
+
const actions = { onOpenTarget: () => {}, onOpenContainer: () => {} }
|
|
1761
|
+
const clean = api.body(api.data([ovGroup('本机', [{ id: 'a', name: 'web', state: 'running', health: null, image: 'nginx', status: 'Up' }], { kind: 'local' }) ]), actions)
|
|
1762
|
+
const cleanText = treeText(clean)
|
|
1763
|
+
assert.ok(cleanText.includes('一切正常'), '没有异常时应显示「一切正常」')
|
|
1764
|
+
assert.equal(treeFind(clean, (el) => el.props?.className === 'dk_ovTable').length, 0, '无异常时不该有异常表')
|
|
1765
|
+
const pendingTree = api.body(api.data([
|
|
1766
|
+
ovGroup('本机', [{ id: 'a', name: 'web', state: 'running', health: null, image: 'nginx', status: 'Up' }], { kind: 'local' }),
|
|
1767
|
+
{ name: 'prod', kind: 'ssh', label: '', containers: [], error: '', loaded: false },
|
|
1768
|
+
]), actions)
|
|
1769
|
+
const pending = treeText(pendingTree)
|
|
1770
|
+
assert.ok(!pending.includes('一切正常'), '还有目标没答完时不能下「一切正常」的结论')
|
|
1771
|
+
assert.ok(pending.includes('读取中'), '未答完时应显示读取中')
|
|
1772
|
+
// 还没答完的卡不能显示 0/0/0(会被读成「这台机器没有容器」):只给读取态
|
|
1773
|
+
const pendingCards = treeFind(pendingTree, (el) => el.props?.className === 'dk_ovCard')
|
|
1774
|
+
assert.deepEqual(pendingCards.map((card) => card.props['data-state']), ['ok', 'loading'])
|
|
1775
|
+
assert.equal(treeFind(pendingTree, (el) => el.props?.className === 'dk_ovCardLoading').length, 1)
|
|
1776
|
+
assert.equal(treeFind(pendingTree, (el) => el.props?.className === 'dk_ovCardCounts').length, 1, '只有答完的那张卡出数字')
|
|
1777
|
+
assert.ok(treeText(api.body(api.data([]), actions)).includes('还没有配置 Docker 目标'), '没有目标时应引导去设置卡片')
|
|
1778
|
+
})
|
|
1779
|
+
|
|
1780
|
+
/* ------------------------------------------------------------------ *
|
|
1781
|
+
* 列表写入闸(旧请求作废)
|
|
1782
|
+
* ------------------------------------------------------------------ */
|
|
1783
|
+
|
|
1784
|
+
await test('列表写入闸:被新请求取代的那一代作废(切目标 / 换页后迟到的响应不许写状态)', () => {
|
|
1785
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
1786
|
+
assert.ok(exports_.__listSeq !== undefined, '缺少 __listSeq 测试缝')
|
|
1787
|
+
const seq = exports_.__listSeq.make()
|
|
1788
|
+
const first = seq.next()
|
|
1789
|
+
assert.ok(seq.isCurrent(first), '当前这一代有效')
|
|
1790
|
+
const second = seq.next()
|
|
1791
|
+
assert.ok(!seq.isCurrent(first), '被新请求取代的那一代必须作废(否则旧目标的数据会盖住新目标)')
|
|
1792
|
+
assert.ok(seq.isCurrent(second))
|
|
1793
|
+
assert.ok(!seq.isCurrent(0))
|
|
1794
|
+
// 闸必须真的接在加载器上,而不是只活在测试缝里:属性名不会被压缩改名,按出现次数兜。
|
|
1795
|
+
// 四个列表加载器 + 总览,各在 then / catch / finally(settle) 三处过闸。
|
|
1796
|
+
const gateUses = code.split('.isCurrent(').length - 1
|
|
1797
|
+
assert.ok(gateUses >= 15, '列表写入闸没接在加载器上(期望 ≥ 15 处,实际 ' + String(gateUses) + ')')
|
|
1798
|
+
})
|
|
1799
|
+
|
|
1800
|
+
/* ------------------------------------------------------------------ *
|
|
1801
|
+
* 总览:/attention 的 total / truncated / degraded(D101)
|
|
1802
|
+
* ------------------------------------------------------------------ */
|
|
1803
|
+
|
|
1804
|
+
await test('总览:需关注计数用服务端 total,而不是被 limit 截断后的条数(D101)', () => {
|
|
1805
|
+
const { data } = overviewApi()
|
|
1806
|
+
const items = [{ name: 'a', reasons: ['oom'] }]
|
|
1807
|
+
const groups = [ovGroup('prod', [], { attention: items, attentionTotal: 320, attentionTruncated: true })]
|
|
1808
|
+
const value = data(groups)
|
|
1809
|
+
assert.equal(value.cards[0].attention, 320, '计数必须是 total(320),不是 items.length(1)')
|
|
1810
|
+
assert.equal(value.cards[0].attentionTruncated, true)
|
|
1811
|
+
assert.equal(value.cards[0].attentionApprox, false, '有权威结果时不该标成摘要口径')
|
|
1812
|
+
assert.match(value.attentionNotice, /实际共 320 条/)
|
|
1813
|
+
assert.match(value.attentionNotice, /只列出前 1 条/)
|
|
1814
|
+
// 异常表仍按实际行数渲染(与计数卡的差异由上面的提示解释)
|
|
1815
|
+
assert.equal(value.rows.length, 1)
|
|
1816
|
+
})
|
|
1817
|
+
|
|
1818
|
+
await test('总览:无 total 的旧宿主回落 items.length,行为与 D101 之前一致', () => {
|
|
1819
|
+
const { data } = overviewApi()
|
|
1820
|
+
const items = [{ name: 'a', reasons: ['oom'] }, { name: 'b', reasons: ['dead'] }]
|
|
1821
|
+
const value = data([ovGroup('prod', [], { attention: items })])
|
|
1822
|
+
assert.equal(value.cards[0].attention, 2)
|
|
1823
|
+
assert.equal(value.cards[0].attentionTruncated, false)
|
|
1824
|
+
assert.equal(value.attentionNotice, '')
|
|
1825
|
+
})
|
|
1826
|
+
|
|
1827
|
+
await test('总览:degraded 目标出提示(inspect 降级 / 候选超预算)(D101)', () => {
|
|
1828
|
+
const { data } = overviewApi()
|
|
1829
|
+
const value = data([ovGroup('prod', [], { attention: [{ name: 'a', reasons: ['exit-nonzero'] }], attentionTotal: 1, attentionDegraded: true })])
|
|
1830
|
+
assert.equal(value.cards[0].attentionDegraded, true)
|
|
1831
|
+
assert.match(value.attentionNotice, /已降级/)
|
|
1832
|
+
})
|
|
1833
|
+
|
|
1834
|
+
/* ------------------------------------------------------------------ *
|
|
1835
|
+
* 结果
|
|
1836
|
+
* ------------------------------------------------------------------ */
|
|
1837
|
+
|
|
1838
|
+
let failed = 0
|
|
1839
|
+
for (const result of results) {
|
|
1840
|
+
if (result.ok) {
|
|
1841
|
+
console.log(` ✓ ${result.name}`)
|
|
1842
|
+
} else {
|
|
1843
|
+
failed += 1
|
|
1844
|
+
console.error(` ✗ ${result.name}\n ${result.message}`)
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
console.log(`\n[dsh-docker] client-smoke: ${String(results.length - failed)}/${String(results.length)} 通过`)
|
|
1848
|
+
clearTimeout(watchdog)
|
|
1849
|
+
// 成功路径也要显式退出(D121):主体结束后仍有周期句柄漏着的话,
|
|
1850
|
+
// 关掉看门狗就再也没人兜底了 —— 直接 exit 既保证退出也省掉排空等待
|
|
1851
|
+
process.exit(failed > 0 ? 1 : 0)
|