@hyzyn/dsh-tty 0.19.3 → 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.
@@ -386,6 +386,172 @@
386
386
  await waitFor(() => q('#preview-settings .tt_cardBody'))
387
387
  await sleep(400)
388
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
+ },
389
555
  /* 设置卡片:docker 与 tty 并排(同一张 ul 里),核对两家的观感是否一致 */
390
556
  async 'settings-docker'() {
391
557
  const host = document.createElement('div')
@@ -424,6 +590,94 @@
424
590
  ttyDesc: getComputedStyle(q('.tt_cardDescription')).fontSize,
425
591
  }
426
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
+ },
427
681
  /* SFTP 单窗体(0.16.0 起挂进右侧挂载位,终端保持可见) */
428
682
  async sftp() {
429
683
  await openPanel()
@@ -590,6 +844,47 @@
590
844
  await waitFor(() => q('.tt_tunnelPop'))
591
845
  await sleep(300)
592
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
+ },
593
888
  /* 搜索框展开 */
594
889
  async search() {
595
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 = [
@@ -52,6 +52,8 @@ const SCENARIOS = [
52
52
  ['ssh-edit', 'SSH 连接对话框(编辑)'],
53
53
  ['settings', '设置卡片'],
54
54
  ['settings-docker', '设置卡片(docker 与 tty 并排对照)'],
55
+ ['docker-stale-book', 'docker 设置卡片:失效的连接簿引用要看得出来'],
56
+ ['tunnel-edit', '端口转发:编辑隧道(回填 / 改端口换名 / 停用的不被启用)'],
55
57
  ['sftp', 'SFTP 单窗体(挂右侧挂载位)'],
56
58
  ['sftp-dual', 'SFTP 双栏(挂右侧挂载位)'],
57
59
  ['sftp-fallback', 'SFTP 落点回退(抽屉被容器面板占用 → 对话框)'],
@@ -60,6 +62,7 @@ const SCENARIOS = [
60
62
  ['exited', '会话退出遮罩'],
61
63
  ['error', '连接错误遮罩'],
62
64
  ['tunnel', '隧道状态弹层'],
65
+ ['connbar-more', '连接栏「⋯」更多(没配隧道的连接也能找到端口转发入口)'],
63
66
  ['search', '搜索框展开'],
64
67
  ['toast', 'toast 提醒'],
65
68
  ['embed', '嵌入式终端(ttyTerminal.mount)'],