@hyzyn/dsh-docker 0.9.5 → 0.9.6
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 +120 -27
- package/README.md +106 -26
- package/client-src/docker.css +326 -160
- package/client-src/index.js +288 -21
- package/client.js +349 -183
- package/lib/docker.d.ts +32 -3
- package/lib/docker.js +43 -7
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +49 -0
- package/lib/index.js +390 -10
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +9 -0
- package/lib/ssh-exec.js +149 -16
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +1 -1
- package/scripts/client-smoke.mjs +205 -0
- package/scripts/log-perf.mjs +9 -2
- package/scripts/route-smoke.mjs +73 -4
package/scripts/client-smoke.mjs
CHANGED
|
@@ -1589,6 +1589,70 @@ await test('活动条:时间格式化(本地时区)与防抖(连续事
|
|
|
1589
1589
|
assert.equal(runs, 1, 'cancel 后不应再触发(切 target / 关面板的清理路径)')
|
|
1590
1590
|
})
|
|
1591
1591
|
|
|
1592
|
+
await test('事件流断开:状态判定只认 closed,unsupported 不给重连', () => {
|
|
1593
|
+
const api = eventsApi()
|
|
1594
|
+
assert.ok(api.canReconnect !== undefined, '缺少 __events.canReconnect 测试缝')
|
|
1595
|
+
assert.equal(api.canReconnect('closed'), true, 'closed 是终止态,必须能重连')
|
|
1596
|
+
// 其余都不是「需要人工重连」:open 正常、connecting / reconnecting 在自愈路上
|
|
1597
|
+
assert.equal(api.canReconnect('open'), false)
|
|
1598
|
+
assert.equal(api.canReconnect('connecting'), false)
|
|
1599
|
+
assert.equal(api.canReconnect('reconnecting'), false)
|
|
1600
|
+
// 环境没有 EventSource:重连必然再失败,给按钮等于骗人
|
|
1601
|
+
assert.equal(api.canReconnect('unsupported'), false)
|
|
1602
|
+
assert.equal(api.canReconnect(''), false)
|
|
1603
|
+
assert.equal(api.canReconnect(undefined), false)
|
|
1604
|
+
})
|
|
1605
|
+
|
|
1606
|
+
await test('事件流断开:活动条接线(兄弟按钮、不是嵌套按钮)与 ⟳ 接手动刷新', () => {
|
|
1607
|
+
const decoded = decodeBundle(code)
|
|
1608
|
+
assert.ok(decoded.includes('重连'), '缺少重连按钮文案')
|
|
1609
|
+
assert.ok(decoded.includes('不会自己回来'), '缺少「不会自愈」的说明')
|
|
1610
|
+
assert.ok(code.includes('dk_activityHeadRow'), '缺少头行容器(兄弟布局的前提)')
|
|
1611
|
+
assert.ok(code.includes('dk_activityRetry'), '缺少重连按钮样式钩子')
|
|
1612
|
+
|
|
1613
|
+
/*
|
|
1614
|
+
* 接线判据走**源码级**(局部标识符会被压缩改名,产物里只剩属性名 / 字符串):
|
|
1615
|
+
* 与上面那条「哨兵被拦在换目标逻辑之前」同一套路。
|
|
1616
|
+
*/
|
|
1617
|
+
const source = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
|
|
1618
|
+
|
|
1619
|
+
// 按钮不能嵌套:折叠头本身是 <button>,重连必须是它的兄弟
|
|
1620
|
+
const headRow = source.indexOf('dk_activityHeadRow')
|
|
1621
|
+
const retry = source.indexOf('dk_activityRetry')
|
|
1622
|
+
assert.ok(headRow !== -1, 'ActivityBar 应有头行容器')
|
|
1623
|
+
assert.ok(retry !== -1, 'ActivityBar 应有重连按钮')
|
|
1624
|
+
assert.ok(headRow < retry, '重连按钮应挂在头行容器(head 的兄弟)里')
|
|
1625
|
+
const headOpen = source.indexOf("className: 'dk_activityHead'")
|
|
1626
|
+
assert.ok(headOpen !== -1)
|
|
1627
|
+
assert.ok(retry > headOpen, '重连是要排在折叠头之后的兄弟')
|
|
1628
|
+
// 折叠头的 children 里不许出现重连按钮(那就是嵌套按钮)
|
|
1629
|
+
const headClose = source.indexOf("}, 'head')", headOpen)
|
|
1630
|
+
assert.ok(headClose !== -1, '折叠头应有结束标记')
|
|
1631
|
+
assert.ok(!source.slice(headOpen, headClose).includes('dk_activityRetry'), '重连按钮不能嵌在折叠按钮里(非法 HTML + 读屏会听成一个动作)')
|
|
1632
|
+
|
|
1633
|
+
// 事件流 effect 的依赖里必须有重连令牌,否则点了不重开
|
|
1634
|
+
assert.ok(/\}, \[active, view, target, eventsReconnect\]\)/.test(source), '事件流 effect 的依赖数组里必须含 eventsReconnect(否则点了不重开)')
|
|
1635
|
+
// 令牌必须自增:布尔翻回 false 时 effect 看不出「又要求了一次」
|
|
1636
|
+
assert.ok(/setEventsReconnect\(\(value\) => value \+ 1\)/.test(source), '重连令牌必须自增')
|
|
1637
|
+
|
|
1638
|
+
/*
|
|
1639
|
+
* ⟳ 走 refreshManually(既刷列表也重连);AUTO REFRESH 轮询必须仍只调 refresh——
|
|
1640
|
+
* 塞进轮询会让一条已终止的流被不断重开,既掩盖「它断了」,又白建 SSE。
|
|
1641
|
+
*/
|
|
1642
|
+
assert.ok(source.includes('refreshManually'), '缺少 ⟳ 的手动刷新包装')
|
|
1643
|
+
assert.ok(/setInterval\(refresh,/.test(source), 'AUTO REFRESH 轮询不该被改成重连(否则把终止态掩盖成自愈)')
|
|
1644
|
+
assert.ok(/onClick: refreshManually/.test(source), '⟳ 按钮应接手动刷新')
|
|
1645
|
+
|
|
1646
|
+
/*
|
|
1647
|
+
* 重连成功后要补一次全量列表刷新:daemon 全程没起时流**从未 open 过**,只按
|
|
1648
|
+
* 「opened」判会把补偿一起省掉——活动条绿了但列表还停在「这个目标的数据没读到」,
|
|
1649
|
+
* 看着像好了其实没好。所以判据里必须把「这次是用户点的重连」也算进去。
|
|
1650
|
+
*/
|
|
1651
|
+
assert.ok(/manualReconnect/.test(source), '缺少「这次是手动重连」的判定')
|
|
1652
|
+
assert.ok(/eventsOpenedRef\.current\.opened \|\| manualReconnect/.test(source),
|
|
1653
|
+
'重连补偿的判据必须含 manualReconnect(否则从未 open 过的流重连后列表不刷新)')
|
|
1654
|
+
})
|
|
1655
|
+
|
|
1592
1656
|
await test('变更操作执行中态:按钮转圈 + 卡片锁组 + 确认框不关', () => {
|
|
1593
1657
|
// 三处过渡态钩子:按钮 data-busy(图标换转圈)、卡片 data-pending(整组变更按钮压暗锁住)、
|
|
1594
1658
|
// 确认框 k_confirmBusy(保持打开 + 「执行中…」)。真实点击路径需要浏览器,这里锁契约。
|
|
@@ -2214,6 +2278,147 @@ await test('日志缓冲:快照切分不把结尾换行算成一行(D136 ——
|
|
|
2214
2278
|
assert.deepEqual(splitLines(''), [])
|
|
2215
2279
|
})
|
|
2216
2280
|
|
|
2281
|
+
/* ------------------------------------------------------------------ *
|
|
2282
|
+
* 一键连接本机:按钮出现的位置与 HTTP 接线
|
|
2283
|
+
* ------------------------------------------------------------------ */
|
|
2284
|
+
|
|
2285
|
+
/**
|
|
2286
|
+
* 往模块级 config 缓存里塞一份 config(按钮的三态判定读的就是它,而它平时只由
|
|
2287
|
+
* `/config` 响应推进去)。走 bundle 自己导出的 publishConfig,与生产路径同一份语义。
|
|
2288
|
+
*/
|
|
2289
|
+
function publishConfigForTest(config) {
|
|
2290
|
+
registration.factory((spec) => SEED[spec]).__config.publishConfig(config)
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
/*
|
|
2294
|
+
* 「复用已有本机目标,不看名字」这条判定的**归属**是宿主:它是写路径的一部分,由
|
|
2295
|
+
* /connect-local 的 connectLocal() / findLocalTargetName 决定,用例在 connect-local.test.ts。
|
|
2296
|
+
* 客户端曾经也有一份 localTargetName(按钮三态用),按钮搬进空态后那份就没了——这里不再重复钉。
|
|
2297
|
+
*/
|
|
2298
|
+
await test('连接本机:客户端不再自己判「复用还是新建」(那是宿主的写路径)', () => {
|
|
2299
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
2300
|
+
assert.equal(exports_.__panel.localTargetName, undefined, '客户端不该保留写路径的判定副本')
|
|
2301
|
+
assert.equal(typeof exports_.__api.connectLocal, 'function', '真正该有的客户端能力是这条 HTTP 接线')
|
|
2302
|
+
})
|
|
2303
|
+
|
|
2304
|
+
await test('连接本机:api.connectLocal 走 POST /api/dsh-docker/connect-local(不带参数)', async () => {
|
|
2305
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
2306
|
+
const api = exports_.__api
|
|
2307
|
+
assert.ok(api !== undefined && typeof api.connectLocal === 'function', '缺少 __api.connectLocal 测试缝')
|
|
2308
|
+
/*
|
|
2309
|
+
* bundle 闭包里的 `fetch` 是 factory 注入的那个参数(见冒烟脚本开头的 fetchStub),
|
|
2310
|
+
* 不是 globalThis.fetch —— 所以「真的打到了哪个 URL」要看桩的兜底错误:
|
|
2311
|
+
* 兜底分支把路径原样写进 error,命中它就等于证明这次调用打的就是这个路径。
|
|
2312
|
+
* (成功路径的响应体由 fetchStub 决定,这里只钉接线,不重复测宿主的行为。)
|
|
2313
|
+
*/
|
|
2314
|
+
await assert.rejects(() => api.connectLocal(), /unexpected \/api\/dsh-docker\/connect-local/)
|
|
2315
|
+
})
|
|
2316
|
+
|
|
2317
|
+
await test('连接本机:已配目标时**不得**出现在工具条(用户报的「多余」就是这条)', () => {
|
|
2318
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
2319
|
+
const Panel = exports_.__render.ContainerPanel
|
|
2320
|
+
assert.equal(typeof Panel, 'function')
|
|
2321
|
+
|
|
2322
|
+
/*
|
|
2323
|
+
* 这条用例是**反向**钉住一个设计决定,所以断言方向很重要:第一版把按钮挂在工具条上
|
|
2324
|
+
* (目标选择器右边)常驻,已有目标时它是纯噪音——用户当场指着截图说「有点多余」。
|
|
2325
|
+
* 所以判据不是「按钮在不在」,而是「有目标时它**不在**」。
|
|
2326
|
+
* 只看 dk_pillLocal 这个类:空态那个按钮用的是 dk_btn dk_btnPrimary,不会误命中。
|
|
2327
|
+
*/
|
|
2328
|
+
const toolbarButton = (tree) => treeFind(tree, (el) => {
|
|
2329
|
+
const cls = String(el.props?.className ?? '')
|
|
2330
|
+
return cls.includes('dk_pillLocal')
|
|
2331
|
+
})
|
|
2332
|
+
|
|
2333
|
+
// ① 全是 SSH 目标(选择器里有可用目标):工具条不该有这个按钮
|
|
2334
|
+
publishConfigForTest({ ...FAKE_CONFIG, targets: [{ name: 'prod', kind: 'ssh', book: 'prod-a' }] })
|
|
2335
|
+
const sshOnly = Panel({ carrier: 'modal', onClose: () => {}, initialTarget: 'prod' })
|
|
2336
|
+
assert.equal(toolbarButton(sshOnly).length, 0, '有可用目标时工具条里不该有「连接本机」')
|
|
2337
|
+
|
|
2338
|
+
// ② 已经有本机目标、且当前看的就是它:同样不该出现
|
|
2339
|
+
publishConfigForTest({
|
|
2340
|
+
...FAKE_CONFIG,
|
|
2341
|
+
targets: [{ name: 'prod', kind: 'ssh', book: 'prod-a' }, { name: 'local', kind: 'local' }],
|
|
2342
|
+
})
|
|
2343
|
+
const withLocal = Panel({ carrier: 'modal', onClose: () => {}, initialTarget: 'local' })
|
|
2344
|
+
assert.equal(toolbarButton(withLocal).length, 0, '当前目标就是本机时更不该有它')
|
|
2345
|
+
|
|
2346
|
+
publishConfigForTest(FAKE_CONFIG)
|
|
2347
|
+
})
|
|
2348
|
+
|
|
2349
|
+
await test('连接本机:只在「一个目标都没有」的空态里出现,且是可点的主按钮', () => {
|
|
2350
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
2351
|
+
const Panel = exports_.__render.ContainerPanel
|
|
2352
|
+
|
|
2353
|
+
/*
|
|
2354
|
+
* 这正是它该在的唯一场景:面板全空、原先只让你去设置卡片手配。
|
|
2355
|
+
* 空态里 `target === ''` ⇒ 不可能已有本机目标可复用 ⇒ 这一下必然是「新建并选中」,
|
|
2356
|
+
* 所以这里只断言「有一个可点的主按钮,文案是『连接本机』」——没有三态。
|
|
2357
|
+
*/
|
|
2358
|
+
publishConfigForTest({ ...FAKE_CONFIG, targets: [] })
|
|
2359
|
+
const empty = Panel({ carrier: 'modal', onClose: () => {}, initialTarget: '' })
|
|
2360
|
+
const buttons = treeFind(empty, (el) => el.props?.className === 'dk_btn dk_btnPrimary')
|
|
2361
|
+
assert.equal(buttons.length, 1, '无目标空态里应恰有一个一键入口')
|
|
2362
|
+
assert.equal(buttons[0].props.type, 'button')
|
|
2363
|
+
assert.equal(buttons[0].props.disabled, false)
|
|
2364
|
+
assert.equal(typeof buttons[0].props.onClick, 'function')
|
|
2365
|
+
assert.ok(treeText(empty).includes('还没有配置 Docker 目标'), '空态文案应保留(一键入口是它的补充,不是替代)')
|
|
2366
|
+
assert.ok(treeText(empty).includes('连接本机'))
|
|
2367
|
+
// 恢复模块级 configCache,避免影响后续用例
|
|
2368
|
+
publishConfigForTest(FAKE_CONFIG)
|
|
2369
|
+
})
|
|
2370
|
+
|
|
2371
|
+
await test('连接本机:目标下拉里的「+ 连接本机」判定(有 SSH、无本机时才列)', () => {
|
|
2372
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
2373
|
+
const panel = exports_.__panel
|
|
2374
|
+
const SENTINEL = panel.CONNECT_LOCAL_OPTION
|
|
2375
|
+
const offer = panel.shouldOfferConnectLocal
|
|
2376
|
+
assert.equal(typeof SENTINEL, 'string', '哨兵值应作为测试缝暴露')
|
|
2377
|
+
assert.equal(typeof offer, 'function', '判定应作为纯函数暴露')
|
|
2378
|
+
|
|
2379
|
+
/*
|
|
2380
|
+
* 这条对应的正是用户报的场景:手上有几台 SSH、想加本机。第一版把按钮从工具条撤掉后,
|
|
2381
|
+
* 这条路只剩「开设置卡片」,用户直接问「我怎么看到本机的容器」。入口要放在**用户找机器
|
|
2382
|
+
* 的地方**(目标下拉末尾),而不是常驻工具条。
|
|
2383
|
+
*
|
|
2384
|
+
* 为什么驱动纯函数而不是遍历渲染树:离线冒烟的 React 桩把 useState 冻在初值上
|
|
2385
|
+
* (组件里 `targets` 初值是 []),树里永远走不到那一项——所以判定抽成了纯函数。
|
|
2386
|
+
*/
|
|
2387
|
+
assert.equal(offer([{ name: 'prod', kind: 'ssh' }]), true,
|
|
2388
|
+
'有目标但没有本机目标时,应列出「+ 连接本机」')
|
|
2389
|
+
assert.equal(offer([
|
|
2390
|
+
{ name: 'prod', kind: 'ssh' },
|
|
2391
|
+
{ name: '本机-自定义', kind: 'local' },
|
|
2392
|
+
]), false, '已经有本机目标时不该再列(选择器里已经有它了)')
|
|
2393
|
+
assert.equal(offer([]), false,
|
|
2394
|
+
'一个目标都没有时入口在正文空态里,下拉里不该重复列一项')
|
|
2395
|
+
|
|
2396
|
+
/*
|
|
2397
|
+
* 哨兵值必须撞不上真实目标名:目标名是用户自由填的(sanitizeTargets 只限非空与长度)。
|
|
2398
|
+
* 若用普通字符串(如 `local`),用户恰好有同名目标时点「连接本机」就会切到那台机器。
|
|
2399
|
+
* NUL 开头的值在输入框里打不出来,所以它不可能与任何真实目标重名。
|
|
2400
|
+
*/
|
|
2401
|
+
assert.equal(SENTINEL.charCodeAt(0), 0, '哨兵应以 NUL 开头(输入框里打不出来)')
|
|
2402
|
+
assert.ok(SENTINEL.length > 1, '哨兵不能就是那个 NUL 本身')
|
|
2403
|
+
})
|
|
2404
|
+
|
|
2405
|
+
await test('连接本机:下拉项接的是 connectLocal(哨兵被拦在换目标逻辑之前)', () => {
|
|
2406
|
+
const source = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
|
|
2407
|
+
/*
|
|
2408
|
+
* 接线判据(源码级):哨兵一旦漏进 setTarget 就会把当前目标换成一个不存在的名字,
|
|
2409
|
+
* 面板随即报「未知目标」。所以 `if (... === CONNECT_LOCAL_OPTION)` 分支必须在
|
|
2410
|
+
* `setTarget(event.target.value)` **之前**,且分支里调的是 connectLocal。
|
|
2411
|
+
*/
|
|
2412
|
+
const guard = source.indexOf('if (event.target.value === CONNECT_LOCAL_OPTION)')
|
|
2413
|
+
const setTarget = source.indexOf('setTarget(event.target.value)')
|
|
2414
|
+
assert.ok(guard !== -1, 'onChange 里应有哨兵分支')
|
|
2415
|
+
assert.ok(setTarget !== -1, 'onChange 里应有 setTarget')
|
|
2416
|
+
assert.ok(guard < setTarget, '哨兵分支必须排在 setTarget 之前(否则会切到不存在的目标)')
|
|
2417
|
+
const branch = source.slice(guard, setTarget)
|
|
2418
|
+
assert.ok(/connectLocal\(\)/.test(branch), '哨兵分支里应调用 connectLocal()')
|
|
2419
|
+
assert.ok(/return/.test(branch), '哨兵分支必须 return,不能继续走换目标')
|
|
2420
|
+
})
|
|
2421
|
+
|
|
2217
2422
|
/* ------------------------------------------------------------------ *
|
|
2218
2423
|
* 结果
|
|
2219
2424
|
* ------------------------------------------------------------------ */
|
package/scripts/log-perf.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { readFileSync, existsSync, copyFileSync, mkdirSync, statSync } from 'nod
|
|
|
18
18
|
import { join, extname, resolve, dirname } from 'node:path'
|
|
19
19
|
import { fileURLToPath } from 'node:url'
|
|
20
20
|
import { Chrome } from '../../../scripts/chrome-cdp.mjs'
|
|
21
|
+
import { describeChromeSearch, findChrome, playwrightChromeCandidates } from '../../../scripts/chrome-path.mjs'
|
|
21
22
|
|
|
22
23
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
23
24
|
const pkg = resolve(here, '..')
|
|
@@ -62,8 +63,14 @@ function skip(reason) {
|
|
|
62
63
|
process.exit(0)
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
/*
|
|
67
|
+
* 浏览器发现走共享模块:此前这里是 `process.env.CHROME_PATH ?? '<写死的 macOS 路径>'`,
|
|
68
|
+
* 与 tty / search / codegraph 那三处同一个毛病(非 macOS 上永远命中不了)。走
|
|
69
|
+
* `findChrome` 后环境变量仍最优先,只是**多了一层「指到不存在的路径时继续找」**
|
|
70
|
+
* (旧写法 `??` 会直接把那个坏路径交给 spawn)。
|
|
71
|
+
*/
|
|
72
|
+
const chromePath = findChrome({ extraCandidates: playwrightChromeCandidates })
|
|
73
|
+
if (chromePath === null) skip('没找到 Chrome/Chromium(可用 CHROME_PATH 指定):' + describeChromeSearch())
|
|
67
74
|
for (const file of ['harness.html', 'mock-host.js', 'harness.js', 'vendor/react.js', 'vendor/react-dom.js']) {
|
|
68
75
|
if (!existsSync(join(fixtureDir, file))) skip('预览夹具缺 ' + file + '(先跑 node packages/tty/scripts/preview.mjs)')
|
|
69
76
|
}
|
package/scripts/route-smoke.mjs
CHANGED
|
@@ -389,9 +389,9 @@ function toolNames() {
|
|
|
389
389
|
* 1. 挂载面
|
|
390
390
|
* ------------------------------------------------------------------ */
|
|
391
391
|
|
|
392
|
-
await test('挂载:注册
|
|
392
|
+
await test('挂载:注册 12 个只读 agent 工具', () => {
|
|
393
393
|
const names = toolNames().sort()
|
|
394
|
-
assert.deepEqual(names, ['docker_attention', 'docker_events', 'docker_image_inspect', 'docker_images', 'docker_inspect', 'docker_logs', 'docker_networks', 'docker_ps', 'docker_stats', 'docker_targets', 'docker_volumes'])
|
|
394
|
+
assert.deepEqual(names, ['docker_attention', 'docker_connect_local', 'docker_events', 'docker_image_inspect', 'docker_images', 'docker_inspect', 'docker_logs', 'docker_networks', 'docker_ps', 'docker_stats', 'docker_targets', 'docker_volumes'])
|
|
395
395
|
})
|
|
396
396
|
|
|
397
397
|
await test('挂载:注册能力公告 section', () => {
|
|
@@ -426,7 +426,7 @@ await test('GET /config:凭证脱敏 + 只读默认 + 复用 tty 连接簿名'
|
|
|
426
426
|
const direct = config.targets.find((item) => item.name === '直连')
|
|
427
427
|
assert.equal(direct.passwordSet, true)
|
|
428
428
|
assert.equal(direct.passphraseSet, false)
|
|
429
|
-
assert.deepEqual(config.toolsRegistered.sort(), ['docker_attention', 'docker_events', 'docker_image_inspect', 'docker_images', 'docker_inspect', 'docker_logs', 'docker_networks', 'docker_ps', 'docker_stats', 'docker_targets', 'docker_volumes'])
|
|
429
|
+
assert.deepEqual(config.toolsRegistered.sort(), ['docker_attention', 'docker_connect_local', 'docker_events', 'docker_image_inspect', 'docker_images', 'docker_inspect', 'docker_logs', 'docker_networks', 'docker_ps', 'docker_stats', 'docker_targets', 'docker_volumes'])
|
|
430
430
|
})
|
|
431
431
|
|
|
432
432
|
await test('POST /config:未知键被拒绝', async () => {
|
|
@@ -1042,7 +1042,76 @@ await test('GET /config 以 settings 解析值为准(挂载竞态不再返回
|
|
|
1042
1042
|
})
|
|
1043
1043
|
|
|
1044
1044
|
/* ------------------------------------------------------------------ *
|
|
1045
|
-
* 7.
|
|
1045
|
+
* 7. 一键连接本机:显式、保守、幂等(空 targets 的既有语义不变)
|
|
1046
|
+
* ------------------------------------------------------------------ */
|
|
1047
|
+
|
|
1048
|
+
/*
|
|
1049
|
+
* 单独搭一个「一个目标都没有」的宿主:主 ctx 已经历过 clearTargets / 重写目标,
|
|
1050
|
+
* 借用它会分不清「本来就空」还是「被上一节清掉了」。这条路径要的正是**从来没配过**
|
|
1051
|
+
* 的那份初始状态。
|
|
1052
|
+
*/
|
|
1053
|
+
const localOnly = makeCtx({ dockerBin: fakeBin, targets: [] }, { ttyConfig: TTY_CONFIG })
|
|
1054
|
+
host.apply({ ...localOnly.ctx }, { dockerBin: fakeBin, targets: [] })
|
|
1055
|
+
const localRoute = localOnly.state.routes.find((item) => item.path === '/api/dsh-docker')
|
|
1056
|
+
assert.ok(localRoute !== undefined, '未注册 /api/dsh-docker 路由(空目标宿主)')
|
|
1057
|
+
|
|
1058
|
+
async function callOn(route_, method, sub, body) {
|
|
1059
|
+
const res = makeRes()
|
|
1060
|
+
await route_.handler(makeReq(method, '/api/dsh-docker' + sub, body), res)
|
|
1061
|
+
return res
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
await test('POST /connect-local:空目标宿主上一步加好本机目标并选中', async () => {
|
|
1065
|
+
const before = await callOn(localRoute, 'GET', '/targets')
|
|
1066
|
+
assert.deepEqual(before.body.targets, [], '前置条件:这个宿主一个目标都没有')
|
|
1067
|
+
const res = await callOn(localRoute, 'POST', '/connect-local', {})
|
|
1068
|
+
assert.equal(res.status, 200, JSON.stringify(res.body))
|
|
1069
|
+
assert.equal(res.body.result.name, 'local')
|
|
1070
|
+
assert.equal(res.body.result.created, true)
|
|
1071
|
+
assert.equal(res.body.result.saved, true)
|
|
1072
|
+
// 假 CLI 的 version 返回 27.3.1 → daemon 可达,probe 结果随响应一起回来
|
|
1073
|
+
assert.equal(res.body.result.reachable, true)
|
|
1074
|
+
assert.equal(res.body.result.serverVersion, '27.3.1')
|
|
1075
|
+
assert.match(res.body.result.message, /已添加并选中本机目标「local」/)
|
|
1076
|
+
assert.deepEqual(res.body.config.targets.map((item) => item.name), ['local'])
|
|
1077
|
+
assert.deepEqual(localOnly.state.settingsStored.targets, [{ name: 'local', kind: 'local' }])
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1080
|
+
await test('POST /connect-local:再点一次复用(不重复创建、不再写盘)', async () => {
|
|
1081
|
+
const writes = localOnly.state.settingsStored.targets.length
|
|
1082
|
+
const res = await callOn(localRoute, 'POST', '/connect-local', {})
|
|
1083
|
+
assert.equal(res.status, 200)
|
|
1084
|
+
assert.equal(res.body.result.name, 'local')
|
|
1085
|
+
assert.equal(res.body.result.created, false)
|
|
1086
|
+
assert.match(res.body.result.message, /已选中本机目标/)
|
|
1087
|
+
assert.equal(res.body.config.targets.length, writes, '目标数不应增加')
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
await test('POST /connect-local:已有 SSH 目标原样保留,本机目标追加在末尾', async () => {
|
|
1091
|
+
const sshHost = makeCtx({ dockerBin: fakeBin, targets: [{ name: '远程', kind: 'ssh', book: 'prod-a' }] }, { ttyConfig: TTY_CONFIG })
|
|
1092
|
+
host.apply({ ...sshHost.ctx }, { dockerBin: fakeBin, targets: [{ name: '远程', kind: 'ssh', book: 'prod-a' }] })
|
|
1093
|
+
const sshRoute = sshHost.state.routes.find((item) => item.path === '/api/dsh-docker')
|
|
1094
|
+
const res = await callOn(sshRoute, 'POST', '/connect-local', {})
|
|
1095
|
+
assert.equal(res.status, 200)
|
|
1096
|
+
assert.deepEqual(res.body.config.targets.map((item) => item.name), ['远程', 'local'])
|
|
1097
|
+
assert.equal(res.body.config.targets[0].book, 'prod-a', 'SSH 目标的连接簿引用必须一字不动')
|
|
1098
|
+
// 这条路径不需要任何能力授权,也不该顺手打开开关
|
|
1099
|
+
assert.equal(res.body.config.allowMutations, false)
|
|
1100
|
+
assert.equal(res.body.config.allowExec, false)
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
await test('POST /connect-local:GET 405;空数组防丢保护不受影响(clearTargets 语义不变)', async () => {
|
|
1104
|
+
const wrongMethod = await callOn(localRoute, 'GET', '/connect-local')
|
|
1105
|
+
assert.equal(wrongMethod.status, 405)
|
|
1106
|
+
const cleared = await callOn(localRoute, 'POST', '/config', { targets: [] })
|
|
1107
|
+
assert.equal(cleared.status, 200)
|
|
1108
|
+
assert.match(cleared.body.warning, /已忽略空的目标列表/, '未带 clearTargets 的空数组仍被忽略')
|
|
1109
|
+
const after = await callOn(localRoute, 'GET', '/targets')
|
|
1110
|
+
assert.equal(after.body.targets.length, 1)
|
|
1111
|
+
})
|
|
1112
|
+
|
|
1113
|
+
/* ------------------------------------------------------------------ *
|
|
1114
|
+
* 8. 禁用与恢复:enabled 热生效(设置卡片路径 + 重启路径)
|
|
1046
1115
|
* ------------------------------------------------------------------ */
|
|
1047
1116
|
|
|
1048
1117
|
await test('禁用:POST /config enabled=false → 工具清空、公告撤下、数据路由 403', async () => {
|