@hyzyn/dsh-tty 0.19.2 → 0.20.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 +62 -10
- package/README.md +49 -10
- package/client.js +162 -38
- package/lib/index.d.ts +80 -0
- package/lib/index.js +431 -91
- package/lib/index.js.map +1 -1
- package/lib/stats.d.ts +22 -1
- package/lib/stats.js +117 -49
- package/lib/stats.js.map +1 -1
- package/lib/tunnels.js +7 -0
- package/lib/tunnels.js.map +1 -1
- package/package.json +1 -1
- package/scripts/integration.mjs +171 -14
- package/scripts/preview/harness.js +322 -0
- package/scripts/preview/mock-host.js +9 -1
- package/scripts/preview.mjs +4 -0
package/scripts/integration.mjs
CHANGED
|
@@ -53,7 +53,7 @@ import path from 'node:path'
|
|
|
53
53
|
import { name, inject, apply } from '../lib/index.js'
|
|
54
54
|
import { parseSshConfig } from '../lib/ssh-config.js'
|
|
55
55
|
import { parseKnownHosts } from '../lib/known-hosts.js'
|
|
56
|
-
import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools'
|
|
56
|
+
import { assertSupportedJsonSchema, validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'
|
|
57
57
|
import { startSftpSshd, TEST_USER, TEST_PASSWORD } from './lib/test-sshd.mjs'
|
|
58
58
|
|
|
59
59
|
|
|
@@ -139,6 +139,8 @@ async function run() {
|
|
|
139
139
|
// 最小 settings 服务 stub:让插件的 settings 注入回调触发,并可手动派发
|
|
140
140
|
// settings/updated 事件(与 dsh-settings 的 dispatch 方式一致)来测配置热生效。
|
|
141
141
|
const toolDefs = []
|
|
142
|
+
/** 输出契约违约(§B33:返回值与声明的 output.schema 不符,真实宿主会直接判工具错)。 */
|
|
143
|
+
const outputViolations = []
|
|
142
144
|
const promptParts = []
|
|
143
145
|
const stubFiber = app.plugin({
|
|
144
146
|
name: 'settings-stub',
|
|
@@ -146,6 +148,32 @@ async function run() {
|
|
|
146
148
|
ctx.provide('settings', { register: () => ({ get: () => ({}), update: async () => {} }) })
|
|
147
149
|
ctx.provide('tools', {
|
|
148
150
|
register: (definition) => {
|
|
151
|
+
// 输出契约看门狗(§B33):把 execute 的返回值按**声明的 output.schema**
|
|
152
|
+
// 校验一遍,把违约记进 outputViolations。
|
|
153
|
+
//
|
|
154
|
+
// 为什么在这里做:宿主真的会校验(dsh-tools 用 additionalProperties:false
|
|
155
|
+
// 判非法输出,工具直接返回 Error),而此前的 B12e 只校验「schema 形状是否
|
|
156
|
+
// 受支持」,校验不了「返回值是否符合 schema」——tty_list 加 owner 字段时
|
|
157
|
+
// 漏改 schema 就是这样溜过去的:schema 形状合法、返回值多一个字段,
|
|
158
|
+
// 只有在真实宿主里调用才炸。
|
|
159
|
+
const origin = definition.execute
|
|
160
|
+
if (typeof origin === 'function') {
|
|
161
|
+
definition.execute = async (args, ctx) => {
|
|
162
|
+
const value = await origin(args, ctx)
|
|
163
|
+
try {
|
|
164
|
+
const schema = definition.output?.schema
|
|
165
|
+
if (schema !== undefined) {
|
|
166
|
+
const violations = validateJsonSchemaValue(schema, value)
|
|
167
|
+
if (Array.isArray(violations) && violations.length > 0) {
|
|
168
|
+
outputViolations.push(`${definition.name}: ${violations.join('; ')}`)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
outputViolations.push(`${definition.name}: 校验抛错 ${String(error && error.message ? error.message : error)}`)
|
|
173
|
+
}
|
|
174
|
+
return value
|
|
175
|
+
}
|
|
176
|
+
}
|
|
149
177
|
toolDefs.push(definition)
|
|
150
178
|
// dispose 必须真摘除:B29 禁用场景要断言「工具全部撤下」
|
|
151
179
|
return () => {
|
|
@@ -463,6 +491,26 @@ async function run() {
|
|
|
463
491
|
}
|
|
464
492
|
if (captured !== null && typeof captured.tail === 'string' && captured.tail.includes('CAPTURE_OK')) pass('B12c tty_capture 读到会话输出')
|
|
465
493
|
else fail('B12c tty_capture 读到会话输出', JSON.stringify(captured === null ? null : captured.tail).slice(0, 120))
|
|
494
|
+
// capture{last:true}:走「上一条已完成命令 + 退出码」分支。这条分支的返回值
|
|
495
|
+
// 带可选 exitCode(曾用 `?? undefined` 留下 undefined 键 → 宿主输出校验判错,
|
|
496
|
+
// 见 B33),此前 B12 只用 lines 路径、恰好绕过了它。这里跑一条会失败的命令,
|
|
497
|
+
// 退出码必然存在,同时也把该分支送进输出契约看门狗。
|
|
498
|
+
let lastResult = null
|
|
499
|
+
for (let i = 0; i < 16; i++) {
|
|
500
|
+
await sleep(250)
|
|
501
|
+
try {
|
|
502
|
+
lastResult = await capture.execute({ sid: 't12', last: true })
|
|
503
|
+
} catch {
|
|
504
|
+
lastResult = null // 命令在途时抛错(inProgress 语义),继续等
|
|
505
|
+
continue
|
|
506
|
+
}
|
|
507
|
+
if (lastResult !== null && lastResult.inProgress !== true) break
|
|
508
|
+
}
|
|
509
|
+
if (lastResult !== null && lastResult.inProgress !== true && typeof lastResult.tail === 'string' && lastResult.tail.includes('CAPTURE_OK')) {
|
|
510
|
+
pass('B12f tty_capture{last:true} 拿到上一条命令输出与退出码')
|
|
511
|
+
} else {
|
|
512
|
+
fail('B12f tty_capture{last:true} 拿到上一条命令输出与退出码', JSON.stringify(lastResult).slice(0, 140))
|
|
513
|
+
}
|
|
466
514
|
let rejected = false
|
|
467
515
|
try {
|
|
468
516
|
await capture.execute({ sid: 'ghost-session' })
|
|
@@ -1259,14 +1307,23 @@ async function run() {
|
|
|
1259
1307
|
else fail('B26c 恢复会话 kill 后 tmux 会话清理', JSON.stringify(listedFinal))
|
|
1260
1308
|
w2.client.close()
|
|
1261
1309
|
|
|
1262
|
-
// B26d: attach
|
|
1263
|
-
|
|
1310
|
+
// B26d: attach 不回放宿主环形缓冲。
|
|
1311
|
+
//
|
|
1312
|
+
// 判据(D51 修订):**不能**数「marker 在屏幕上出现几次」——attach 天然有
|
|
1313
|
+
// 两个重画来源(tmux `-A` 自身 + 宿主 refresh-client),偶尔都落帧就是 2 次,
|
|
1314
|
+
// 而那是观感问题、不是本用例要锁的回归;旧断言因此在真实 PTY 下偶发假红。
|
|
1315
|
+
//
|
|
1316
|
+
// 真正要锁的是「回放 vs 不回放」。用一个滚出可见屏的 marker 区分:
|
|
1317
|
+
// · 回放宿主 256KB 环形缓冲 → 连历史一起送到 → marker 出现(回归)
|
|
1318
|
+
// · 跳过回放、只由 tmux 重画可见屏 → 历史不在屏上 → marker 不出现(正确)
|
|
1319
|
+
// 尾部再打一个 B26TAIL 留在屏上,用它证明「确实重画过了」,避免把
|
|
1320
|
+
// 「什么都没收到」误判成通过。
|
|
1264
1321
|
const w3 = openSession(port)
|
|
1265
1322
|
await w3.open()
|
|
1266
1323
|
w3.client.send(JSON.stringify({ t: 'spawn', sid: 'b26d', cols: 80, rows: 24, persist: true, persistName: 'b26refresh' }))
|
|
1267
1324
|
await w3.waitFor(() => w3.state.ready, 10000, 'b26d ready')
|
|
1268
|
-
w3.client.send(JSON.stringify({ t: 'input', sid: 'b26d', d: 'printf "B26REFRESH-%s\\n" ok\n' }))
|
|
1269
|
-
await w3.waitFor(() => /
|
|
1325
|
+
w3.client.send(JSON.stringify({ t: 'input', sid: 'b26d', d: 'printf "B26REFRESH-%s\\n" ok; for i in $(seq 1 40); do echo "B26FILLER-$i"; done; printf "B26TAIL-%s\\n" ok\n' }))
|
|
1326
|
+
await w3.waitFor(() => /B26TAIL-ok/.test(w3.state.text), 10000, 'b26d 输出(marker 已滚出可见屏)')
|
|
1270
1327
|
await sleep(600) // 等 D 标记与重画尘埃落定
|
|
1271
1328
|
w3.client.close() // 异常断开 → 孤儿(grace=1s,需在回收前尽快 attach)
|
|
1272
1329
|
await sleep(200)
|
|
@@ -1274,16 +1331,20 @@ async function run() {
|
|
|
1274
1331
|
await w4.open()
|
|
1275
1332
|
w4.client.send(JSON.stringify({ t: 'attach', sid: 'b26d' }))
|
|
1276
1333
|
await w4.waitFor(() => w4.state.ready, 10000, 'b26d attach ready')
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1334
|
+
// 等 tmux 把可见屏重画出来(尾部 marker 可见 = 重画确实发生了)
|
|
1335
|
+
await w4.waitFor(() => /B26TAIL-ok/.test(w4.state.text), 10000, 'refresh-client 重画')
|
|
1336
|
+
await sleep(600) // 重画可能多帧落定:等稳定后再判定
|
|
1337
|
+
const replayed = /B26REFRESH-ok/.test(w4.state.text)
|
|
1338
|
+
if (!replayed) pass('B26d attach 跳过缓冲回放(仅 tmux 重画可见屏,无重影/幽灵滚动条)')
|
|
1339
|
+
else fail('B26d attach 跳过缓冲回放(仅 tmux 重画可见屏,无重影/幽灵滚动条)', 'attach 后收到了已滚出屏的历史(宿主环形缓冲被回放)')
|
|
1340
|
+
// refresh 帧:reset 后请宿主 refresh-client 重画(不碰尺寸)。
|
|
1341
|
+
// 判据用屏上可见的尾部 marker(B26REFRESH 已滚出屏,见上一条),且
|
|
1342
|
+
// 只要求「重画后仍可见」——不数次数(多来源重画是正常观感,见 D51)。
|
|
1282
1343
|
w4.client.send(JSON.stringify({ t: 'refresh', sid: 'b26d' }))
|
|
1283
1344
|
await sleep(600)
|
|
1284
|
-
const refreshedOk = (w4.state.text
|
|
1345
|
+
const refreshedOk = /B26TAIL-ok/.test(w4.state.text) && w4.state.errors.length === 0
|
|
1285
1346
|
if (refreshedOk) pass('B26e refresh 帧(refresh-client 重画,无错误)')
|
|
1286
|
-
else fail('B26e refresh 帧(refresh-client 重画,无错误)', `errors=${JSON.stringify(w4.state.errors)}`)
|
|
1347
|
+
else fail('B26e refresh 帧(refresh-client 重画,无错误)', `errors=${JSON.stringify(w4.state.errors)} tailVisible=${String(/B26TAIL-ok/.test(w4.state.text))}`)
|
|
1287
1348
|
w4.client.send(JSON.stringify({ t: 'kill', sid: 'b26d' }))
|
|
1288
1349
|
await w4.waitFor(() => w4.state.exited !== null, 10000, 'b26d exit')
|
|
1289
1350
|
w4.client.close()
|
|
@@ -1447,8 +1508,8 @@ async function run() {
|
|
|
1447
1508
|
if (on.status === 200 && on.body.config?.enabled === true) pass('B29h 重新启用生效')
|
|
1448
1509
|
else fail('B29h 重新启用生效', `status=${String(on.status)}`)
|
|
1449
1510
|
await sleep(100)
|
|
1450
|
-
if (toolDefs.length ===
|
|
1451
|
-
else fail('B29i 重新启用后
|
|
1511
|
+
if (toolDefs.length === 16) pass('B29i 重新启用后 16 个 agent 工具回归')
|
|
1512
|
+
else fail('B29i 重新启用后 16 个 agent 工具回归', `当前 ${String(toolDefs.length)}`)
|
|
1452
1513
|
if (promptParts.length === 2) pass('B29j 重新启用后公告与动态快照恢复')
|
|
1453
1514
|
else fail('B29j 重新启用后公告与动态快照恢复', `当前 ${String(promptParts.length)}`)
|
|
1454
1515
|
const again = openSession(port)
|
|
@@ -1679,6 +1740,102 @@ async function run() {
|
|
|
1679
1740
|
await postConfig({ sshHosts: [] })
|
|
1680
1741
|
}
|
|
1681
1742
|
|
|
1743
|
+
// B32: agent 自开终端(0.20.0 tty_open / tty_close / tty_stats)
|
|
1744
|
+
//
|
|
1745
|
+
// 锁的是「闭环」这件事:agent 能自己开一个会话(不必用户在面板里点)、
|
|
1746
|
+
// 长了长驻进程能读到就绪信号、用完能自己关;同时锁住两条边界:
|
|
1747
|
+
// ① agent 开的会话不被孤儿回收器当孤儿收掉(否则长驻任务一开就没);
|
|
1748
|
+
// ② tty_close 拒绝关用户开的会话(agent 不越权结束用户正在用的标签)。
|
|
1749
|
+
console.log('\n[30] agent 自开终端')
|
|
1750
|
+
{
|
|
1751
|
+
const open = toolDefs.find((d) => d.name === 'tty_open')
|
|
1752
|
+
const close = toolDefs.find((d) => d.name === 'tty_close')
|
|
1753
|
+
const statsT = toolDefs.find((d) => d.name === 'tty_stats')
|
|
1754
|
+
const list = toolDefs.find((d) => d.name === 'tty_list')
|
|
1755
|
+
const expect = toolDefs.find((d) => d.name === 'tty_expect')
|
|
1756
|
+
if (open === undefined || close === undefined || statsT === undefined) {
|
|
1757
|
+
fail('B32 agent 自开终端工具集', '缺工具: ' + toolDefs.map((d) => d.name).join(','))
|
|
1758
|
+
} else {
|
|
1759
|
+
// 面板连接先开着:验证 agent 开的会话会被推到面板(用户可见)
|
|
1760
|
+
const panel = openSession(port)
|
|
1761
|
+
await panel.open()
|
|
1762
|
+
|
|
1763
|
+
const opened = await open.execute({ cwd: '/tmp' })
|
|
1764
|
+
const sid = opened.sid
|
|
1765
|
+
if (typeof sid === 'string' && sid !== '') pass('B32a tty_open 开出会话')
|
|
1766
|
+
else fail('B32a tty_open 开出会话', JSON.stringify(opened))
|
|
1767
|
+
|
|
1768
|
+
// owner=agent 出现在 tty_list 里
|
|
1769
|
+
const listed = await list.execute({})
|
|
1770
|
+
const mine = (listed.sessions ?? []).find((x) => x.sid === sid)
|
|
1771
|
+
if (mine !== undefined && mine.owner === 'agent') pass('B32b tty_list 标出 owner=agent')
|
|
1772
|
+
else fail('B32b tty_list 标出 owner=agent', JSON.stringify(mine))
|
|
1773
|
+
|
|
1774
|
+
// 面板可见(sessions 帧广播带 owner=agent,且 attachable)
|
|
1775
|
+
let visible = false
|
|
1776
|
+
for (let i = 0; i < 20; i++) {
|
|
1777
|
+
await sleep(150)
|
|
1778
|
+
const frame = panel.state.frames.filter((f) => f.t === 'sessions').at(-1)
|
|
1779
|
+
const entry = (frame?.list ?? []).find((x) => x.sid === sid)
|
|
1780
|
+
if (entry !== undefined && entry.owner === 'agent' && entry.attachable === true) { visible = true; break }
|
|
1781
|
+
}
|
|
1782
|
+
if (visible) pass('B32c agent 开的会话对面板可见(owner=agent + attachable)')
|
|
1783
|
+
else fail('B32c agent 开的会话对面板可见', '面板没收到该会话的 sessions 帧')
|
|
1784
|
+
|
|
1785
|
+
// 长驻任务闭环:起一个「先等一拍再打印就绪」的进程,用 tty_expect 等它
|
|
1786
|
+
const send = toolDefs.find((d) => d.name === 'tty_send')
|
|
1787
|
+
await send.execute({ sid, data: 'sleep 1; printf "B32READY-%s\\n" go\n' })
|
|
1788
|
+
const waited = await expect.execute({ sid, pattern: 'B32READY-go', timeoutSec: 15 })
|
|
1789
|
+
if (waited.matched === true) pass('B32d tty_open + tty_expect 长任务就绪闭环')
|
|
1790
|
+
else fail('B32d tty_open + tty_expect 长任务就绪闭环', JSON.stringify(waited).slice(0, 120))
|
|
1791
|
+
|
|
1792
|
+
// 用户开一个会话,验证 agent 关不掉它
|
|
1793
|
+
panel.client.send(JSON.stringify({ t: 'spawn', sid: 'b32user', cols: 80, rows: 24 }))
|
|
1794
|
+
await panel.waitFor(() => panel.state.ready, 10000, 'b32user ready')
|
|
1795
|
+
let refused = false
|
|
1796
|
+
try {
|
|
1797
|
+
await close.execute({ sid: 'b32user' })
|
|
1798
|
+
} catch {
|
|
1799
|
+
refused = true
|
|
1800
|
+
}
|
|
1801
|
+
const stillAlive = (await list.execute({})).sessions.some((x) => x.sid === 'b32user')
|
|
1802
|
+
if (refused && stillAlive) pass('B32e tty_close 拒绝关用户开的会话(不越权)')
|
|
1803
|
+
else fail('B32e tty_close 拒绝关用户开的会话', `refused=${String(refused)} alive=${String(stillAlive)}`)
|
|
1804
|
+
|
|
1805
|
+
// agent 关自己的会话
|
|
1806
|
+
const closed = await close.execute({ sid })
|
|
1807
|
+
if (closed.ok === true) pass('B32f tty_close 关掉 agent 自己的会话')
|
|
1808
|
+
else fail('B32f tty_close 关掉 agent 自己的会话', JSON.stringify(closed))
|
|
1809
|
+
await sleep(300)
|
|
1810
|
+
const gone = !(await list.execute({})).sessions.some((x) => x.sid === sid)
|
|
1811
|
+
if (gone) pass('B32g 关闭后会话从清单消失')
|
|
1812
|
+
else fail('B32g 关闭后会话从清单消失', '仍在 sessions 里')
|
|
1813
|
+
|
|
1814
|
+
// tty_stats:本地会话取宿主机指标(本机是 macOS,部分字段可采;只要有数据即通过)
|
|
1815
|
+
const stat = await statsT.execute({ sid: 'b32user' })
|
|
1816
|
+
if (stat.available === true && (typeof stat.memPct === 'number' || typeof stat.cpuPct === 'number')) {
|
|
1817
|
+
pass('B32h tty_stats 取到本机指标')
|
|
1818
|
+
} else {
|
|
1819
|
+
fail('B32h tty_stats 取到本机指标', JSON.stringify(stat).slice(0, 160))
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
panel.client.send(JSON.stringify({ t: 'kill', sid: 'b32user' }))
|
|
1823
|
+
await panel.waitFor(() => panel.state.exited !== null, 10000, 'b32user exit')
|
|
1824
|
+
panel.client.close()
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// B33: 输出契约(所有已调用工具的真实返回值都符合各自声明的 output.schema)
|
|
1829
|
+
//
|
|
1830
|
+
// 这一条是 §B12e 的补充:B12e 只证明 schema 形状受支持,证明不了返回值合法。
|
|
1831
|
+
// 真实宿主对输出做 additionalProperties:false 校验,违约会直接把工具调用变成
|
|
1832
|
+
// Error(tty_list 加 owner 漏改 schema 即此)。
|
|
1833
|
+
console.log('\n[31] 输出契约')
|
|
1834
|
+
{
|
|
1835
|
+
if (outputViolations.length === 0) pass('B33 工具返回值符合声明的 output.schema')
|
|
1836
|
+
else fail('B33 工具返回值符合声明的 output.schema', outputViolations.slice(0, 3).join(' | '))
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1682
1839
|
const failed = RESULTS.filter(([kind]) => kind === 'FAIL')
|
|
1683
1840
|
console.log(`\n==== 集成测试:${RESULTS.length - failed.length}/${RESULTS.length} PASS ====`)
|
|
1684
1841
|
for (const [kind, name, detail] of RESULTS) console.log(` ${kind === 'PASS' ? '✔' : '✘'} ${name}${detail ? ' — ' + detail : ''}`)
|
|
@@ -246,6 +246,33 @@
|
|
|
246
246
|
return null
|
|
247
247
|
}
|
|
248
248
|
},
|
|
249
|
+
/* 宿主采样慢(D50):stats 帧 4 秒才来一次——旧代码的 3s 陈旧窗口会让状态条
|
|
250
|
+
每秒整条收起再出现(用户报的「瞬间消失又出现」)。这里连续采样 9 秒(跨两个
|
|
251
|
+
帧间隔),要求状态条**一次都没有**隐藏过。 */
|
|
252
|
+
async 'stats-slow'() {
|
|
253
|
+
window.__PREVIEW_STATS_INTERVAL_MS = 4000
|
|
254
|
+
await openPanel()
|
|
255
|
+
await waitFor(() => tabs().length === 1)
|
|
256
|
+
// 首帧立即推 → 状态条先出来
|
|
257
|
+
await waitFor(() => {
|
|
258
|
+
const bar = q('.tt_statsBar')
|
|
259
|
+
return bar !== null && bar.hidden !== true && /CPU/.test(bar.textContent)
|
|
260
|
+
}, 4000)
|
|
261
|
+
const visible = []
|
|
262
|
+
const timer = setInterval(() => {
|
|
263
|
+
const bar = q('.tt_statsBar')
|
|
264
|
+
visible.push(bar !== null && bar.hidden !== true)
|
|
265
|
+
}, 100)
|
|
266
|
+
await sleep(9000)
|
|
267
|
+
clearInterval(timer)
|
|
268
|
+
window.__previewAssert = async () => {
|
|
269
|
+
const hidden = visible.filter((value) => value !== true).length
|
|
270
|
+
if (hidden > 0) {
|
|
271
|
+
return '4s 一帧时状态条有 ' + String(hidden) + '/' + String(visible.length) + ' 次采样是隐藏的(整条消失又出现)'
|
|
272
|
+
}
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
},
|
|
249
276
|
/* 坏数据兜底:宿主发来的 stats 帧是垃圾(字符串/数组/null/越界值/缺字段),
|
|
250
277
|
面板必须既不抛异常也不出现非法渲染——最多整条隐藏 */
|
|
251
278
|
async 'stats-broken'() {
|
|
@@ -359,6 +386,172 @@
|
|
|
359
386
|
await waitFor(() => q('#preview-settings .tt_cardBody'))
|
|
360
387
|
await sleep(400)
|
|
361
388
|
},
|
|
389
|
+
/* 端口转发:编辑一条隧道(此前隧道区块零界面回归,D53/D54/D56 就是这么潜伏的) */
|
|
390
|
+
async 'tunnel-edit'() {
|
|
391
|
+
const host = document.createElement('div')
|
|
392
|
+
host.id = 'preview-settings'
|
|
393
|
+
host.style.cssText = 'position:fixed;inset:24px 24px 24px 260px;overflow:auto;z-index:2000;background:var(--dsw-alias-bg-base);padding:8px;border-radius:16px'
|
|
394
|
+
document.body.appendChild(host)
|
|
395
|
+
const root = window.ReactDOM.createRoot(host)
|
|
396
|
+
root.render(window.React.createElement(cards[0].Component))
|
|
397
|
+
await waitFor(() => q('#preview-settings .tt_card'))
|
|
398
|
+
await sleep(80)
|
|
399
|
+
q('#preview-settings .tt_cardHeader').click()
|
|
400
|
+
await waitFor(() => q('#preview-settings .tt_cardBody'))
|
|
401
|
+
await sleep(400)
|
|
402
|
+
|
|
403
|
+
const rowOf = (name) => [...document.querySelectorAll('#preview-settings .tt_sshHostRow')]
|
|
404
|
+
.find((r) => (r.querySelector('.tt_sshHostName')?.textContent ?? '').startsWith(name))
|
|
405
|
+
const buttonsIn = (el) => [...el.querySelectorAll('button')]
|
|
406
|
+
|
|
407
|
+
// 「编辑」按钮是本次新增的能力:先在第一条隧道行上找到它
|
|
408
|
+
const firstRow = rowOf('staging-pg')
|
|
409
|
+
if (firstRow === undefined) throw new Error('找不到 staging-pg 行(fixture 应有该隧道)')
|
|
410
|
+
const editBtn = buttonsIn(firstRow).find((b) => b.textContent === '编辑')
|
|
411
|
+
if (editBtn === undefined) throw new Error('隧道行没有「编辑」按钮')
|
|
412
|
+
editBtn.click()
|
|
413
|
+
await sleep(150)
|
|
414
|
+
|
|
415
|
+
// 把隧道区块滚进视口:截图要能拍到表单本身(设置卡片很长,隧道在下方)
|
|
416
|
+
const scrollTunnelIntoView = () => {
|
|
417
|
+
const anchor = document.querySelector('#preview-settings .tt_segmented') ?? document.querySelector('#preview-settings .tt_tunnelEndpoints')
|
|
418
|
+
if (anchor !== null) anchor.scrollIntoView({ block: 'center' })
|
|
419
|
+
}
|
|
420
|
+
scrollTunnelIntoView()
|
|
421
|
+
await sleep(120)
|
|
422
|
+
|
|
423
|
+
// 回填:本地转发那边「127.0.0.1:<端口>」成对呈现,端口输入带 aria-label 标识
|
|
424
|
+
const localPortInput = document.querySelector('#preview-settings [aria-label="本机监听端口"]')
|
|
425
|
+
if (localPortInput === null) throw new Error('本地转发表单没渲染(找不到本机监听端口输入)')
|
|
426
|
+
if (localPortInput.value !== '15432') throw new Error('编辑未回填本地端口:' + String(localPortInput.value))
|
|
427
|
+
|
|
428
|
+
// 固定端必须是**静态文本**而不是输入框(宿主硬编码了 127.0.0.1,给个能改的框是骗人)
|
|
429
|
+
const staticEnd = document.querySelector('#preview-settings .tt_tunnelEndpointStatic')
|
|
430
|
+
if (staticEnd === null) throw new Error('固定端没有渲染成静态文本')
|
|
431
|
+
if (staticEnd.textContent !== '127.0.0.1') throw new Error('固定端文本不对:' + String(staticEnd.textContent))
|
|
432
|
+
if (staticEnd.tagName === 'INPUT') throw new Error('固定端是输入框(应为静态文本)')
|
|
433
|
+
|
|
434
|
+
// 端点必须是成对呈现(host+port 同组),且中间有方向箭头
|
|
435
|
+
const endpointCount = document.querySelectorAll('#preview-settings .tt_tunnelEndpoint').length
|
|
436
|
+
if (endpointCount !== 2) throw new Error('端点组数不为 2:' + String(endpointCount))
|
|
437
|
+
if (document.querySelector('#preview-settings .tt_tunnelArrow') === null) throw new Error('缺少方向箭头')
|
|
438
|
+
|
|
439
|
+
// 分段控件:本地/远程可切换,当前项高亮
|
|
440
|
+
const segBtns = [...document.querySelectorAll('#preview-settings .tt_segmentedBtn')]
|
|
441
|
+
if (segBtns.length !== 2) throw new Error('分段控件按钮数不为 2:' + String(segBtns.length))
|
|
442
|
+
const activeSeg = segBtns.filter((b) => b.hasAttribute('data-active'))
|
|
443
|
+
if (activeSeg.length !== 1) throw new Error('分段控件没有唯一高亮项:' + String(activeSeg.length))
|
|
444
|
+
|
|
445
|
+
// 切到「远程 -R」:固定端应换到**右侧**(本机拨号),左侧变成可填的服务器侧监听地址
|
|
446
|
+
// ——两个方向的固定端不同,这正是不能照搬单一形状的原因
|
|
447
|
+
segBtns.find((b) => (b.textContent ?? '').includes('远程')).click()
|
|
448
|
+
await sleep(200)
|
|
449
|
+
scrollTunnelIntoView()
|
|
450
|
+
const remoteEndpoints = [...document.querySelectorAll('#preview-settings .tt_tunnelEndpoint')]
|
|
451
|
+
if (remoteEndpoints.length !== 2) throw new Error('远程方向端点组数不为 2:' + String(remoteEndpoints.length))
|
|
452
|
+
const hasStaticInFirst = remoteEndpoints[0].querySelector('.tt_tunnelEndpointStatic') !== null
|
|
453
|
+
const hasStaticInSecond = remoteEndpoints[1].querySelector('.tt_tunnelEndpointStatic') !== null
|
|
454
|
+
if (hasStaticInFirst || !hasStaticInSecond) {
|
|
455
|
+
throw new Error('远程方向的固定端没有换到右侧(左静态=' + String(hasStaticInFirst) + ' 右静态=' + String(hasStaticInSecond) + ')')
|
|
456
|
+
}
|
|
457
|
+
// 箭头在远程方向应翻转(CSS 旋转 180°,data-direction 决定)
|
|
458
|
+
const arrow = document.querySelector('#preview-settings .tt_tunnelArrow')
|
|
459
|
+
if (arrow === null || arrow.getAttribute('data-direction') !== 'remote') {
|
|
460
|
+
throw new Error('远程方向的箭头没有翻转标记')
|
|
461
|
+
}
|
|
462
|
+
// 切回本地 -L,后续断言按本地方向的期望继续
|
|
463
|
+
segBtns.find((b) => (b.textContent ?? '').includes('本地')).click()
|
|
464
|
+
await sleep(200)
|
|
465
|
+
scrollTunnelIntoView()
|
|
466
|
+
await sleep(80)
|
|
467
|
+
|
|
468
|
+
/*
|
|
469
|
+
* 窄容器下的排布:端点用 flex-wrap,最怕的是"挤不下也不换行 → 内容溢出到卡片外"。
|
|
470
|
+
* 设置卡片在真实界面里可能只有一栏宽度,所以这里把容器压到 420px 验证:
|
|
471
|
+
* ① 端点该换行(不是硬挤一行);② 没有任何子元素横向溢出容器。
|
|
472
|
+
*/
|
|
473
|
+
{
|
|
474
|
+
const settingsHost = document.querySelector('#preview-settings')
|
|
475
|
+
const originalWidth = settingsHost.style.width
|
|
476
|
+
settingsHost.style.width = '420px'
|
|
477
|
+
await sleep(200)
|
|
478
|
+
const endpointsRow = document.querySelector('#preview-settings .tt_tunnelEndpoints')
|
|
479
|
+
const card = document.querySelector('#preview-settings .tt_card')
|
|
480
|
+
if (endpointsRow !== null && card !== null) {
|
|
481
|
+
const rowRect = endpointsRow.getBoundingClientRect()
|
|
482
|
+
const cardRect = card.getBoundingClientRect()
|
|
483
|
+
if (rowRect.right > cardRect.right + 1) {
|
|
484
|
+
throw new Error('窄容器下端点半点溢出卡片(右边界 ' + rowRect.right.toFixed(0) + ' > 卡片 ' + cardRect.right.toFixed(0) + ')')
|
|
485
|
+
}
|
|
486
|
+
// 两个端点必须真的换行(上下排布),而不是压成一行把内容挤扁
|
|
487
|
+
const groups = [...document.querySelectorAll('#preview-settings .tt_tunnelEndpoint')]
|
|
488
|
+
if (groups.length === 2) {
|
|
489
|
+
const a = groups[0].getBoundingClientRect()
|
|
490
|
+
const b = groups[1].getBoundingClientRect()
|
|
491
|
+
if (Math.abs(a.top - b.top) < 4) {
|
|
492
|
+
throw new Error('窄容器下两个端点没有换行(仍在同一行,会被挤扁)')
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
settingsHost.style.width = originalWidth
|
|
497
|
+
await sleep(200)
|
|
498
|
+
scrollTunnelIntoView()
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// 方向切换会重渲表单:DOM 节点可能被替换,重新取一次(别用切换前的引用)
|
|
502
|
+
const localPortInput2 = document.querySelector('#preview-settings [aria-label="本机监听端口"]')
|
|
503
|
+
if (localPortInput2 === null) throw new Error('切回本地后表单没渲染')
|
|
504
|
+
|
|
505
|
+
const body = q('#preview-settings .tt_cardBody')
|
|
506
|
+
if (buttonsIn(body).find((b) => b.textContent === '保存修改') === undefined) throw new Error('编辑态没有「保存修改」')
|
|
507
|
+
if (buttonsIn(body).find((b) => b.textContent === '取消') === undefined) throw new Error('编辑态没有「取消」')
|
|
508
|
+
if (buttonsIn(body).find((b) => b.textContent === '添加隧道') !== undefined) {
|
|
509
|
+
throw new Error('编辑态仍显示「添加隧道」(会误加一条而不是改这条)')
|
|
510
|
+
}
|
|
511
|
+
if (document.querySelectorAll('#preview-settings .tt_sshHostRow[data-editing]').length !== 1) {
|
|
512
|
+
throw new Error('正在编辑的行没有唯一标记(data-editing)')
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// 改端口 → 保存:名字必须按规则**重新派生**(`<bookName>-L<localPort>`)。
|
|
516
|
+
//
|
|
517
|
+
// 注意 fixture 里那条叫 `staging-pg`(手写的假名字,不符合派生规则),
|
|
518
|
+
// bookName 是 `staging-db`——所以编辑后新名字是 `staging-db-L15433`。
|
|
519
|
+
// 真实数据里隧道名一律由规则派生(UI 没有"自定义名字"入口),编辑不会改名;
|
|
520
|
+
// 这里恰好顺带钉住了"名字按规则重算"这个语义。
|
|
521
|
+
//
|
|
522
|
+
// 直接 `input.value = x` 对 React 无效:React 在 input 上装了 value 的原生
|
|
523
|
+
// setter,直接赋值后它读到的还是旧值(受控组件状态不更新)。必须先取 prototype
|
|
524
|
+
// 上的原生 setter 赋值、再派发冒泡的 input 事件。harness 里此前没有"往输入框
|
|
525
|
+
// 打字"的场景,这是第一个(`setReactInput` 供后续场景复用)。
|
|
526
|
+
const setReactInput = (el, value) => {
|
|
527
|
+
const desc = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')
|
|
528
|
+
desc.set.call(el, value)
|
|
529
|
+
el.dispatchEvent(new window.Event('input', { bubbles: true }))
|
|
530
|
+
}
|
|
531
|
+
setReactInput(localPortInput2, '15433')
|
|
532
|
+
await sleep(150)
|
|
533
|
+
buttonsIn(body).find((b) => b.textContent === '保存修改').click()
|
|
534
|
+
await sleep(400)
|
|
535
|
+
|
|
536
|
+
window.__previewAssert = async () => {
|
|
537
|
+
const problems = []
|
|
538
|
+
const names = [...document.querySelectorAll('#preview-settings .tt_sshHostName')].map((el) => el.textContent ?? '')
|
|
539
|
+
if (!names.some((n) => n.startsWith('staging-db-L15433'))) {
|
|
540
|
+
problems.push('改端口后名字没按规则重新派生:' + names.join(' | '))
|
|
541
|
+
}
|
|
542
|
+
if (names.some((n) => n.startsWith('staging-pg'))) {
|
|
543
|
+
problems.push('旧名字仍在(按原始名定位替换失败)')
|
|
544
|
+
}
|
|
545
|
+
// 另一条隧道不能受影响(编辑只动被编辑的那条)
|
|
546
|
+
if (!names.some((n) => n.startsWith('prod-redis'))) {
|
|
547
|
+
problems.push('编辑波及了别的隧道')
|
|
548
|
+
}
|
|
549
|
+
if (buttonsIn(document.querySelector('#preview-settings .tt_cardBody')).find((b) => b.textContent === '添加隧道') === undefined) {
|
|
550
|
+
problems.push('保存后没有退出编辑态')
|
|
551
|
+
}
|
|
552
|
+
return problems.length > 0 ? problems.join(';') : null
|
|
553
|
+
}
|
|
554
|
+
},
|
|
362
555
|
/* 设置卡片:docker 与 tty 并排(同一张 ul 里),核对两家的观感是否一致 */
|
|
363
556
|
async 'settings-docker'() {
|
|
364
557
|
const host = document.createElement('div')
|
|
@@ -397,6 +590,94 @@
|
|
|
397
590
|
ttyDesc: getComputedStyle(q('.tt_cardDescription')).fontSize,
|
|
398
591
|
}
|
|
399
592
|
},
|
|
593
|
+
/*
|
|
594
|
+
* docker 设置卡片:失效的连接簿引用要**看得出来**。
|
|
595
|
+
*
|
|
596
|
+
* 背景:`book` 下拉的候选项来自 ttyBooks,引用的名字若不在其中,下拉会渲染成**空白**
|
|
597
|
+
* (没有匹配的 option)——界面上完全看不出错,直到真去连才报「引用的连接簿条目不存在」。
|
|
598
|
+
* 实测踩过:目标引用 HS-248,而连接簿里只有 HS_248_ADMIN(改名后残留的旧引用)。
|
|
599
|
+
* 这里钉住:失效行有 data-stale 标记 + 下拉里补了显式 option + 给出了提示文案。
|
|
600
|
+
*/
|
|
601
|
+
async 'docker-stale-book'() {
|
|
602
|
+
const host = document.createElement('div')
|
|
603
|
+
host.id = 'preview-docker-settings'
|
|
604
|
+
host.style.cssText = 'position:fixed;inset:24px 24px 24px 260px;overflow:auto;z-index:2000;background:var(--dsw-alias-bg-base);padding:8px;border-radius:16px'
|
|
605
|
+
document.body.appendChild(host)
|
|
606
|
+
const docker = cards.find((c) => c.spec.key === 'docker')
|
|
607
|
+
if (docker === undefined) throw new Error('未注册 docker 设置卡片')
|
|
608
|
+
const root = window.ReactDOM.createRoot(host)
|
|
609
|
+
root.render(window.React.createElement(docker.Component))
|
|
610
|
+
await waitFor(() => q('#preview-docker-settings .dk_settingsCard'), 4000)
|
|
611
|
+
q('#preview-docker-settings .dk_settingsHead').click()
|
|
612
|
+
await waitFor(() => q('#preview-docker-settings .dk_settingsBody'), 4000)
|
|
613
|
+
await sleep(500)
|
|
614
|
+
|
|
615
|
+
/*
|
|
616
|
+
* 「目标名」输入框必须能连续打字。
|
|
617
|
+
*
|
|
618
|
+
* 实测 bug:那一行的 React key 写成 `String(index) + item.name`——key 里含 item.name,
|
|
619
|
+
* 于是每敲一个字符 key 就变一次,React 判定为"新元素"、卸载重建整行 DOM,输入框当场
|
|
620
|
+
* 失焦(表现就是"这个输入框无法聚焦",只能输进去一个字)。修法是 key 只留 index
|
|
621
|
+
* (行是**按位置**编辑的,顺序变化由数组本身负责,名字不该参与身份)。
|
|
622
|
+
*
|
|
623
|
+
* 这里模拟真实输入:聚焦 → 逐个字符派发 input 事件 → 检查焦点是否还在同一个节点上、
|
|
624
|
+
* 且累积的文本完整(重建会让 value 回退/焦点丢失)。
|
|
625
|
+
*/
|
|
626
|
+
const nameInput = document.querySelector('#preview-docker-settings .dk_targetRow .dk_input')
|
|
627
|
+
if (nameInput === null) throw new Error('找不到目标名输入框')
|
|
628
|
+
nameInput.focus()
|
|
629
|
+
if (document.activeElement !== nameInput) throw new Error('目标名输入框无法聚焦')
|
|
630
|
+
const setNative = (el, value) => {
|
|
631
|
+
const desc = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')
|
|
632
|
+
desc.set.call(el, value)
|
|
633
|
+
el.dispatchEvent(new window.Event('input', { bubbles: true }))
|
|
634
|
+
}
|
|
635
|
+
// 逐字符敲 "abc":若 key 含 name,每次都会重建节点 → 焦点丢失
|
|
636
|
+
const typed = 'Xy9'
|
|
637
|
+
let accumulated = ''
|
|
638
|
+
for (const ch of typed) {
|
|
639
|
+
accumulated += ch
|
|
640
|
+
setNative(nameInput, accumulated)
|
|
641
|
+
await sleep(60)
|
|
642
|
+
if (document.activeElement !== nameInput) {
|
|
643
|
+
throw new Error('敲入第 ' + String(accumulated.length) + ' 个字符后输入框失焦(key 含可变字段导致 DOM 重建)')
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (nameInput.value !== typed) {
|
|
647
|
+
throw new Error('输入未累积完整(期望 ' + typed + ',实得 ' + String(nameInput.value) + ')')
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
window.__previewAssert = async () => {
|
|
651
|
+
const problems = []
|
|
652
|
+
const rows = [...document.querySelectorAll('#preview-docker-settings .dk_targetRow')]
|
|
653
|
+
if (rows.length === 0) return '目标行没渲染出来'
|
|
654
|
+
const staleRows = rows.filter((r) => r.hasAttribute('data-stale'))
|
|
655
|
+
if (staleRows.length !== 1) {
|
|
656
|
+
problems.push('应恰好有 1 行被标为失效引用,实得 ' + String(staleRows.length))
|
|
657
|
+
}
|
|
658
|
+
// 失效那一行的下拉必须**显示得出那个失效的名字**(否则是空白,看不出问题)。
|
|
659
|
+
// 注意一行里有**两个** select:第 1 个是「本机 / SSH 主机」的 kind,第 2 个才是
|
|
660
|
+
// 连接簿下拉——按 title 定位它(staleBookRef 命中时会给它加提示 title)。
|
|
661
|
+
const staleSelect = staleRows[0]?.querySelector('select[title]')
|
|
662
|
+
if (staleSelect === undefined || staleSelect === null) {
|
|
663
|
+
problems.push('失效引用那行的连接簿下拉没有提示 title')
|
|
664
|
+
} else {
|
|
665
|
+
const text = staleSelect.options[staleSelect.selectedIndex]?.textContent ?? ''
|
|
666
|
+
if (!text.includes('已改名的条目')) problems.push('失效引用的名字没在下拉里显式显示:' + text)
|
|
667
|
+
if (!text.includes('不存在')) problems.push('失效引用没有标注「不存在」:' + text)
|
|
668
|
+
}
|
|
669
|
+
// 提示文案要给出可执行的下一步
|
|
670
|
+
const warn = staleRows[0]?.querySelector('.dk_hintWarn')
|
|
671
|
+
if (warn === null || warn === undefined) problems.push('失效引用那行没有警示文案')
|
|
672
|
+
// 正常引用(prod-web-01)不能被误标
|
|
673
|
+
const healthy = rows.find((r) => {
|
|
674
|
+
const sel = r.querySelector('select[title], select')
|
|
675
|
+
return sel !== null && [...sel.options].some((o) => o.value === 'prod-web-01' && o.selected)
|
|
676
|
+
})
|
|
677
|
+
if (healthy !== undefined && healthy.hasAttribute('data-stale')) problems.push('正常引用被误标为失效')
|
|
678
|
+
return problems.length > 0 ? problems.join(';') : null
|
|
679
|
+
}
|
|
680
|
+
},
|
|
400
681
|
/* SFTP 单窗体(0.16.0 起挂进右侧挂载位,终端保持可见) */
|
|
401
682
|
async sftp() {
|
|
402
683
|
await openPanel()
|
|
@@ -563,6 +844,47 @@
|
|
|
563
844
|
await waitFor(() => q('.tt_tunnelPop'))
|
|
564
845
|
await sleep(300)
|
|
565
846
|
},
|
|
847
|
+
/*
|
|
848
|
+
* 连接栏「⋯」更多(没有启用隧道的连接)。
|
|
849
|
+
*
|
|
850
|
+
* 这条路径此前**完全不可见**:隧道入口只在 `count > 0` 时出现,没配隧道的连接在界面上
|
|
851
|
+
* 没有任何线索能发现"这里可以配端口转发"。现在没隧道时收进「⋯」——既有发现路径、
|
|
852
|
+
* 又不占常驻宽度(连接栏宽度优先给标签条)。
|
|
853
|
+
*/
|
|
854
|
+
async 'connbar-more'() {
|
|
855
|
+
await openPanel()
|
|
856
|
+
// bare-host 在 fixture 里刻意没有任何隧道
|
|
857
|
+
await clickAdd()
|
|
858
|
+
await clickMenuItem('bare-host')
|
|
859
|
+
await waitFor(() => tabs().length === 2)
|
|
860
|
+
await sleep(300)
|
|
861
|
+
|
|
862
|
+
const acts = qa('.tt_connAct')
|
|
863
|
+
// 没有隧道 ⇒ 不该出现常驻「隧道 N」按钮
|
|
864
|
+
if (acts.some((b) => b.textContent.includes('隧道'))) {
|
|
865
|
+
throw new Error('没有隧道的连接却出现了常驻「隧道」按钮')
|
|
866
|
+
}
|
|
867
|
+
const more = acts.find((b) => b.textContent.includes('更多'))
|
|
868
|
+
if (more === undefined) throw new Error('没有隧道的连接缺少「⋯ 更多」入口')
|
|
869
|
+
more.click()
|
|
870
|
+
await waitFor(() => q('.tt_connMore'))
|
|
871
|
+
await sleep(250)
|
|
872
|
+
|
|
873
|
+
// 闭环:菜单项必须真的可点、并给出可执行的信息(该连接没有启用隧道 + 去哪配)
|
|
874
|
+
const item = qa('.tt_connMore .tt_addMenuItem').find((el) => el.textContent.includes('端口转发'))
|
|
875
|
+
if (item === undefined) throw new Error('「⋯」菜单里没有端口转发项')
|
|
876
|
+
item.click()
|
|
877
|
+
await waitFor(() => q('.tt_tunnelPop'))
|
|
878
|
+
await sleep(300)
|
|
879
|
+
window.__previewAssert = async () => {
|
|
880
|
+
const pop = q('.tt_tunnelPop')
|
|
881
|
+
if (pop === null) return '点击菜单项后没有打开隧道弹层'
|
|
882
|
+
const text = pop.textContent ?? ''
|
|
883
|
+
if (!text.includes('暂无启用')) return '弹层没有说明「该连接暂无启用的隧道」:' + text
|
|
884
|
+
if (!text.includes('插件配置') && !text.includes('设置')) return '弹层没有告诉用户去哪配置'
|
|
885
|
+
return null
|
|
886
|
+
}
|
|
887
|
+
},
|
|
566
888
|
/* 搜索框展开 */
|
|
567
889
|
async search() {
|
|
568
890
|
await openPanel()
|
|
@@ -45,6 +45,9 @@
|
|
|
45
45
|
sshHosts: [
|
|
46
46
|
{ name: 'prod-web-01', host: '198.51.100.20', port: 22, username: 'deploy', auth: 'key', keyPath: '~/.ssh/id_ed25519', passphrase: '', password: '', agentForward: true, persist: false },
|
|
47
47
|
{ name: 'staging-db', host: 'db.staging.internal', port: 2222, username: 'root', auth: 'password', keyPath: '', passphrase: '', password: 'env:STAGING_DB_PASSWORD', agentForward: false, persist: false },
|
|
48
|
+
// 刻意**没有**任何隧道:验证「没配隧道时收进 ⋯ 更多」这条路径
|
|
49
|
+
// (上面两条都有隧道,走的是常驻「隧道 N」按钮)
|
|
50
|
+
{ name: 'bare-host', host: 'bare.internal', port: 22, username: 'ops', auth: 'agent', keyPath: '', passphrase: '', password: '', agentForward: false, persist: false },
|
|
48
51
|
],
|
|
49
52
|
hostKeys: [
|
|
50
53
|
{ host: '198.51.100.20', port: 22, fingerprint: 'SHA256:9xQ2mVr7Kp3sTf1cZbLd8uYwAe4nHjR6gPoXkMvQ2Bc' },
|
|
@@ -131,6 +134,9 @@
|
|
|
131
134
|
{ name: '目标1', kind: 'ssh', book: '', host: '192.0.2.10', port: 22, username: 'root', auth: 'agent', agentForward: false },
|
|
132
135
|
{ name: '目标2', kind: 'local', book: '', host: '', port: 22, username: '', auth: 'agent', agentForward: false },
|
|
133
136
|
{ name: 'prod-web-01', kind: 'ssh', book: 'prod-web-01', host: '', port: 22, username: '', auth: 'agent', agentForward: false },
|
|
137
|
+
// 刻意引用一个**不在 ttyBooks 里**的条目名:验证设置卡片能把"失效引用"标出来
|
|
138
|
+
// (不标的话那个 book 下拉渲染成空白,看不出错,要等真去连才报)
|
|
139
|
+
{ name: '陈旧引用', kind: 'ssh', book: '已改名的条目', host: '', port: 22, username: '', auth: 'agent', agentForward: false },
|
|
134
140
|
],
|
|
135
141
|
}
|
|
136
142
|
window.__PREVIEW_DOCKER_CONTAINERS = [
|
|
@@ -307,7 +313,9 @@
|
|
|
307
313
|
} })
|
|
308
314
|
}
|
|
309
315
|
push()
|
|
310
|
-
|
|
316
|
+
// 推送间隔可调(stats-slow 场景用 4s 复现「宿主采样慢」:D50 里那台 macOS
|
|
317
|
+
// 的 netstat -ib 挂 3s、帧间隔被拉到 4s,旧的 3s 陈旧窗口于是每 4 秒闪一次)
|
|
318
|
+
this._statsTimer = setInterval(push, Number(window.__PREVIEW_STATS_INTERVAL_MS) || 1000)
|
|
311
319
|
return
|
|
312
320
|
}
|
|
313
321
|
if (msg.t === 'statsOff') {
|