@hyzyn/dsh-tty 0.18.2 → 0.19.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 +85 -34
- package/README.md +65 -29
- package/client.js +51 -37
- package/lib/index.d.ts +367 -1
- package/lib/index.js +420 -138
- package/lib/index.js.map +1 -1
- package/lib/known-hosts.d.ts +7 -1
- package/lib/known-hosts.js +26 -12
- package/lib/known-hosts.js.map +1 -1
- package/lib/probe.d.ts +2 -2
- package/lib/probe.js +8 -36
- package/lib/probe.js.map +1 -1
- package/lib/sftp.d.ts +63 -10
- package/lib/sftp.js +244 -44
- package/lib/sftp.js.map +1 -1
- package/lib/shell-integration.js +33 -13
- package/lib/shell-integration.js.map +1 -1
- package/lib/ssh.d.ts +21 -7
- package/lib/ssh.js +114 -19
- package/lib/ssh.js.map +1 -1
- package/lib/tunnels.js +67 -13
- package/lib/tunnels.js.map +1 -1
- package/package.json +7 -2
- package/scripts/build-client.mjs +30 -0
- package/scripts/integration.mjs +1691 -0
- package/scripts/lib/test-sshd.mjs +314 -0
- package/scripts/live.mjs +68 -0
- package/scripts/preview/harness.html +75 -0
- package/scripts/preview/harness.js +875 -0
- package/scripts/preview/mock-host.js +365 -0
- package/scripts/preview.mjs +502 -0
- package/scripts/probe-route-smoke.mjs +111 -0
- package/scripts/probe-smoke.mjs +143 -0
- package/scripts/probe.mjs +240 -0
- package/scripts/sftplimits-smoke.mjs +92 -0
- package/scripts/ssh-smoke.mjs +593 -0
- package/scripts/tui-smoke.mjs +112 -0
- package/scripts/windows-smoke.mjs +179 -0
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/**
|
|
3
|
+
* dsh-tty 预览夹具:加载 client.js → 以假 ctx 挂载 → 按 ?scenario= 驱动界面到
|
|
4
|
+
* 指定状态,供 scripts/preview.mjs 截图。仅供开发走查,不随 npm 包发布。
|
|
5
|
+
*/
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict'
|
|
8
|
+
|
|
9
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
10
|
+
const waitFor = async (fn, timeout = 3000) => {
|
|
11
|
+
const start = Date.now()
|
|
12
|
+
for (;;) {
|
|
13
|
+
let value
|
|
14
|
+
try {
|
|
15
|
+
value = fn()
|
|
16
|
+
} catch {
|
|
17
|
+
value = null
|
|
18
|
+
}
|
|
19
|
+
if (value) return value
|
|
20
|
+
if (Date.now() - start > timeout) throw new Error('waitFor timeout')
|
|
21
|
+
await sleep(25)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const q = (sel) => document.querySelector(sel)
|
|
25
|
+
const qa = (sel) => [...document.querySelectorAll(sel)]
|
|
26
|
+
|
|
27
|
+
/* ---------- 挂载插件 ---------- */
|
|
28
|
+
// 场景之间共用同一个页面 target,必须先清掉上一个场景的标签持久化
|
|
29
|
+
try {
|
|
30
|
+
sessionStorage.clear()
|
|
31
|
+
localStorage.clear()
|
|
32
|
+
} catch {
|
|
33
|
+
/* file:// 下存储可能不可用 */
|
|
34
|
+
}
|
|
35
|
+
const mod = window.__ttyModule
|
|
36
|
+
if (!mod) throw new Error('client.js 未注册到 __ModuleLoader__')
|
|
37
|
+
const requireShim = (name) => {
|
|
38
|
+
if (name === 'react') return window.React
|
|
39
|
+
if (name === 'react/jsx-runtime') return window.__jsxRuntime
|
|
40
|
+
// dsh-docker 的客户端用 createRoot 挂面板(React 18 的 react-dom/client 入口)
|
|
41
|
+
if (name === 'react-dom') return window.ReactDOM
|
|
42
|
+
if (name === 'react-dom/client') return { createRoot: window.ReactDOM.createRoot, hydrateRoot: window.ReactDOM.hydrateRoot }
|
|
43
|
+
throw new Error('预览夹具未提供模块:' + name)
|
|
44
|
+
}
|
|
45
|
+
const exports = mod.factory(requireShim)
|
|
46
|
+
const cards = []
|
|
47
|
+
/** 客户端服务表:tty 提供 ttyConnbar,夹具里也允许注册消费方(见 connbarActions)。 */
|
|
48
|
+
const services = new Map()
|
|
49
|
+
const ctx = {
|
|
50
|
+
sessions: {
|
|
51
|
+
list: {
|
|
52
|
+
// 按**真实宿主**的形状桩(DSH 0.1.6):会话列表快照没有 current,选中项靠
|
|
53
|
+
// retainedBy.mainView 表达——夹具要是照旧塞个 current,就又把新读法的 bug 遮住了
|
|
54
|
+
getSnapshot: () => ({
|
|
55
|
+
ids: ['s1'],
|
|
56
|
+
byId: { s1: { id: 's1', cwd: window.__PREVIEW_CWD || '/home/user/project', retainedBy: { mainView: 1 } } },
|
|
57
|
+
phase: 'ready',
|
|
58
|
+
subagentsByParent: {},
|
|
59
|
+
jobsBySession: {},
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
slots: {
|
|
64
|
+
inject: (_name, cb) => cb(),
|
|
65
|
+
register: (spec, Component) => {
|
|
66
|
+
cards.push({ spec, Component })
|
|
67
|
+
return () => {}
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
// cordis 服务面(tty 客户端 0.13.0 起用 ctx.provide 暴露连接栏注册点)
|
|
71
|
+
provide: (name, value) => {
|
|
72
|
+
services.set(name, value)
|
|
73
|
+
return () => services.delete(name)
|
|
74
|
+
},
|
|
75
|
+
get: (name) => services.get(name),
|
|
76
|
+
inject: (names, cb) => {
|
|
77
|
+
// 与真实 cordis 一致:依赖里有任何一个服务不存在时**不触发**回调,而不是塞一堆
|
|
78
|
+
// undefined 进去。塞 undefined 的后果是消费方(如 dsh-docker 的 sidebarRight 那一支)
|
|
79
|
+
// 在回调里直接 `scope.foo.register` 炸掉——一个可选依赖就能让整个预览场景全挂。
|
|
80
|
+
if (names.some((name) => !services.has(name))) return () => {}
|
|
81
|
+
const scope = {}
|
|
82
|
+
for (const name of names) scope[name] = services.get(name)
|
|
83
|
+
cb(scope)
|
|
84
|
+
return () => {}
|
|
85
|
+
},
|
|
86
|
+
effect: (cb) => {
|
|
87
|
+
const dispose = cb()
|
|
88
|
+
return () => {
|
|
89
|
+
if (typeof dispose === 'function') dispose()
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
reflect: {
|
|
93
|
+
provide: (name, value) => {
|
|
94
|
+
services.set(name, value)
|
|
95
|
+
return () => services.delete(name)
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
// 宿主会话服务也进服务表:docker 那一半是经 ctx.inject(['sessions']) 取的(夹具里
|
|
100
|
+
// ctx.sessions 是直接属性、不走服务表,这里补一份,免得那条 inject 永远等不到依赖)
|
|
101
|
+
services.set('sessions', ctx.sessions)
|
|
102
|
+
exports.apply(ctx)
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* docker 插件(可选):与 tty 共用同一个 ctx——tty 已经 ctx.provide 了 ttyConnbar /
|
|
106
|
+
* ttyTerminal,所以 docker 的 ctx.inject 能拿到真实服务。这就是「ttyTerminal.mount
|
|
107
|
+
* 就地嵌入」的端到端联调场景(不是靠 mock 服务自欺)。
|
|
108
|
+
*/
|
|
109
|
+
const dockerModule = window.__modules.get('@hyzyn/dsh-docker')
|
|
110
|
+
if (dockerModule) {
|
|
111
|
+
const dockerExports = dockerModule.factory(requireShim)
|
|
112
|
+
dockerExports.apply(ctx)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ---------- 交互助手 ---------- */
|
|
116
|
+
const entry = () => q('[data-dsh-tty-entry]')
|
|
117
|
+
const modal = () => q('.tt_modal')
|
|
118
|
+
const tabs = () => qa('.tt_tab')
|
|
119
|
+
const activeTabEl = () => q('.tt_tab[data-active]')
|
|
120
|
+
const sidOf = (el) => el && el.dataset ? el.dataset.sid : undefined
|
|
121
|
+
const socket = () => window.__mockSockets[window.__mockSockets.length - 1]
|
|
122
|
+
const emit = (msg) => {
|
|
123
|
+
const s = socket()
|
|
124
|
+
if (s) s._deliver(msg)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const openPanel = async () => {
|
|
128
|
+
await waitFor(() => entry())
|
|
129
|
+
entry().click()
|
|
130
|
+
await waitFor(() => modal())
|
|
131
|
+
await waitFor(() => tabs().length > 0, 4000)
|
|
132
|
+
await sleep(120)
|
|
133
|
+
}
|
|
134
|
+
const clickAdd = async () => {
|
|
135
|
+
q('.tt_tabAdd').click()
|
|
136
|
+
await waitFor(() => q('.tt_addMenu'))
|
|
137
|
+
await sleep(80)
|
|
138
|
+
}
|
|
139
|
+
const clickMenuItem = async (label) => {
|
|
140
|
+
const item = await waitFor(() => qa('.tt_addMenuItem').find((el) => el.textContent.includes(label)))
|
|
141
|
+
item.click()
|
|
142
|
+
await sleep(200)
|
|
143
|
+
return item
|
|
144
|
+
}
|
|
145
|
+
/** SFTP 落点断言:卡片在挂载位里(docked)还是退回了居中对话框(modal)。 */
|
|
146
|
+
const sftpSurfaceAssert = () => ({
|
|
147
|
+
docked: q('.tt_dockPaneBody > .tt_sftpCard') !== null || q('.tt_dockPaneBody > .tt_sftpDualCard') !== null,
|
|
148
|
+
modal: q('.tt_sshBackdrop > .tt_sftpCard') !== null || q('.tt_sshBackdrop > .tt_sftpDualCard') !== null,
|
|
149
|
+
cardTitleRowHidden: (() => {
|
|
150
|
+
const row = q('.tt_dockPaneBody .tt_sftpTitleRow')
|
|
151
|
+
return row === null ? null : getComputedStyle(row).display
|
|
152
|
+
})(),
|
|
153
|
+
rows: qa('.tt_sftpRow').length,
|
|
154
|
+
termWidth: Math.round((q('.tt_body') || { getBoundingClientRect: () => ({ width: 0 }) }).getBoundingClientRect().width),
|
|
155
|
+
// 卡片必须完整落在挂载位里(border-box 回归):右边/底边不溢出,工具栏最右按钮不被裁
|
|
156
|
+
fitsDock: (() => {
|
|
157
|
+
const body = q('.tt_dockPaneBody')
|
|
158
|
+
if (body === null) return null
|
|
159
|
+
const card = q('.tt_dockPaneBody > .tt_sftpCard, .tt_dockPaneBody > .tt_sftpDualCard')
|
|
160
|
+
if (card === null) return null
|
|
161
|
+
const b = body.getBoundingClientRect()
|
|
162
|
+
const c = card.getBoundingClientRect()
|
|
163
|
+
const bar = q('.tt_dockPaneBody .tt_sftpBar')
|
|
164
|
+
return {
|
|
165
|
+
overflowRight: Math.round(c.right - b.right),
|
|
166
|
+
overflowBottom: Math.round(c.bottom - b.bottom),
|
|
167
|
+
actionsFit: bar === null ? null : Math.round(bar.getBoundingClientRect().right) <= Math.round(b.right),
|
|
168
|
+
}
|
|
169
|
+
})(),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
/** 终端里最后一行非空文本(xterm 会把光标所在行渲染成空 div)。 */
|
|
173
|
+
const lastTermRowText = () => {
|
|
174
|
+
const rows = qa('.tt_term .xterm-rows > div')
|
|
175
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
176
|
+
const text = rows[i].textContent.trim()
|
|
177
|
+
if (text !== '') return text
|
|
178
|
+
}
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 终端里第一行非空文本(判断视口有没有跳)。 */
|
|
183
|
+
const firstTermRowText = () => {
|
|
184
|
+
const rows = qa('.tt_term .xterm-rows > div')
|
|
185
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
186
|
+
const text = rows[i].textContent.trim()
|
|
187
|
+
if (text !== '') return text
|
|
188
|
+
}
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const feed = (text) => {
|
|
193
|
+
const el = activeTabEl()
|
|
194
|
+
if (el) emit({ t: 'data', sid: sidOf(el), d: text })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const SCENARIOS = {
|
|
198
|
+
/* 单标签本地终端(含服务器状态条:订阅链路必须真的建立起来) */
|
|
199
|
+
async local() {
|
|
200
|
+
await openPanel()
|
|
201
|
+
await waitFor(() => q('.tt_statsBar') !== null)
|
|
202
|
+
// 状态条要等 1s 内的 stats 帧才显示;这里等到它带着内容出现
|
|
203
|
+
await waitFor(() => {
|
|
204
|
+
const bar = q('.tt_statsBar')
|
|
205
|
+
return bar !== null && bar.hidden !== true && /CPU/.test(bar.textContent)
|
|
206
|
+
}, 4000)
|
|
207
|
+
await sleep(200)
|
|
208
|
+
window.__previewAssert = async () => {
|
|
209
|
+
const bar = q('.tt_statsBar')
|
|
210
|
+
if (bar === null) return '状态条 DOM 缺失'
|
|
211
|
+
if (bar.hidden === true) return '状态条未显示(stats 订阅链路可能断开)'
|
|
212
|
+
const text = bar.textContent
|
|
213
|
+
if (!/CPU/.test(text) || !/内存/.test(text) || !/网络/.test(text)) return '状态条内容不完整:' + text
|
|
214
|
+
/*
|
|
215
|
+
* 订阅时序:客户端**故意**在 spawn 之前就发一次 statsOn(switchTab 早于 spawnTab),
|
|
216
|
+
* 宿主此时还没登记这个 sid,会按「未知 sid」忽略——这是设计里的正常现象,所以
|
|
217
|
+
* 断言不能要求「从没发过未知订阅」。要钉的是 ready 之后**重新对齐**过:最后一次
|
|
218
|
+
* 订阅必须被接受(否则状态条该出不来),且早期那次不该被重复轰炸。
|
|
219
|
+
*/
|
|
220
|
+
const logged = window.__mockLog || []
|
|
221
|
+
const subscriptions = logged.filter((entry) => entry === 'in:statsOn' || entry === 'in:statsOn(unknown)')
|
|
222
|
+
if (subscriptions[subscriptions.length - 1] === 'in:statsOn(unknown)') return '最后一次 statsOn 仍被宿主按未知 sid 忽略(ready 后没有重新对齐)'
|
|
223
|
+
const ignored = logged.filter((entry) => entry === 'in:statsOn(unknown)').length
|
|
224
|
+
if (ignored > 1) return 'spawn 前的 statsOn 重复发送(' + String(ignored) + ' 次未对齐订阅)'
|
|
225
|
+
return null
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
/* 服务器状态条关闭(statsEnabled=false):整条收起,WS 也不该收到订阅 */
|
|
229
|
+
async 'stats-off'() {
|
|
230
|
+
// 必须在开面板(拉配置)之前改:客户端按 config 决定订阅与显隐
|
|
231
|
+
window.__PREVIEW_CONFIG.statsEnabled = false
|
|
232
|
+
await openPanel()
|
|
233
|
+
await waitFor(() => tabs().length === 1)
|
|
234
|
+
await sleep(400)
|
|
235
|
+
window.__previewAssert = async () => {
|
|
236
|
+
const bar = q('.tt_statsBar')
|
|
237
|
+
if (bar === null) return '服务器状态条 DOM 缺失'
|
|
238
|
+
if (bar.hidden !== true) return 'statsEnabled=false 时状态条仍然可见'
|
|
239
|
+
// 隐藏必须是「真的不占位」:author 的 display:flex 会压过 UA 的
|
|
240
|
+
// [hidden]{display:none},一旦仍占 24px 就会盖住终端第一行(界面变形)
|
|
241
|
+
const height = bar.getBoundingClientRect().height
|
|
242
|
+
if (height > 0) return '隐藏状态下状态条仍占位(height=' + String(height) + ')'
|
|
243
|
+
if (document.querySelector('.tt_body[data-stats]') !== null) return '隐藏状态下仍给终端加了状态条偏移'
|
|
244
|
+
const logged = window.__mockLog || []
|
|
245
|
+
if (logged.some((entry) => entry === 'in:statsOn')) return '关闭状态下仍发送了 statsOn'
|
|
246
|
+
return null
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
/* 坏数据兜底:宿主发来的 stats 帧是垃圾(字符串/数组/null/越界值/缺字段),
|
|
250
|
+
面板必须既不抛异常也不出现非法渲染——最多整条隐藏 */
|
|
251
|
+
async 'stats-broken'() {
|
|
252
|
+
window.__PREVIEW_STATS_PAYLOAD = [
|
|
253
|
+
'not-an-object',
|
|
254
|
+
[1, 2, 3],
|
|
255
|
+
null,
|
|
256
|
+
{ cpuPct: 'x', cores: -5, memUsed: 1e30, memTotal: 1e30, uptimeSec: Number.MAX_SAFE_INTEGER, tcpConns: -1, tempC: {} },
|
|
257
|
+
{},
|
|
258
|
+
{ cpuPct: 12.5, cores: 8, memTotal: 17179869184, memUsed: 9663676416, memPct: 56, uptimeSec: 3600, tcpConns: 12 },
|
|
259
|
+
]
|
|
260
|
+
await openPanel()
|
|
261
|
+
await waitFor(() => tabs().length === 1)
|
|
262
|
+
await sleep(1800) // 至少走两轮坏帧
|
|
263
|
+
window.__previewAssert = async () => {
|
|
264
|
+
const bar = q('.tt_statsBar')
|
|
265
|
+
if (bar === null) return '状态条 DOM 缺失'
|
|
266
|
+
const text = bar.textContent || ''
|
|
267
|
+
if (/-5|NaN|undefined|Infinity|e\+30|x%/.test(text)) return '坏数据被渲染出来了:' + text
|
|
268
|
+
const height = bar.getBoundingClientRect().height
|
|
269
|
+
if (bar.hidden === true && height > 0) return '隐藏态仍占位(height=' + String(height) + ')'
|
|
270
|
+
if (bar.hidden !== true && height <= 0) return '显示态却没有高度'
|
|
271
|
+
return null
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
/* 多标签(含 SSH 标签 → 连接栏;开启 tmux 持久化以展示持久徽标) */
|
|
275
|
+
async multi() {
|
|
276
|
+
window.__PREVIEW_CONFIG.persistence = 'tmux'
|
|
277
|
+
await openPanel()
|
|
278
|
+
await clickAdd()
|
|
279
|
+
await clickMenuItem('prod-web-01')
|
|
280
|
+
await waitFor(() => tabs().length === 2)
|
|
281
|
+
await clickAdd()
|
|
282
|
+
await clickMenuItem('staging-db')
|
|
283
|
+
await waitFor(() => tabs().length === 3)
|
|
284
|
+
await clickAdd()
|
|
285
|
+
await clickMenuItem('本地终端')
|
|
286
|
+
await waitFor(() => tabs().length === 4)
|
|
287
|
+
tabs()[1].click()
|
|
288
|
+
await sleep(250)
|
|
289
|
+
// 切到另一个标签后状态条要跟着走:显示新标签的数据,且偏移与显隐一致
|
|
290
|
+
// (跨标签/跨平台混用时,这里出问题就会表现为终端被顶歪)
|
|
291
|
+
await waitFor(() => {
|
|
292
|
+
const bar = q('.tt_statsBar')
|
|
293
|
+
return bar !== null && bar.hidden !== true && /CPU/.test(bar.textContent)
|
|
294
|
+
}, 4000)
|
|
295
|
+
window.__previewAssert = async () => {
|
|
296
|
+
const bar = q('.tt_statsBar')
|
|
297
|
+
const body = q('.tt_body')
|
|
298
|
+
if (bar === null || body === null) return '状态条/终端容器缺失'
|
|
299
|
+
const shifted = body.dataset.stats !== undefined
|
|
300
|
+
const visible = bar.hidden !== true && bar.getBoundingClientRect().height > 0
|
|
301
|
+
if (visible !== shifted) return '显隐与终端偏移不一致(visible=' + String(visible) + ' shifted=' + String(shifted) + ')'
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
/* 「+」菜单 */
|
|
306
|
+
async menu() {
|
|
307
|
+
await openPanel()
|
|
308
|
+
await clickAdd()
|
|
309
|
+
},
|
|
310
|
+
/* SSH 连接对话框(新建) */
|
|
311
|
+
async ssh() {
|
|
312
|
+
await openPanel()
|
|
313
|
+
await clickAdd()
|
|
314
|
+
await clickMenuItem('SSH 连接…')
|
|
315
|
+
await waitFor(() => q('.tt_sshCard'))
|
|
316
|
+
await sleep(150)
|
|
317
|
+
},
|
|
318
|
+
/* SSH 连接对话框:跑一次「试连」并展示结果(验证状态带不撑动布局) */
|
|
319
|
+
async 'ssh-probe'() {
|
|
320
|
+
await openPanel()
|
|
321
|
+
await clickAdd()
|
|
322
|
+
await clickMenuItem('SSH 连接…')
|
|
323
|
+
await waitFor(() => q('.tt_sshCard'))
|
|
324
|
+
const inputs = qa('.tt_sshCard input')
|
|
325
|
+
inputs[0].value = '192.0.2.10'
|
|
326
|
+
inputs[1].value = '22'
|
|
327
|
+
inputs[2].value = 'root'
|
|
328
|
+
const probeBtn = qa('.tt_sshActions .tt_toolBtn').find((b) => b.textContent.includes('试连'))
|
|
329
|
+
probeBtn.click()
|
|
330
|
+
const resultEl = () => q('.tt_sshProbeResult')
|
|
331
|
+
await waitFor(() => resultEl() && resultEl().textContent !== '' && !resultEl().textContent.includes('测试中'))
|
|
332
|
+
await sleep(250)
|
|
333
|
+
},
|
|
334
|
+
/* SSH 连接对话框(编辑连接簿条目,含 env 选择器) */
|
|
335
|
+
async 'ssh-edit'() {
|
|
336
|
+
await openPanel()
|
|
337
|
+
await clickAdd()
|
|
338
|
+
const rows = await waitFor(() => {
|
|
339
|
+
const r = qa('.tt_addMenuRow')
|
|
340
|
+
return r.length > 0 ? r : null
|
|
341
|
+
})
|
|
342
|
+
const editBtn = rows[1].querySelector('.tt_addMenuEdit[title="编辑连接"]')
|
|
343
|
+
editBtn.click()
|
|
344
|
+
await waitFor(() => q('.tt_sshCard'))
|
|
345
|
+
await sleep(200)
|
|
346
|
+
},
|
|
347
|
+
/* 设置卡片(展开) */
|
|
348
|
+
async settings() {
|
|
349
|
+
const host = document.createElement('div')
|
|
350
|
+
host.id = 'preview-settings'
|
|
351
|
+
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'
|
|
352
|
+
document.body.appendChild(host)
|
|
353
|
+
const card = cards[0]
|
|
354
|
+
const root = window.ReactDOM.createRoot(host)
|
|
355
|
+
root.render(window.React.createElement(card.Component))
|
|
356
|
+
await waitFor(() => q('#preview-settings .tt_card'))
|
|
357
|
+
await sleep(80)
|
|
358
|
+
q('#preview-settings .tt_cardHeader').click()
|
|
359
|
+
await waitFor(() => q('#preview-settings .tt_cardBody'))
|
|
360
|
+
await sleep(400)
|
|
361
|
+
},
|
|
362
|
+
/* 设置卡片:docker 与 tty 并排(同一张 ul 里),核对两家的观感是否一致 */
|
|
363
|
+
async 'settings-docker'() {
|
|
364
|
+
const host = document.createElement('div')
|
|
365
|
+
host.id = 'preview-settings'
|
|
366
|
+
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'
|
|
367
|
+
document.body.appendChild(host)
|
|
368
|
+
const list = document.createElement('ul')
|
|
369
|
+
list.style.cssText = 'display:flex;flex-direction:column;gap:12px;margin:0;padding:0'
|
|
370
|
+
host.appendChild(list)
|
|
371
|
+
const docker = cards.find((c) => c.spec.key === 'docker')
|
|
372
|
+
const tty = cards.find((c) => c.spec.key === 'tty')
|
|
373
|
+
if (docker === undefined) throw new Error('未注册 docker 设置卡片')
|
|
374
|
+
const root = window.ReactDOM.createRoot(list)
|
|
375
|
+
root.render(window.React.createElement(window.React.Fragment, null,
|
|
376
|
+
window.React.createElement(tty.Component),
|
|
377
|
+
window.React.createElement(docker.Component),
|
|
378
|
+
))
|
|
379
|
+
await waitFor(() => q('.dk_settingsCard'), 3000)
|
|
380
|
+
q('.tt_cardHeader').click()
|
|
381
|
+
await sleep(300)
|
|
382
|
+
q('.dk_settingsHead').click()
|
|
383
|
+
await waitFor(() => q('.dk_settingsBody'), 4000)
|
|
384
|
+
await sleep(600)
|
|
385
|
+
const box = (sel) => {
|
|
386
|
+
const el = q(sel)
|
|
387
|
+
if (el === null) return null
|
|
388
|
+
const cs = getComputedStyle(el)
|
|
389
|
+
return { radius: cs.borderRadius, border: cs.borderColor, bg: cs.backgroundColor, pad: cs.padding, w: Math.round(el.getBoundingClientRect().width) }
|
|
390
|
+
}
|
|
391
|
+
window.__previewAssert = {
|
|
392
|
+
dockerCard: box('.dk_settingsCard'),
|
|
393
|
+
ttyCard: box('.tt_card'),
|
|
394
|
+
dockerName: getComputedStyle(q('.dk_settingsName')).fontSize + '/' + getComputedStyle(q('.dk_settingsName')).fontWeight,
|
|
395
|
+
ttyName: getComputedStyle(q('.tt_cardName')).fontSize + '/' + getComputedStyle(q('.tt_cardName')).fontWeight,
|
|
396
|
+
dockerDesc: getComputedStyle(q('.dk_settingsDesc')).fontSize,
|
|
397
|
+
ttyDesc: getComputedStyle(q('.tt_cardDescription')).fontSize,
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
/* SFTP 单窗体(0.16.0 起挂进右侧挂载位,终端保持可见) */
|
|
401
|
+
async sftp() {
|
|
402
|
+
await openPanel()
|
|
403
|
+
await clickAdd()
|
|
404
|
+
await clickMenuItem('prod-web-01')
|
|
405
|
+
await waitFor(() => tabs().length === 2)
|
|
406
|
+
await sleep(200)
|
|
407
|
+
const sftpBtn = qa('.tt_connAct').find((b) => b.textContent.includes('SFTP'))
|
|
408
|
+
sftpBtn.click()
|
|
409
|
+
await waitFor(() => q('.tt_sftpCard'))
|
|
410
|
+
await waitFor(() => qa('.tt_sftpRow').length > 1)
|
|
411
|
+
await sleep(250)
|
|
412
|
+
window.__previewAssert = sftpSurfaceAssert()
|
|
413
|
+
},
|
|
414
|
+
/* SFTP 双栏(同样挂进挂载位,初始给到面板宽度的 62%) */
|
|
415
|
+
async 'sftp-dual'() {
|
|
416
|
+
// 双栏风格由 config.sftpStyle 决定,必须在开面板(拉配置)之前改
|
|
417
|
+
window.__PREVIEW_CONFIG.sftpStyle = 'dual'
|
|
418
|
+
await openPanel()
|
|
419
|
+
await clickAdd()
|
|
420
|
+
await clickMenuItem('prod-web-01')
|
|
421
|
+
await waitFor(() => tabs().length === 2)
|
|
422
|
+
await sleep(200)
|
|
423
|
+
const sftpBtn = qa('.tt_connAct').find((b) => b.textContent.includes('SFTP'))
|
|
424
|
+
sftpBtn.click()
|
|
425
|
+
await waitFor(() => q('.tt_sftpDualCard'))
|
|
426
|
+
await waitFor(() => qa('.tt_sftpRow').length > 2)
|
|
427
|
+
await sleep(250)
|
|
428
|
+
window.__previewAssert = sftpSurfaceAssert()
|
|
429
|
+
},
|
|
430
|
+
/* 挂载位跟着标签切(0.18.4)
|
|
431
|
+
复现:标签 A(prod-web-01)开 SFTP 文件浏览 → 切到标签 B(staging-db)→ 面板
|
|
432
|
+
此前**没跟着切**:标题仍是 A、底下文件列表是 A 那台主机的,而活动标签是 B。
|
|
433
|
+
同一 bug 也砸中 dsh-docker 挂进来的容器面板。修复=挂载位记归属标签,切换时收起
|
|
434
|
+
不属于当前标签的那块(收起 ≠ 关掉:DOM / 在途传输保活);连带修掉「A 收起的面板
|
|
435
|
+
把 B 的『SFTP』按钮堵成哑按钮」。 */
|
|
436
|
+
async 'dock-pane-tab'() {
|
|
437
|
+
const dockState = () => {
|
|
438
|
+
const el = q('.tt_dockPane')
|
|
439
|
+
const body = q('.tt_body')
|
|
440
|
+
if (el === null) return { present: false, hidden: null, display: null, width: 0, height: 0, rows: 0, title: '', termWidth: 0, termHeight: 0 }
|
|
441
|
+
return {
|
|
442
|
+
present: true,
|
|
443
|
+
hidden: el.dataset.dockHidden === '1',
|
|
444
|
+
display: getComputedStyle(el).display,
|
|
445
|
+
width: Math.round(el.getBoundingClientRect().width),
|
|
446
|
+
height: Math.round(el.getBoundingClientRect().height),
|
|
447
|
+
rows: qa('.tt_dockPaneBody .tt_sftpRow').length,
|
|
448
|
+
title: q('.tt_dockPaneTitle') === null ? '' : q('.tt_dockPaneTitle').textContent,
|
|
449
|
+
// 挂载位在下方占高度、在右侧占宽度:收起后终端该拿回对应的那一轴
|
|
450
|
+
termWidth: body === null ? 0 : Math.round(body.getBoundingClientRect().width),
|
|
451
|
+
termHeight: body === null ? 0 : Math.round(body.getBoundingClientRect().height),
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const sftpButton = () => {
|
|
455
|
+
const btn = qa('.tt_connAct').find((b) => b.textContent.includes('SFTP'))
|
|
456
|
+
if (btn === undefined) throw new Error('连接栏没有「SFTP」按钮')
|
|
457
|
+
return btn
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
await openPanel()
|
|
461
|
+
await clickAdd()
|
|
462
|
+
await clickMenuItem('prod-web-01')
|
|
463
|
+
await waitFor(() => tabs().length === 2)
|
|
464
|
+
await clickAdd()
|
|
465
|
+
await clickMenuItem('staging-db')
|
|
466
|
+
await waitFor(() => tabs().length === 3)
|
|
467
|
+
await sleep(250)
|
|
468
|
+
|
|
469
|
+
// A = prod-web-01(第二个标签):在它上面开文件浏览
|
|
470
|
+
const tabA = tabs()[1]
|
|
471
|
+
const tabB = tabs()[2]
|
|
472
|
+
tabA.click()
|
|
473
|
+
await sleep(250)
|
|
474
|
+
const sidA = sidOf(tabA)
|
|
475
|
+
sftpButton().click()
|
|
476
|
+
await waitFor(() => q('.tt_dockPaneBody .tt_sftpRow'), 5000)
|
|
477
|
+
await sleep(300)
|
|
478
|
+
const onA = dockState()
|
|
479
|
+
const cardOnA = q('.tt_dockPaneBody > .tt_sftpCard')
|
|
480
|
+
if (cardOnA === null) throw new Error('SFTP 卡片没有挂进挂载位')
|
|
481
|
+
|
|
482
|
+
// 切到 B:必须收起(且只是收起——DOM 还在,在途传输不会被掐断)
|
|
483
|
+
tabB.click()
|
|
484
|
+
await sleep(450)
|
|
485
|
+
const onB = dockState()
|
|
486
|
+
const cardAliveAfterSwitch = cardOnA.isConnected === true
|
|
487
|
+
const sidB = sidOf(tabB)
|
|
488
|
+
|
|
489
|
+
// 在 B 点「SFTP」:A 收起的面板不该把 B 的入口堵死(旧实现这里是哑按钮)
|
|
490
|
+
sftpButton().click()
|
|
491
|
+
await waitFor(() => {
|
|
492
|
+
const el = q('.tt_dockPane')
|
|
493
|
+
return el !== null && el.dataset.dockHidden !== '1'
|
|
494
|
+
}, 5000)
|
|
495
|
+
await waitFor(() => qa('.tt_dockPaneBody .tt_sftpRow').length > 1, 5000)
|
|
496
|
+
await sleep(300)
|
|
497
|
+
const onBOpened = dockState()
|
|
498
|
+
// 被顶掉的那块(A 的)应当真的收掉了,不留僵尸
|
|
499
|
+
const oldCardGone = cardOnA.isConnected !== true
|
|
500
|
+
|
|
501
|
+
window.__previewAssert = () => {
|
|
502
|
+
const problems = []
|
|
503
|
+
if (onA.hidden === true || onA.rows < 1) problems.push('在归属标签上 SFTP 没挂进挂载位:' + JSON.stringify(onA))
|
|
504
|
+
if (onB.hidden !== true) problems.push('切到另一个标签后挂载位没跟着收起:' + JSON.stringify(onB))
|
|
505
|
+
if (onB.display !== 'none') problems.push('收起态不是 display:none(' + String(onB.display) + ')')
|
|
506
|
+
if (onB.height !== 0 || onB.width !== 0) problems.push('收起态仍占位(width=' + String(onB.width) + ' height=' + String(onB.height) + ')')
|
|
507
|
+
if (onB.termHeight <= onA.termHeight && onB.termWidth <= onA.termWidth) {
|
|
508
|
+
problems.push('挂载位收起后终端没有拿回空间(宽 ' + String(onA.termWidth) + '→' + String(onB.termWidth)
|
|
509
|
+
+ ' 高 ' + String(onA.termHeight) + '→' + String(onB.termHeight) + ')')
|
|
510
|
+
}
|
|
511
|
+
if (cardAliveAfterSwitch !== true) problems.push('收起时把面板 DOM 摘掉了(在途传输会被打断)')
|
|
512
|
+
if (onBOpened.hidden === true || onBOpened.rows < 1) problems.push('另一个标签上「SFTP」是哑的:' + JSON.stringify(onBOpened))
|
|
513
|
+
if (oldCardGone !== true) problems.push('新面板没把上一块收起(同时挂了两块)')
|
|
514
|
+
return problems.length === 0 ? null : problems.join(';')
|
|
515
|
+
}
|
|
516
|
+
window.__previewAssert.data = { sidA, sidB, onA, onB, onBOpened, cardAliveAfterSwitch, oldCardGone }
|
|
517
|
+
},
|
|
518
|
+
/* SFTP 落点断言:挂载位(dock)还是退回了对话框 */
|
|
519
|
+
async 'sftp-fallback'() {
|
|
520
|
+
// 抽屉已被 dsh-docker 的容器面板占用 → SFTP 应退回居中对话框,不能挤掉它
|
|
521
|
+
await SCENARIOS['docker-dock']()
|
|
522
|
+
const before = { dockerPanel: q('.tt_dockPaneBody .dk_panel') !== null }
|
|
523
|
+
const sftpBtn = qa('.tt_connAct').find((b) => b.textContent.includes('SFTP'))
|
|
524
|
+
sftpBtn.click()
|
|
525
|
+
await waitFor(() => q('.tt_sshBackdrop .tt_sftpCard'), 4000)
|
|
526
|
+
await sleep(250)
|
|
527
|
+
window.__previewAssert = {
|
|
528
|
+
...sftpSurfaceAssert(),
|
|
529
|
+
dockerPanelBefore: before.dockerPanel,
|
|
530
|
+
dockerPanelAfter: q('.tt_dockPaneBody .dk_panel') !== null,
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
/* 最小化(状态并入侧边栏入口) */
|
|
534
|
+
async minimized() {
|
|
535
|
+
await openPanel()
|
|
536
|
+
q('.tt_min').click()
|
|
537
|
+
await waitFor(() => q('[data-dsh-tty-entry][data-minimized]'))
|
|
538
|
+
await sleep(150)
|
|
539
|
+
},
|
|
540
|
+
/* 会话退出遮罩 */
|
|
541
|
+
async exited() {
|
|
542
|
+
await openPanel()
|
|
543
|
+
const el = activeTabEl()
|
|
544
|
+
emit({ t: 'exit', sid: sidOf(el), code: 0 })
|
|
545
|
+
await sleep(200)
|
|
546
|
+
},
|
|
547
|
+
/* 连接错误遮罩 */
|
|
548
|
+
async error() {
|
|
549
|
+
await openPanel()
|
|
550
|
+
const el = activeTabEl()
|
|
551
|
+
emit({ t: 'error', sid: sidOf(el), m: 'SSH 认证失败:Permission denied (publickey)' })
|
|
552
|
+
await sleep(200)
|
|
553
|
+
},
|
|
554
|
+
/* 隧道状态弹层 */
|
|
555
|
+
async tunnel() {
|
|
556
|
+
await openPanel()
|
|
557
|
+
await clickAdd()
|
|
558
|
+
await clickMenuItem('staging-db')
|
|
559
|
+
await waitFor(() => tabs().length === 2)
|
|
560
|
+
await sleep(200)
|
|
561
|
+
const btn = qa('.tt_connAct').find((b) => b.textContent.includes('隧道'))
|
|
562
|
+
if (btn) btn.click()
|
|
563
|
+
await waitFor(() => q('.tt_tunnelPop'))
|
|
564
|
+
await sleep(300)
|
|
565
|
+
},
|
|
566
|
+
/* 搜索框展开 */
|
|
567
|
+
async search() {
|
|
568
|
+
await openPanel()
|
|
569
|
+
q('[data-act=search]').click()
|
|
570
|
+
await sleep(120)
|
|
571
|
+
q('.tt_searchInput').value = 'client.js'
|
|
572
|
+
await sleep(80)
|
|
573
|
+
},
|
|
574
|
+
/* 嵌入终端:ttyTerminal.mount 挂到一块普通 div(面板不开,冷启动连接) */
|
|
575
|
+
async embed() {
|
|
576
|
+
const host = document.createElement('div')
|
|
577
|
+
host.id = 'preview-embed'
|
|
578
|
+
host.style.cssText = 'position:fixed;left:120px;top:120px;width:840px;height:420px;z-index:3000;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;overflow:hidden;background:#0b0e14'
|
|
579
|
+
document.body.appendChild(host)
|
|
580
|
+
const terminal = services.get('ttyTerminal')
|
|
581
|
+
if (terminal === undefined) throw new Error('ttyTerminal 服务不存在')
|
|
582
|
+
window.__embedDispose = terminal.mount(host, { command: 'docker exec -it app-web-1 sh', label: 'app-web-1 · exec' })
|
|
583
|
+
await waitFor(() => q('#preview-embed .xterm'), 4000)
|
|
584
|
+
await sleep(700)
|
|
585
|
+
},
|
|
586
|
+
/* 共存:先挂嵌入终端,再开/关 tty 面板 —— 嵌入会话必须活着 */
|
|
587
|
+
async 'embed-panel'() {
|
|
588
|
+
await SCENARIOS.embed()
|
|
589
|
+
await openPanel()
|
|
590
|
+
await sleep(400)
|
|
591
|
+
q('.tt_close').click()
|
|
592
|
+
await sleep(500)
|
|
593
|
+
const frames = (window.__mockLog || []).join(' ')
|
|
594
|
+
window.__previewAssert = {
|
|
595
|
+
embedAlive: q('#preview-embed .xterm') !== null,
|
|
596
|
+
killFrames: (frames.match(/in:kill/g) || []).length,
|
|
597
|
+
socketOpen: window.__mockSockets.some((s) => s.readyState === 1),
|
|
598
|
+
}
|
|
599
|
+
},
|
|
600
|
+
/* docker 面板(列表视图):看容器卡片与动作条分组 */
|
|
601
|
+
async 'docker-panel'() {
|
|
602
|
+
const entry = await waitFor(() => q('[data-dsh-docker-entry]'), 4000)
|
|
603
|
+
entry.click()
|
|
604
|
+
await waitFor(() => q('.dk_panel'), 5000)
|
|
605
|
+
await waitFor(() => qa('.dk_card').length >= 4, 5000)
|
|
606
|
+
await sleep(350)
|
|
607
|
+
},
|
|
608
|
+
/* docker 面板(允许变更):动作条右组可用的样子 */
|
|
609
|
+
async 'docker-panel-rw'() {
|
|
610
|
+
window.__PREVIEW_DOCKER_CONFIG.allowMutations = true
|
|
611
|
+
await SCENARIOS['docker-panel']()
|
|
612
|
+
},
|
|
613
|
+
/* docker 面板 → 卡片「终端」→ 抽屉里的嵌入式终端 */
|
|
614
|
+
async 'docker-exec'() {
|
|
615
|
+
const entry = await waitFor(() => q('[data-dsh-docker-entry]'), 4000)
|
|
616
|
+
entry.click()
|
|
617
|
+
await waitFor(() => q('.dk_panel'), 5000)
|
|
618
|
+
const card = await waitFor(() => qa('.dk_card')[0], 5000)
|
|
619
|
+
// 卡片动作条第一个图标按钮就是「终端」(docker exec -it)
|
|
620
|
+
card.querySelector('.dk_iconBtn').click()
|
|
621
|
+
await waitFor(() => q('.dk_drawer'), 4000)
|
|
622
|
+
await waitFor(() => q('.dk_drawerBody .xterm'), 4000)
|
|
623
|
+
await sleep(700)
|
|
624
|
+
},
|
|
625
|
+
/* docker:终端抽屉 + 日志页共存,折叠只隐藏不结束会话(0.2.0 回归) */
|
|
626
|
+
async 'docker-exec-logs'() {
|
|
627
|
+
await SCENARIOS['docker-exec']()
|
|
628
|
+
// 抽屉开着的同时进日志页:两边都得活着
|
|
629
|
+
qa('.dk_card')[0].querySelectorAll('.dk_iconBtn')[1].click()
|
|
630
|
+
await waitFor(() => q('.dk_logs'), 4000)
|
|
631
|
+
await waitFor(() => q('.dk_drawerBody .xterm'), 4000)
|
|
632
|
+
await sleep(300)
|
|
633
|
+
const fold = await waitFor(() => q('.dk_drawerFold'), 3000)
|
|
634
|
+
fold.click()
|
|
635
|
+
await waitFor(() => q('.dk_drawer[data-collapsed="1"]'), 3000)
|
|
636
|
+
await sleep(250)
|
|
637
|
+
const termAliveWhenCollapsed = q('.dk_drawerBody .xterm') !== null
|
|
638
|
+
const collapsedHeight = Math.round(q('.dk_drawer').getBoundingClientRect().height)
|
|
639
|
+
fold.click()
|
|
640
|
+
await waitFor(() => !q('.dk_drawer[data-collapsed="1"]'), 3000)
|
|
641
|
+
await sleep(300)
|
|
642
|
+
window.__previewAssert = {
|
|
643
|
+
termAliveWhenCollapsed,
|
|
644
|
+
collapsedHeight,
|
|
645
|
+
expandedHeight: Math.round(q('.dk_drawer').getBoundingClientRect().height),
|
|
646
|
+
logLines: document.querySelectorAll('.dk_logLine').length,
|
|
647
|
+
sockets: window.__mockSockets.length,
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
/* 右侧挂载位(ttyPanel.mountPane):骨架 + 终端共存 */
|
|
651
|
+
async 'dock-pane'() {
|
|
652
|
+
await openPanel()
|
|
653
|
+
const service = services.get('ttyPanel')
|
|
654
|
+
if (service === undefined) throw new Error('ttyPanel 服务不存在')
|
|
655
|
+
const pane = service.mountPane({ title: '演示侧栏', hint: 'ttyPanel v1', size: 420 })
|
|
656
|
+
pane.element.innerHTML = '<div style="padding:16px;color:var(--tt-label-2);font-size:13px;line-height:1.8">'
|
|
657
|
+
+ '这块由消费插件自己 render(dsh-docker 的容器面板就走这里)。<br>'
|
|
658
|
+
+ '拖左边缘可调宽,标题栏右侧箭头可折叠,终端始终可见可用。</div>'
|
|
659
|
+
await waitFor(() => q('.tt_dockPane'), 3000)
|
|
660
|
+
await sleep(300)
|
|
661
|
+
window.__previewAssert = {
|
|
662
|
+
width: Math.round(q('.tt_dockPane').getBoundingClientRect().width),
|
|
663
|
+
collapsed: pane.isCollapsed(),
|
|
664
|
+
termVisible: q('.tt_term') !== null,
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
/* 底部挂载位 + 终端重排:缩高度后视口必须钉在底部(输出不被顶上去) */
|
|
668
|
+
async 'dock-pane-bottom'() {
|
|
669
|
+
await openPanel()
|
|
670
|
+
const el = tabs()[0]
|
|
671
|
+
const sid = sidOf(el)
|
|
672
|
+
let text = ''
|
|
673
|
+
for (let i = 1; i <= 200; i += 1) text += 'line-' + i + '\r\n'
|
|
674
|
+
text += 'END-OF-OUTPUT\r\n'
|
|
675
|
+
emit({ t: 'data', sid, d: text })
|
|
676
|
+
await sleep(400)
|
|
677
|
+
const before = lastTermRowText()
|
|
678
|
+
const rowsBefore = qa('.tt_term .xterm-rows > div').length
|
|
679
|
+
const pane = services.get('ttyPanel').mountPane({ title: '演示底部面板', hint: 'side=bottom', side: 'bottom', size: 320 })
|
|
680
|
+
pane.element.innerHTML = '<div style="padding:12px;color:var(--tt-label-2);font-size:13px">底部面板(等同 SFTP 落点)</div>'
|
|
681
|
+
await waitFor(() => q('.tt_dockPane[data-side="bottom"]'), 3000)
|
|
682
|
+
await sleep(600)
|
|
683
|
+
const phaseA = {
|
|
684
|
+
rowsAfter: qa('.tt_term .xterm-rows > div').length,
|
|
685
|
+
after: lastTermRowText(),
|
|
686
|
+
pinnedToBottom: lastTermRowText() === 'END-OF-OUTPUT',
|
|
687
|
+
viewportAtBottom: (() => {
|
|
688
|
+
const el2 = q('.tt_term .xterm-viewport')
|
|
689
|
+
return el2 === null ? null : el2.scrollTop + el2.clientHeight >= el2.scrollHeight - 2
|
|
690
|
+
})(),
|
|
691
|
+
}
|
|
692
|
+
// B) 翻在历史里时再改一次高度(折叠再展开):视口停在原处,不该跳到别处
|
|
693
|
+
const vp = q('.tt_term .xterm-viewport')
|
|
694
|
+
vp.scrollTop = 0
|
|
695
|
+
await sleep(250)
|
|
696
|
+
const historyBefore = firstTermRowText()
|
|
697
|
+
q('.tt_dockPaneFold').click()
|
|
698
|
+
await sleep(350)
|
|
699
|
+
q('.tt_dockPaneFold').click()
|
|
700
|
+
await sleep(550)
|
|
701
|
+
const historyAfter = firstTermRowText()
|
|
702
|
+
window.__previewAssert = {
|
|
703
|
+
before,
|
|
704
|
+
rowsBefore,
|
|
705
|
+
// A) 贴着底部开面板:最新一行必须还在视口里
|
|
706
|
+
...phaseA,
|
|
707
|
+
// B) 翻在历史里改高度:视口锁在同几行
|
|
708
|
+
historyBefore,
|
|
709
|
+
historyAfter,
|
|
710
|
+
historyStable: historyBefore === historyAfter,
|
|
711
|
+
bodyH: Math.round(q('.tt_body').getBoundingClientRect().height),
|
|
712
|
+
screenH: Math.round(q('.tt_term .xterm-screen').getBoundingClientRect().height),
|
|
713
|
+
termH: Math.round(q('.tt_term').getBoundingClientRect().height),
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
/* docker 面板挂进 tty 右侧 dock(连接栏「容器」→ 不盖住终端,0.16.0 端到端) */
|
|
717
|
+
async 'docker-dock'() {
|
|
718
|
+
await openPanel()
|
|
719
|
+
await clickAdd()
|
|
720
|
+
await clickMenuItem('prod-web-01')
|
|
721
|
+
await waitFor(() => tabs().length === 2)
|
|
722
|
+
await sleep(250)
|
|
723
|
+
const btn = qa('.tt_connAct').find((b) => b.textContent.includes('容器'))
|
|
724
|
+
if (btn === undefined) throw new Error('连接栏没有「容器」按钮')
|
|
725
|
+
btn.click()
|
|
726
|
+
await waitFor(() => q('.tt_dockPane'), 5000)
|
|
727
|
+
await waitFor(() => qa('.tt_dockPaneBody .dk_card').length >= 3, 5000)
|
|
728
|
+
await sleep(400)
|
|
729
|
+
const before = {
|
|
730
|
+
modalWidth: Math.round(q('.tt_modal').getBoundingClientRect().width),
|
|
731
|
+
bodyWidth: Math.round(q('.tt_body').getBoundingClientRect().width),
|
|
732
|
+
paneWidth: Math.round(q('.tt_dockPane').getBoundingClientRect().width),
|
|
733
|
+
}
|
|
734
|
+
// 折叠 → 终端拿回宽度;展开 → 恢复
|
|
735
|
+
q('.tt_dockPaneFold').click()
|
|
736
|
+
await sleep(300)
|
|
737
|
+
const collapsed = {
|
|
738
|
+
paneWidth: Math.round(q('.tt_dockPane').getBoundingClientRect().width),
|
|
739
|
+
bodyWidth: Math.round(q('.tt_body').getBoundingClientRect().width),
|
|
740
|
+
// 折叠只是 display:none,DOM(和消费插件的 React 树)必须还在
|
|
741
|
+
bodyDisplay: getComputedStyle(q('.tt_dockPaneBody')).display,
|
|
742
|
+
}
|
|
743
|
+
q('.tt_dockPaneFold').click()
|
|
744
|
+
await sleep(300)
|
|
745
|
+
const expanded = { paneWidth: Math.round(q('.tt_dockPane').getBoundingClientRect().width) }
|
|
746
|
+
window.__previewAssert = {
|
|
747
|
+
docked: q('.tt_dockPaneBody .dk_panel[data-dock="1"]') !== null,
|
|
748
|
+
modalBackdrop: q('.dk_backdrop') !== null,
|
|
749
|
+
cards: qa('.tt_dockPaneBody .dk_card').length,
|
|
750
|
+
before,
|
|
751
|
+
collapsed,
|
|
752
|
+
expanded,
|
|
753
|
+
ttTerms: document.querySelectorAll('.tt_term').length,
|
|
754
|
+
}
|
|
755
|
+
},
|
|
756
|
+
/* toast 提醒(并发上限) */
|
|
757
|
+
async toast() {
|
|
758
|
+
// 先开面板拿到首个标签,再进入「已达上限」状态(否则开面板本身就被拦下)
|
|
759
|
+
await openPanel()
|
|
760
|
+
window.__PREVIEW_AT_LIMIT = true
|
|
761
|
+
await clickAdd()
|
|
762
|
+
await clickMenuItem('本地终端')
|
|
763
|
+
await waitFor(() => q('.tt_toast'))
|
|
764
|
+
await sleep(200)
|
|
765
|
+
},
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const name = new URLSearchParams(location.search).get('scenario') || 'local'
|
|
769
|
+
const run = SCENARIOS[name]
|
|
770
|
+
window.__previewState = { name, done: false, error: null }
|
|
771
|
+
const promise = (run ? run() : Promise.reject(new Error('未知场景:' + name)))
|
|
772
|
+
.then(() => {
|
|
773
|
+
window.__previewState.done = true
|
|
774
|
+
return name
|
|
775
|
+
})
|
|
776
|
+
.catch((error) => {
|
|
777
|
+
window.__previewState.error = String((error && error.message) || error)
|
|
778
|
+
window.__previewState.done = true
|
|
779
|
+
throw error
|
|
780
|
+
})
|
|
781
|
+
window.__previewDiag = () => {
|
|
782
|
+
const rect = (sel) => {
|
|
783
|
+
const el = document.querySelector(sel)
|
|
784
|
+
if (!el) return null
|
|
785
|
+
const r = el.getBoundingClientRect()
|
|
786
|
+
return { w: Math.round(r.width), h: Math.round(r.height), x: Math.round(r.x), y: Math.round(r.y) }
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
scenario: name,
|
|
790
|
+
modal: rect('.tt_modal'),
|
|
791
|
+
sftp: rect('.tt_sftpCard') || rect('.tt_sftpDualCard'),
|
|
792
|
+
ssh: rect('.tt_sshCard'),
|
|
793
|
+
// 分组标签与状态带:核对「选项」不再重复、试连结果落在定高状态带里
|
|
794
|
+
sshSections: [...document.querySelectorAll('.tt_sshSection')].map((el) => el.textContent),
|
|
795
|
+
sshStatus: (() => {
|
|
796
|
+
const band = document.querySelector('.tt_sshStatus')
|
|
797
|
+
if (!band) return null
|
|
798
|
+
return {
|
|
799
|
+
h: Math.round(band.getBoundingClientRect().height),
|
|
800
|
+
error: band.querySelector('.tt_sshError')?.textContent || '',
|
|
801
|
+
probe: band.querySelector('.tt_sshProbeResult')?.textContent || '',
|
|
802
|
+
}
|
|
803
|
+
})(),
|
|
804
|
+
addMenu: rect('.tt_addMenu'),
|
|
805
|
+
toast: rect('.tt_toast'),
|
|
806
|
+
dock: rect('.tt_dock'),
|
|
807
|
+
tabs: document.querySelectorAll('.tt_tab').length,
|
|
808
|
+
rows: document.querySelectorAll('.tt_sftpRow').length,
|
|
809
|
+
// 终端渲染自检:xterm 是否真的画出了字符
|
|
810
|
+
term: {
|
|
811
|
+
sockets: window.__mockSockets.length,
|
|
812
|
+
frames: (window.__mockLog || []).slice(-10).join(' '),
|
|
813
|
+
xtermEls: document.querySelectorAll('.tt_term .xterm').length,
|
|
814
|
+
canvases: document.querySelectorAll('.tt_term canvas').length,
|
|
815
|
+
rowCount: document.querySelectorAll('.tt_term .xterm-rows > div').length,
|
|
816
|
+
text: (document.querySelector('.tt_term .xterm-rows')?.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120),
|
|
817
|
+
screenText: (() => {
|
|
818
|
+
const el = document.querySelector('.tt_term .xterm-screen')
|
|
819
|
+
return el ? el.getBoundingClientRect().width + 'x' + Math.round(el.getBoundingClientRect().height) : null
|
|
820
|
+
})(),
|
|
821
|
+
},
|
|
822
|
+
docker: {
|
|
823
|
+
panel: document.querySelector('.dk_panel') !== null,
|
|
824
|
+
cards: document.querySelectorAll('.dk_card').length,
|
|
825
|
+
// 卡片动作条:分组、间距、配色(用来核对「查看 / 变更」两组的划分)
|
|
826
|
+
actionBar: (() => {
|
|
827
|
+
const bar = document.querySelector('.dk_actionBar')
|
|
828
|
+
if (!bar) return null
|
|
829
|
+
return [...bar.children].map((el) => {
|
|
830
|
+
const box = el.getBoundingClientRect()
|
|
831
|
+
const style = getComputedStyle(el)
|
|
832
|
+
return {
|
|
833
|
+
cls: el.classList.contains('dk_actionBarSep') ? 'SEP' : el.className.replace('dk_iconBtn', 'btn').trim(),
|
|
834
|
+
x: Math.round(box.x),
|
|
835
|
+
w: Math.round(box.width),
|
|
836
|
+
gapBefore: el.previousElementSibling === null ? null : Math.round(box.x - el.previousElementSibling.getBoundingClientRect().right),
|
|
837
|
+
color: style.color,
|
|
838
|
+
disabled: el.disabled === true,
|
|
839
|
+
}
|
|
840
|
+
})
|
|
841
|
+
})(),
|
|
842
|
+
drawer: !!rect('.dk_drawer'),
|
|
843
|
+
drawerTerm: document.querySelector('.dk_drawerBody .xterm') !== null,
|
|
844
|
+
drawerText: (document.querySelector('.dk_drawerBody .xterm-rows')?.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
|
|
845
|
+
},
|
|
846
|
+
embed: {
|
|
847
|
+
host: document.querySelector('#preview-embed .xterm') !== null,
|
|
848
|
+
text: (document.querySelector('#preview-embed .xterm-rows')?.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
|
|
849
|
+
},
|
|
850
|
+
assert: window.__previewAssert ?? null,
|
|
851
|
+
bodyChildren: [...document.body.children].map((el) => el.className || el.tagName).slice(0, 12),
|
|
852
|
+
// 样式自检:浏览器实际解析出的规则数(与 tty.css 的规则数比对,能发现
|
|
853
|
+
// 语法错误导致的静默丢弃——esbuild 的 text loader 不解析 CSS)
|
|
854
|
+
cssRules: (() => {
|
|
855
|
+
const sheet = document.getElementById('dsh-tty-style')?.sheet
|
|
856
|
+
if (!sheet) return null
|
|
857
|
+
const count = (rules) => [...rules].reduce((n, r) => n + 1 + (r.cssRules ? count(r.cssRules) : 0), 0)
|
|
858
|
+
try {
|
|
859
|
+
return count(sheet.cssRules)
|
|
860
|
+
} catch {
|
|
861
|
+
return 'blocked'
|
|
862
|
+
}
|
|
863
|
+
})(),
|
|
864
|
+
// 主题自检:确认暗色 token 真的生效(截图颜色异常时先看这里)
|
|
865
|
+
theme: {
|
|
866
|
+
darkAttr: document.body.hasAttribute('data-ds-dark-theme'),
|
|
867
|
+
bgBase: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
|
|
868
|
+
layer2: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-layer-2').trim(),
|
|
869
|
+
headerBg: getComputedStyle(document.querySelector('.tt_header') || document.body).backgroundImage.slice(0, 80),
|
|
870
|
+
},
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
window.__previewReady = promise
|
|
874
|
+
window.__preview = { sleep, waitFor, openPanel, clickAdd, clickMenuItem, feed, emit, tabs, modal, cards, scenarios: Object.keys(SCENARIOS) }
|
|
875
|
+
})()
|