@hyzyn/dsh-tty 0.27.0 → 0.30.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 +79 -7
- package/README.md +67 -8
- package/client.js +240 -61
- package/lib/index.d.ts +82 -3
- package/lib/index.js +224 -22
- package/lib/index.js.map +1 -1
- package/package.json +3 -3
- package/scripts/preview/harness.js +688 -2
- package/scripts/preview/mock-host.js +5 -0
- package/scripts/preview.mjs +12 -1
|
@@ -183,6 +183,12 @@
|
|
|
183
183
|
const s = socket()
|
|
184
184
|
if (s) s._deliver(msg)
|
|
185
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* 造一帧 `sessions`(宿主视角的会话表)——给需要**逐帧改变**清单的场景用(#7④:先有这条
|
|
188
|
+
* agent 会话、下一帧它出表)。基准表来自 mock 宿主自己的会话表(`__mockSessionList`),
|
|
189
|
+
* 不要在场景里拿客户端标签推算:那等于用被测对象造夹具。
|
|
190
|
+
*/
|
|
191
|
+
const sessionList = (extra = []) => window.__mockSessionList().concat(extra)
|
|
186
192
|
|
|
187
193
|
const openPanel = async () => {
|
|
188
194
|
await waitFor(() => entry())
|
|
@@ -497,6 +503,25 @@
|
|
|
497
503
|
if (endpointCount !== 2) throw new Error('端点组数不为 2:' + String(endpointCount))
|
|
498
504
|
if (document.querySelector('#preview-settings .tt_tunnelArrow') === null) throw new Error('缺少方向箭头')
|
|
499
505
|
|
|
506
|
+
/*
|
|
507
|
+
* 端点组的几何(用户两张截图的正题:「输入框的样式优化一下」):**贴合内容**——
|
|
508
|
+
* 组宽 ≈ 组内孩子宽度之和。原先是 `flex: 1 1 190px`,宽卡片里左组被抻到几百像素
|
|
509
|
+
* (端口框后面一片组内死区),右组的 host 又吃掉全部余量、把冒号 + 端口顶到卡片右缘
|
|
510
|
+
* (离「服务器侧主机」占位符几百像素)。
|
|
511
|
+
*
|
|
512
|
+
* 聚焦环那条**不在这里判**:headless 里 `document.hasFocus()` 是 false,`:focus` 压根
|
|
513
|
+
* 不匹配(实测 mFocus=false、:focus-within 照常匹配),所以「内层输入有没有自己画环」
|
|
514
|
+
* 在这一层恒为 none——那种恒绿的断言比没有更坏。它由 test/tunnel-endpoint.test.ts 从
|
|
515
|
+
* CSS 源上钉(内层 box-shadow: none + 组上 :focus-within 有环,两条成对)。
|
|
516
|
+
*
|
|
517
|
+
* 量成纯数字存下来——方向切换会重渲表单、DOM 引用会失效。
|
|
518
|
+
*/
|
|
519
|
+
const endpointGroups = [...document.querySelectorAll('#preview-settings .tt_tunnelEndpoint')]
|
|
520
|
+
const endpointSlack = endpointGroups.map((group) => {
|
|
521
|
+
const inner = [...group.children].reduce((n, el) => n + el.getBoundingClientRect().width, 0)
|
|
522
|
+
return group.getBoundingClientRect().width - inner
|
|
523
|
+
})
|
|
524
|
+
|
|
500
525
|
// 分段控件:本地/远程可切换,当前项高亮
|
|
501
526
|
const segBtns = [...document.querySelectorAll('#preview-settings .tt_segmentedBtn')]
|
|
502
527
|
if (segBtns.length !== 2) throw new Error('分段控件按钮数不为 2:' + String(segBtns.length))
|
|
@@ -607,6 +632,18 @@
|
|
|
607
632
|
if (!names.some((n) => n.startsWith('prod-redis'))) {
|
|
608
633
|
problems.push('编辑波及了别的隧道')
|
|
609
634
|
}
|
|
635
|
+
/*
|
|
636
|
+
* 端点组:贴合内容(不许抻出组内死区)。判据与采集处的注释同源;聚焦环那两条见
|
|
637
|
+
* test/tunnel-endpoint.test.ts(headless 里判不了)。
|
|
638
|
+
*/
|
|
639
|
+
if (endpointSlack.length !== 2) {
|
|
640
|
+
problems.push('夹具失效:端点组数不是 2(实测 ' + String(endpointSlack.length) + ')')
|
|
641
|
+
}
|
|
642
|
+
for (const slack of endpointSlack) {
|
|
643
|
+
if (slack > 2.5) {
|
|
644
|
+
problems.push('端点组没有贴合内容(组内死区 ' + String(Math.round(slack)) + 'px)——用户实测现场')
|
|
645
|
+
}
|
|
646
|
+
}
|
|
610
647
|
if (buttonsIn(document.querySelector('#preview-settings .tt_cardBody')).find((b) => saysExact(b, '添加隧道', 'Add tunnel')) === undefined) {
|
|
611
648
|
problems.push('保存后没有退出编辑态')
|
|
612
649
|
}
|
|
@@ -739,6 +776,9 @@
|
|
|
739
776
|
let defaultLabel = ''
|
|
740
777
|
let defaultCleared = ''
|
|
741
778
|
let labelsRepeatId = false
|
|
779
|
+
// 「保存」按钮是**两张候选表共用**的布局标尺(都用它量「打开候选表有没有把卡片顶动」),
|
|
780
|
+
// 所以定义提到这里,别在各自的块里各找一次
|
|
781
|
+
const saveBtn = qa('.tt_cardSave').find((el) => saysExact(el, '保存', 'Save'))
|
|
742
782
|
if (routeInput !== null) {
|
|
743
783
|
// ① 挂载时目录取不到(夹具预先置了 __PREVIEW_CATALOG.fail)
|
|
744
784
|
routeInput.click()
|
|
@@ -746,7 +786,6 @@
|
|
|
746
786
|
unavailableText = routeField.textContent
|
|
747
787
|
// ② 目录恢复:再聚焦一次会重取(失败时 groups 仍为空,守卫放行)
|
|
748
788
|
delete window.__PREVIEW_CATALOG
|
|
749
|
-
const saveBtn = qa('.tt_cardSave').find((el) => saysExact(el, '保存', 'Save'))
|
|
750
789
|
const saveTopBefore = saveBtn === undefined ? null : saveBtn.getBoundingClientRect().top
|
|
751
790
|
routeInput.click()
|
|
752
791
|
await sleep(400)
|
|
@@ -779,6 +818,62 @@
|
|
|
779
818
|
routeReadOnly = routeInput.readOnly
|
|
780
819
|
}
|
|
781
820
|
|
|
821
|
+
/*
|
|
822
|
+
* Shell 路径的候选表:同一张卡片里的**第二张**候选表,同样必须是浮层。
|
|
823
|
+
*
|
|
824
|
+
* 用户截图现场就是内联版——输入框正下方多出一整块,把下面的字段整段推走(原话「这个选择会
|
|
825
|
+
* 挤占空间破坏布局」)。判据与模型路由一式一样:量「保存」按钮的位置有没有被顶动 + 候选表
|
|
826
|
+
* 的 computed position;再加两条行为断言——点一条要真的写进输入框、并进保存 payload。
|
|
827
|
+
*/
|
|
828
|
+
const shellField = qa('.tt_cardField').find((el) => {
|
|
829
|
+
const label = el.querySelector('.tt_cardLabel')
|
|
830
|
+
return label !== null && (label.textContent.indexOf('Shell 路径') >= 0 || label.textContent.indexOf('Shell path') >= 0)
|
|
831
|
+
})
|
|
832
|
+
const shellInput = shellField === undefined ? null : shellField.querySelector('input.tt_cardInput')
|
|
833
|
+
const shellList = () => (shellField === undefined ? null : shellField.querySelector('.tt_shellList'))
|
|
834
|
+
let shellListPosition = ''
|
|
835
|
+
let shellShift = null
|
|
836
|
+
let shellShiftTyped = null
|
|
837
|
+
let shellRows = 0
|
|
838
|
+
let shellValueBefore = ''
|
|
839
|
+
let shellPickedItem = ''
|
|
840
|
+
let shellPicked = ''
|
|
841
|
+
let shellClosedAfterPick = null
|
|
842
|
+
if (shellInput !== null) {
|
|
843
|
+
shellValueBefore = shellInput.value
|
|
844
|
+
const shellTopBefore = saveBtn === undefined ? null : saveBtn.getBoundingClientRect().top
|
|
845
|
+
/*
|
|
846
|
+
* 用例 A:值已经是**完整路径**(`/bin/zsh`)——候选被这个值自己过滤成**同一行**
|
|
847
|
+
* (用户截图里就是这一幕:输入框下面孤零零一行 `/bin/zsh`)。
|
|
848
|
+
*/
|
|
849
|
+
shellInput.click()
|
|
850
|
+
await sleep(300)
|
|
851
|
+
const listEl = shellList()
|
|
852
|
+
shellListPosition = listEl === null ? '' : window.getComputedStyle(listEl).position
|
|
853
|
+
const shellTopOpen = saveBtn === undefined ? null : saveBtn.getBoundingClientRect().top
|
|
854
|
+
shellShift = shellTopBefore === null || shellTopOpen === null ? null : shellTopOpen - shellTopBefore
|
|
855
|
+
/*
|
|
856
|
+
* 用例 B:打字过滤(`/bin/`)→ 候选变成 3 行、列表长高一大截。内联版正是**这里**把
|
|
857
|
+
* 下面的字段整段推走(列表越高推得越远),所以位移要在这一档再量一次。
|
|
858
|
+
*/
|
|
859
|
+
setReactInput(shellInput, '/bin/')
|
|
860
|
+
await sleep(300)
|
|
861
|
+
const typedList = shellList()
|
|
862
|
+
shellRows = typedList === null ? 0 : typedList.querySelectorAll('.tt_envItem').length
|
|
863
|
+
const shellTopTyped = saveBtn === undefined ? null : saveBtn.getBoundingClientRect().top
|
|
864
|
+
shellShiftTyped = shellTopBefore === null || shellTopTyped === null ? null : shellTopTyped - shellTopBefore
|
|
865
|
+
// 挑一条与**聚焦前的值**不同的候选:值真的变了才证明「点一下写进去了」,而不是本来就长这样
|
|
866
|
+
const items = typedList === null ? [] : Array.from(typedList.querySelectorAll('.tt_envItem'))
|
|
867
|
+
const item = items.find((el) => el.textContent !== shellValueBefore) ?? null
|
|
868
|
+
if (item !== null) {
|
|
869
|
+
shellPickedItem = item.textContent
|
|
870
|
+
item.click()
|
|
871
|
+
await sleep(250)
|
|
872
|
+
shellPicked = shellInput.value
|
|
873
|
+
shellClosedAfterPick = shellList() === null
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
782
877
|
const toggleBefore = qa('.tt_cardLabel').find((el) => says(el, '失败即解释', 'Explain failures'))
|
|
783
878
|
const boxBefore = toggleBefore === undefined ? null : toggleBefore.parentElement.querySelector('input[type=checkbox]')
|
|
784
879
|
const checkedByDefault = boxBefore === null ? null : boxBefore.checked
|
|
@@ -858,6 +953,28 @@
|
|
|
858
953
|
if (postedPayload.assistProvider !== 'mock-provider' || postedPayload.assistModel !== 'mock-fast') {
|
|
859
954
|
return '选中的那一对没进保存 payload:' + JSON.stringify([postedPayload.assistProvider, postedPayload.assistModel])
|
|
860
955
|
}
|
|
956
|
+
// Shell 路径的候选表:同一张卡片里的第二张候选表,判据同模型路由(用户截图现场)
|
|
957
|
+
if (shellInput === null) return '设置卡片里没有「Shell 路径」控件'
|
|
958
|
+
if (shellShift === null || shellShiftTyped === null) return '夹具失效:量不到「保存」按钮的位置(Shell 路径那一栏)'
|
|
959
|
+
if (Math.abs(shellShift) > 0.5 || Math.abs(shellShiftTyped) > 0.5) {
|
|
960
|
+
return '打开 Shell 候选表把卡片布局顶动了 '
|
|
961
|
+
+ String(Math.round(shellShift)) + 'px(同值一行)/ ' + String(Math.round(shellShiftTyped)) + 'px(过滤后 3 行)——用户实测现场'
|
|
962
|
+
}
|
|
963
|
+
if (shellListPosition !== 'fixed') {
|
|
964
|
+
return 'Shell 候选表不是浮层(会顶动卡片布局):position=' + shellListPosition
|
|
965
|
+
}
|
|
966
|
+
if (shellRows < 2) return '夹具失效:打字过滤之后候选该有好几行,实测 ' + String(shellRows) + ' 行'
|
|
967
|
+
if (shellPickedItem === '') return '夹具失效:Shell 候选表里没有可点的候选'
|
|
968
|
+
if (shellPicked !== shellPickedItem) {
|
|
969
|
+
return '点 Shell 候选没有写进输入框:' + JSON.stringify([shellPickedItem, shellPicked])
|
|
970
|
+
}
|
|
971
|
+
if (shellPicked === shellValueBefore) {
|
|
972
|
+
return '点 Shell 候选之后值没变(这条断言证明不了「写入」):' + JSON.stringify(shellPicked)
|
|
973
|
+
}
|
|
974
|
+
if (shellClosedAfterPick !== true) return '选中 Shell 候选之后候选表没有收起'
|
|
975
|
+
if (postedPayload.shell !== shellPicked) {
|
|
976
|
+
return '选中的 Shell 没进保存 payload:' + JSON.stringify([shellPicked, postedPayload.shell])
|
|
977
|
+
}
|
|
861
978
|
// 路由是**一个**控件(渠道 + 模型一对):两栏并列的旧形态不许回来
|
|
862
979
|
const labels = qa('.tt_cardLabel').map((el) => el.textContent)
|
|
863
980
|
const routeLabels = labels.filter((text) => text.indexOf('模型路由') >= 0 || text.indexOf('Model route') >= 0)
|
|
@@ -867,6 +984,46 @@
|
|
|
867
984
|
return null
|
|
868
985
|
}
|
|
869
986
|
},
|
|
987
|
+
/*
|
|
988
|
+
* 设置卡片:Shell 路径的候选表(浮层)。
|
|
989
|
+
*
|
|
990
|
+
* 这个场景**只为出图**:把值改成 `/bin/` 让候选变成多行、留着列表打开截图。布局那几条判据
|
|
991
|
+
* 在 `settings-assist` 里(同一张卡片,量「保存」按钮的位移),这里给的是同一幕的肉眼版本——
|
|
992
|
+
* 用户上报的就是它(内联版会把这下面整段字段推走)。
|
|
993
|
+
*/
|
|
994
|
+
async 'settings-shell'() {
|
|
995
|
+
const host = document.createElement('div')
|
|
996
|
+
host.id = 'preview-settings'
|
|
997
|
+
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'
|
|
998
|
+
document.body.appendChild(host)
|
|
999
|
+
const list = document.createElement('ul')
|
|
1000
|
+
list.style.cssText = 'display:flex;flex-direction:column;gap:12px;margin:0;padding:0'
|
|
1001
|
+
host.appendChild(list)
|
|
1002
|
+
const tty = cards.find((c) => c.spec.key === 'tty')
|
|
1003
|
+
if (tty === undefined) throw new Error('未注册 tty 设置卡片')
|
|
1004
|
+
const root = window.ReactDOM.createRoot(list)
|
|
1005
|
+
root.render(window.React.createElement(tty.Component))
|
|
1006
|
+
await waitFor(() => q('.tt_card'), 3000)
|
|
1007
|
+
q('.tt_cardHeader').click()
|
|
1008
|
+
await waitFor(() => q('.tt_cardBody'), 4000)
|
|
1009
|
+
await sleep(400)
|
|
1010
|
+
const field = qa('.tt_cardField').find((el) => {
|
|
1011
|
+
const label = el.querySelector('.tt_cardLabel')
|
|
1012
|
+
return label !== null && (label.textContent.indexOf('Shell 路径') >= 0 || label.textContent.indexOf('Shell path') >= 0)
|
|
1013
|
+
})
|
|
1014
|
+
const input = field === undefined ? null : field.querySelector('input.tt_cardInput')
|
|
1015
|
+
if (input === null) throw new Error('设置卡片里没有「Shell 路径」控件')
|
|
1016
|
+
// 先滚到这一栏再聚焦:滚动后浮层要跟着锚点重新定位(夹具顺带把那条路径也走一遍)
|
|
1017
|
+
field.scrollIntoView({ block: 'center' })
|
|
1018
|
+
await sleep(250)
|
|
1019
|
+
input.click()
|
|
1020
|
+
await sleep(250)
|
|
1021
|
+
// 打字过滤走原生 setter + input 事件(直接赋值 React 读到的还是旧值):候选从「同值一行」变 3 行
|
|
1022
|
+
const desc = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')
|
|
1023
|
+
desc.set.call(input, '/bin/')
|
|
1024
|
+
input.dispatchEvent(new window.Event('input', { bubbles: true }))
|
|
1025
|
+
await sleep(400)
|
|
1026
|
+
},
|
|
870
1027
|
/* 设置卡片:docker 与 tty 并排(同一张 ul 里),核对两家的观感是否一致 */
|
|
871
1028
|
async 'settings-docker'() {
|
|
872
1029
|
const host = document.createElement('div')
|
|
@@ -1133,12 +1290,344 @@
|
|
|
1133
1290
|
await waitFor(() => q('[data-dsh-tty-entry][data-minimized]'))
|
|
1134
1291
|
await sleep(150)
|
|
1135
1292
|
},
|
|
1136
|
-
/*
|
|
1293
|
+
/*
|
|
1294
|
+
* 会话退出:**不画遮罩** + 顶部提示条(issue #7①②)。
|
|
1295
|
+
*
|
|
1296
|
+
* 现场(用户截图):agent 跑的每条命令都会落一个标签,退出后被一层铺满终端区的遮罩
|
|
1297
|
+
* (inset:0 + 半透明底 + 模糊 + 整块可点)盖住——结果看不清、连选中复制都做不到,
|
|
1298
|
+
* 用户的绕过手段是 F12 里把 `.tt_overlay` 藏掉。
|
|
1299
|
+
*
|
|
1300
|
+
* 断言四条(缺一条都是静默回归):① 面板标签的退出遮罩是空的;② 顶部提示条出现且文案
|
|
1301
|
+
* 是「已退出」;③ 条子真的把终端往下推了(`.tt_term` 的 top 偏移 ≥ 条高,否则第一行被盖);
|
|
1302
|
+
* ④ 条上有「重新打开」这颗按钮(原来那个入口是「点遮罩」,撤了遮罩就必须补上它)。
|
|
1303
|
+
*/
|
|
1137
1304
|
async exited() {
|
|
1138
1305
|
await openPanel()
|
|
1139
1306
|
const el = activeTabEl()
|
|
1140
1307
|
emit({ t: 'exit', sid: sidOf(el), code: 0 })
|
|
1308
|
+
await sleep(250)
|
|
1309
|
+
window.__previewAssert = async () => {
|
|
1310
|
+
const term = qa('.tt_term').find((node) => node.style.display !== 'none')
|
|
1311
|
+
if (term === undefined) return '夹具失效:没有可见的终端容器'
|
|
1312
|
+
const overlay = term.querySelector('.tt_overlay')
|
|
1313
|
+
if (overlay !== null && (overlay.textContent || '') !== '') {
|
|
1314
|
+
return '退出态又画上了遮罩(输出会被挡住):' + String(overlay.textContent)
|
|
1315
|
+
}
|
|
1316
|
+
const bar = q('.tt_exitBar')
|
|
1317
|
+
if (bar === null || bar.hidden === true) return '退出提示条没出现(撤了遮罩就没人告诉用户会话已退出)'
|
|
1318
|
+
if (!says(bar, '已退出', 'Exited')) return '提示条文案不对:' + String(bar.textContent)
|
|
1319
|
+
const body = q('.tt_body')
|
|
1320
|
+
const offset = Math.round(term.getBoundingClientRect().top - body.getBoundingClientRect().top)
|
|
1321
|
+
if (offset < 24) return '提示条没让位:终端 top 偏移只有 ' + String(offset) + 'px'
|
|
1322
|
+
if (bar.querySelector('.tt_exitBarBtn') === null) return '提示条上没有「重新打开」这颗按钮'
|
|
1323
|
+
return null
|
|
1324
|
+
}
|
|
1325
|
+
},
|
|
1326
|
+
/*
|
|
1327
|
+
* #7③:「⋯」标签列表里的「清理已退出的标签」——**长什么样**。
|
|
1328
|
+
*
|
|
1329
|
+
* 现场:用户攒了 47 个已退出的标签,只能一个一个点标签上的 ✕。入口在「⋯」里,而那个
|
|
1330
|
+
* 按钮此前**只在标签栏溢出时**才出现。这条钉「没溢出但有死标签时它也在」以及菜单里那
|
|
1331
|
+
* 一行(截图要看清的就是它);「点一下真的收走」在 `clean-exited-apply` 里做——
|
|
1332
|
+
* 断言在截图**之前**跑,凡是会改界面的检查都不该放在断言里(截图会变成点完的样子)。
|
|
1333
|
+
*/
|
|
1334
|
+
async 'clean-exited'() {
|
|
1335
|
+
await openPanel()
|
|
1336
|
+
// 造三个标签,退掉其中两个:留一条活会话,面板不会因为清理把自己关掉
|
|
1337
|
+
for (let i = 0; i < 2; i += 1) {
|
|
1338
|
+
await clickAdd()
|
|
1339
|
+
await clickMenuItem('本地终端', 'Local terminal')
|
|
1340
|
+
await waitFor(() => tabs().length === i + 2, 4000)
|
|
1341
|
+
}
|
|
1342
|
+
const all = tabs()
|
|
1343
|
+
emit({ t: 'exit', sid: sidOf(all[1]), code: 0 })
|
|
1344
|
+
emit({ t: 'exit', sid: sidOf(all[2]), code: 0 })
|
|
1345
|
+
await sleep(250)
|
|
1346
|
+
const more = q('.tt_tabMore')
|
|
1347
|
+
if (more !== null && more.hidden !== true) {
|
|
1348
|
+
more.click()
|
|
1349
|
+
await waitFor(() => q('.tt_tabMenu'))
|
|
1350
|
+
await sleep(120)
|
|
1351
|
+
}
|
|
1352
|
+
window.__previewAssert = async () => {
|
|
1353
|
+
if (tabs().length !== 3) return '夹具失效:期望 3 个标签,实际 ' + String(tabs().length)
|
|
1354
|
+
if (more === null || more.hidden === true) return '有已退出的标签时「⋯」没出现(清理入口够不着)'
|
|
1355
|
+
const menu = q('.tt_tabMenu')
|
|
1356
|
+
if (menu === null) return '「⋯」菜单没打开'
|
|
1357
|
+
const row = qa('.tt_addMenuItem').find((el) => says(el, '清理已退出', 'Clean up exited'))
|
|
1358
|
+
if (row === undefined) return '菜单里没有「清理已退出的标签」这一行'
|
|
1359
|
+
if (!row.textContent.includes('2')) return '清理行没写明条数:' + String(row.textContent)
|
|
1360
|
+
return null
|
|
1361
|
+
}
|
|
1362
|
+
},
|
|
1363
|
+
/*
|
|
1364
|
+
* #7③ 的功能面:点那一下真的把死标签收走,且「⋯」自己藏回去(没有死标签、也没溢出)。
|
|
1365
|
+
* 断言只读(收集结果),界面动作全在场景体里——同 `clean-exited` 的理由。
|
|
1366
|
+
*/
|
|
1367
|
+
async 'clean-exited-apply'() {
|
|
1368
|
+
await openPanel()
|
|
1369
|
+
for (let i = 0; i < 2; i += 1) {
|
|
1370
|
+
await clickAdd()
|
|
1371
|
+
await clickMenuItem('本地终端', 'Local terminal')
|
|
1372
|
+
await waitFor(() => tabs().length === i + 2, 4000)
|
|
1373
|
+
}
|
|
1374
|
+
const all = tabs()
|
|
1375
|
+
emit({ t: 'exit', sid: sidOf(all[1]), code: 0 })
|
|
1376
|
+
emit({ t: 'exit', sid: sidOf(all[2]), code: 0 })
|
|
1377
|
+
await sleep(250)
|
|
1378
|
+
const more = q('.tt_tabMore')
|
|
1379
|
+
if (more !== null) more.click()
|
|
1380
|
+
await waitFor(() => q('.tt_tabMenu'))
|
|
1381
|
+
await sleep(120)
|
|
1382
|
+
const row = await waitFor(() => qa('.tt_addMenuItem').find((el) => says(el, '清理已退出', 'Clean up exited')))
|
|
1383
|
+
row.click()
|
|
1384
|
+
await sleep(300)
|
|
1385
|
+
const state = { tabs: tabs().length, moreHidden: more === null ? null : more.hidden === true, menu: q('.tt_tabMenu') !== null }
|
|
1386
|
+
window.__previewAssert = async () => {
|
|
1387
|
+
if (state.tabs !== 1) return '清理没生效:还剩 ' + String(state.tabs) + ' 个标签'
|
|
1388
|
+
if (state.menu) return '清理后菜单还挂着'
|
|
1389
|
+
if (state.moreHidden === false) return '死标签清完了,「⋯」没藏回去'
|
|
1390
|
+
return null
|
|
1391
|
+
}
|
|
1392
|
+
},
|
|
1393
|
+
/*
|
|
1394
|
+
* #7④:AI 显式释放会话 → 标签跟着走。
|
|
1395
|
+
*
|
|
1396
|
+
* 判据是「宿主表里还有没有这一条」(agent 的 tty_close / tty_run 收尾会让它出表),
|
|
1397
|
+
* 而不是「进程退没退出」——只读保留(exited: true)的那种要**留着可读**。
|
|
1398
|
+
*
|
|
1399
|
+
* 两条路一起钉:后台的 agent 标签当场收走;**正在看的**那一条先留一台阶(提示条写
|
|
1400
|
+
* 「AI 已结束这个会话」),切走时再收。反向验证:把 `holdReleasedTab` 摘掉 → 第一条红;
|
|
1401
|
+
* 把「出表」也当只读保留 → 第二条红(标签不会消失)。
|
|
1402
|
+
*/
|
|
1403
|
+
async 'agent-release'() {
|
|
1404
|
+
await openPanel()
|
|
1405
|
+
await waitFor(() => tabs().length === 1, 4000)
|
|
1406
|
+
const agentOf = (sid) => ({ sid, owner: 'agent', kind: 'local', cwd: window.__PREVIEW_CWD, attachable: true })
|
|
1407
|
+
// ① 后台的 agent 标签:宿主推的会话清单里多一条 owner:'agent'
|
|
1408
|
+
emit({ t: 'sessions', list: sessionList([agentOf('agent-bg')]) })
|
|
1409
|
+
await waitFor(() => tabs().length === 2, 4000)
|
|
1410
|
+
await sleep(200)
|
|
1411
|
+
// AI 释放它(宿主表里撤掉)→ 后台标签应当当场消失
|
|
1412
|
+
emit({ t: 'sessions', list: sessionList() })
|
|
1413
|
+
await sleep(300)
|
|
1414
|
+
const bgClosed = qa('.tt_tab').every((el) => sidOf(el) !== 'agent-bg')
|
|
1415
|
+
// ② 正在看的那一条:再开一条 agent 会话,切到它,然后释放
|
|
1416
|
+
emit({ t: 'sessions', list: sessionList([agentOf('agent-watch')]) })
|
|
1417
|
+
await waitFor(() => qa('.tt_tab').some((el) => sidOf(el) === 'agent-watch'), 4000)
|
|
1418
|
+
qa('.tt_tab').find((el) => sidOf(el) === 'agent-watch').click()
|
|
1419
|
+
await sleep(200)
|
|
1420
|
+
emit({ t: 'sessions', list: sessionList() })
|
|
1421
|
+
await sleep(300)
|
|
1422
|
+
const held = {
|
|
1423
|
+
count: tabs().length,
|
|
1424
|
+
stillThere: qa('.tt_tab').some((el) => sidOf(el) === 'agent-watch'),
|
|
1425
|
+
text: q('.tt_exitBar') === null ? '' : String(q('.tt_exitBar').textContent),
|
|
1426
|
+
}
|
|
1427
|
+
// ③ 切走 → 台阶结束,标签被收走
|
|
1428
|
+
const other = qa('.tt_tab').find((el) => sidOf(el) !== 'agent-watch')
|
|
1429
|
+
if (other !== undefined) other.click()
|
|
1430
|
+
await sleep(300)
|
|
1431
|
+
const closedOnSwitch = !qa('.tt_tab').some((el) => sidOf(el) === 'agent-watch')
|
|
1432
|
+
/*
|
|
1433
|
+
* ④ 再走一遍,把「AI 已结束这个会话 + 收起」的样子留给截图(截图在断言之后拍,
|
|
1434
|
+
* 断言又必须只读——所以要在场景体里把界面弄成要拍的样子,不能等断言去点)。
|
|
1435
|
+
*/
|
|
1436
|
+
emit({ t: 'sessions', list: sessionList([agentOf('agent-shot')]) })
|
|
1437
|
+
await waitFor(() => qa('.tt_tab').some((el) => sidOf(el) === 'agent-shot'), 4000)
|
|
1438
|
+
qa('.tt_tab').find((el) => sidOf(el) === 'agent-shot').click()
|
|
1141
1439
|
await sleep(200)
|
|
1440
|
+
emit({ t: 'sessions', list: sessionList() })
|
|
1441
|
+
await sleep(300)
|
|
1442
|
+
const shotState = {
|
|
1443
|
+
tabs: tabs().length,
|
|
1444
|
+
text: q('.tt_exitBar') === null ? '' : String(q('.tt_exitBar').textContent),
|
|
1445
|
+
dismiss: q('.tt_exitBarBtn') === null ? '' : String(q('.tt_exitBarBtn').textContent),
|
|
1446
|
+
}
|
|
1447
|
+
window.__previewAssert = async () => {
|
|
1448
|
+
if (!bgClosed) return '后台的 agent 标签没被收走(AI 释放后还留着)'
|
|
1449
|
+
if (!held.stillThere || held.count !== 2) return '正在看的 agent 标签被当场收走了(少了那一步台阶)'
|
|
1450
|
+
if (!held.text.includes('AI 已结束') && !held.text.includes('The AI finished')) {
|
|
1451
|
+
return '提示条没说明「AI 已结束这个会话」:' + held.text
|
|
1452
|
+
}
|
|
1453
|
+
if (!closedOnSwitch) return '切走之后台阶没结束:标签还在'
|
|
1454
|
+
if (!shotState.text.includes('AI 已结束') && !shotState.text.includes('The AI finished')) {
|
|
1455
|
+
return '台阶态的提示条文案不对:' + shotState.text
|
|
1456
|
+
}
|
|
1457
|
+
if (!/收起|Dismiss/.test(shotState.dismiss)) return '台阶态没有「收起」这颗按钮:' + shotState.dismiss
|
|
1458
|
+
return null
|
|
1459
|
+
}
|
|
1460
|
+
},
|
|
1461
|
+
/*
|
|
1462
|
+
* 4b 现场回归(2026-10-10 真机验收):**客户端自己顶上去的活动标签不算「正在看」**。
|
|
1463
|
+
*
|
|
1464
|
+
* 现场:AI 在后台开了一条 `sleep 300`,随即 `tty_close`;工具侧确实没有这条会话了,
|
|
1465
|
+
* 可标签栏里还留着它——「标签退出了,但是还在」。根因不在删除逻辑,而在台阶的判据:
|
|
1466
|
+
* 那一刻它的 `sid === activeSid` 成立(客户端自己把它顶成了活动标签:关掉一个标签时
|
|
1467
|
+
* `closeTab` 会选一个邻居、`adoptAgentSessions` 在没有活动标签时也会切过去),于是走
|
|
1468
|
+
* 了「AI 已结束这个会话 + 收起」那条保留分支。
|
|
1469
|
+
*
|
|
1470
|
+
* 修法:台阶只认**用户自己切过**的标签(`switchTab(sid, { user: true })` 打的
|
|
1471
|
+
* `watched` 标记)。本场景正是那条路:先让关标签的「邻居选择」把 agent 标签顶成活动
|
|
1472
|
+
* 标签(没有用户点击),再释放它——**必须直接收走**。
|
|
1473
|
+
* 反向验证:把 `holdReleasedTab` 里的 `tab.watched !== true` 去掉 → 本场景红。
|
|
1474
|
+
*/
|
|
1475
|
+
async 'agent-release-neighbor'() {
|
|
1476
|
+
await openPanel()
|
|
1477
|
+
// 再开一个用户标签(两个用户标签 + 一个后台 agent 标签,面板不会因为清空而关掉)
|
|
1478
|
+
await clickAdd()
|
|
1479
|
+
await clickMenuItem('本地终端', 'Local terminal')
|
|
1480
|
+
await waitFor(() => tabs().length === 2, 4000)
|
|
1481
|
+
const agentOf = (sid) => ({ sid, owner: 'agent', kind: 'local', cwd: window.__PREVIEW_CWD, attachable: true })
|
|
1482
|
+
emit({ t: 'sessions', list: sessionList([agentOf('agent-nb')]) })
|
|
1483
|
+
await waitFor(() => qa('.tt_tab').some((el) => sidOf(el) === 'agent-nb'), 4000)
|
|
1484
|
+
await sleep(200)
|
|
1485
|
+
const adopted = {
|
|
1486
|
+
tabs: tabs().length,
|
|
1487
|
+
agentIsActive: sidOf(activeTabEl()) === 'agent-nb',
|
|
1488
|
+
}
|
|
1489
|
+
// 关掉当前活动标签:closeTab 会选「最后一个标签」当邻居 = 刚采纳的 agent 标签
|
|
1490
|
+
const activeClose = activeTabEl().querySelector('.tt_tabClose')
|
|
1491
|
+
activeClose.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
1492
|
+
await sleep(250)
|
|
1493
|
+
const promoted = {
|
|
1494
|
+
active: sidOf(activeTabEl()),
|
|
1495
|
+
tabs: tabs().length,
|
|
1496
|
+
}
|
|
1497
|
+
// AI 释放它(宿主表里撤掉)→ 必须直接收走,不能留台阶
|
|
1498
|
+
emit({ t: 'sessions', list: sessionList() })
|
|
1499
|
+
await sleep(350)
|
|
1500
|
+
window.__previewAssert = async () => {
|
|
1501
|
+
if (adopted.tabs !== 3) return '夹具失效:期望 3 个标签,实际 ' + String(adopted.tabs)
|
|
1502
|
+
if (adopted.agentIsActive) return '夹具失效:agent 标签一开始就该在后台'
|
|
1503
|
+
if (promoted.active !== 'agent-nb') return '夹具失效:关标签的邻居选择没把 agent 标签顶成活动标签(' + String(promoted.active) + ')'
|
|
1504
|
+
if (qa('.tt_tab').some((el) => sidOf(el) === 'agent-nb')) {
|
|
1505
|
+
return 'AI 释放的是**客户端自己顶上去**的活动标签,标签却留下了(4b 现场:标签退出了但还在)'
|
|
1506
|
+
}
|
|
1507
|
+
const bar = q('.tt_exitBar')
|
|
1508
|
+
if (bar !== null && bar.hidden !== true && says(bar, 'AI 已结束', 'The AI finished')) {
|
|
1509
|
+
return '错误地走了「AI 已结束 + 收起」的保留分支'
|
|
1510
|
+
}
|
|
1511
|
+
if (tabs().length !== 1) return '收走之后标签数不对:' + String(tabs().length)
|
|
1512
|
+
return null
|
|
1513
|
+
}
|
|
1514
|
+
},
|
|
1515
|
+
/*
|
|
1516
|
+
* issue #8:点 ✕ 关面板时,有活会话就先问一句。
|
|
1517
|
+
*
|
|
1518
|
+
* 判据是「这一下会结束几条**活会话**」,不是「开了几个标签」:本场景只有一条活会话
|
|
1519
|
+
* 也必须问;而「只剩已退出的标签」时不该问(那条由 `close-confirm-empty` 钉)。
|
|
1520
|
+
* 「取消不关面板 / 不结束会话」这一段在场景体里做(断言必须只读:它跑在截图之前,
|
|
1521
|
+
* 在断言里点掉确认会让截图变成点完的样子)。
|
|
1522
|
+
*/
|
|
1523
|
+
async 'close-confirm'() {
|
|
1524
|
+
await openPanel()
|
|
1525
|
+
await sleep(120)
|
|
1526
|
+
q('.tt_close').click()
|
|
1527
|
+
await waitFor(() => q('.tt_confirmLayer'))
|
|
1528
|
+
await sleep(150)
|
|
1529
|
+
// 先验证「取消」这条路,再重新打开确认层留给截图
|
|
1530
|
+
const before = tabs().length
|
|
1531
|
+
q('.tt_confirmCancel').click()
|
|
1532
|
+
await sleep(150)
|
|
1533
|
+
const afterCancel = { layer: q('.tt_confirmLayer') !== null, panel: modal() !== null, tabs: tabs().length, before }
|
|
1534
|
+
q('.tt_close').click()
|
|
1535
|
+
await waitFor(() => q('.tt_confirmLayer'))
|
|
1536
|
+
await sleep(150)
|
|
1537
|
+
// 再验证「点层内空白 = 取消」(下面留给截图的那一份重新打开)
|
|
1538
|
+
q('.tt_confirmLayer').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
1539
|
+
await sleep(150)
|
|
1540
|
+
const afterBlank = { layer: q('.tt_confirmLayer') !== null, panel: modal() !== null }
|
|
1541
|
+
q('.tt_close').click()
|
|
1542
|
+
await waitFor(() => q('.tt_confirmLayer'))
|
|
1543
|
+
await sleep(150)
|
|
1544
|
+
/*
|
|
1545
|
+
* 第三颗按钮「最小化」(2026-10-10,用户看过截图后加的):这次的错点往往不是「结束」,
|
|
1546
|
+
* 而是「想收起来」——而确认层盖住整个视口,hint 里那句「点「—」」在这时候根本点不到。
|
|
1547
|
+
* 所以把它放到手边,并在这里钉死**它只能收起、绝不能结束会话**:接成 `closeModal` 的话
|
|
1548
|
+
* 这条断言会当场红(这是本仓最该拦住的一类事故:把安全出路做成破坏按钮)。
|
|
1549
|
+
* 走完这一步把面板恢复出来,最后再开一次确认层留给截图。
|
|
1550
|
+
*/
|
|
1551
|
+
const minBtnExists = q('.tt_confirmMin') !== null
|
|
1552
|
+
const minLabel = minBtnExists ? String(q('.tt_confirmMin').textContent) : ''
|
|
1553
|
+
const beforeMin = tabs().length
|
|
1554
|
+
if (minBtnExists) q('.tt_confirmMin').click()
|
|
1555
|
+
await sleep(200)
|
|
1556
|
+
const afterMin = {
|
|
1557
|
+
layer: q('.tt_confirmLayer') !== null,
|
|
1558
|
+
minimized: isMinimized(),
|
|
1559
|
+
panelAlive: modal() !== null,
|
|
1560
|
+
tabs: tabs().length,
|
|
1561
|
+
before: beforeMin,
|
|
1562
|
+
}
|
|
1563
|
+
// 恢复面板:点侧边栏入口(收起态下它就是「恢复」那条路)
|
|
1564
|
+
entry().click()
|
|
1565
|
+
await waitFor(() => !isMinimized())
|
|
1566
|
+
await sleep(150)
|
|
1567
|
+
q('.tt_close').click()
|
|
1568
|
+
await waitFor(() => q('.tt_confirmLayer'))
|
|
1569
|
+
await sleep(150)
|
|
1570
|
+
window.__previewAssert = async () => {
|
|
1571
|
+
if (afterCancel.layer) return '点「取消」没关掉确认层'
|
|
1572
|
+
if (!afterCancel.panel) return '点「取消」把面板关掉了'
|
|
1573
|
+
if (afterCancel.tabs !== afterCancel.before) return '点「取消」把会话结束了'
|
|
1574
|
+
if (afterBlank.layer) return '点确认层里的空白没关掉它(对话框的常规手感)'
|
|
1575
|
+
if (!afterBlank.panel) return '点空白把面板也关掉了'
|
|
1576
|
+
if (!minBtnExists) return '确认框里没有「最小化」这颗按钮(用户看过截图后要求加的那颗)'
|
|
1577
|
+
if (!/最小化|Minimize/.test(minLabel)) return '第三颗按钮不是「最小化」:' + minLabel
|
|
1578
|
+
if (afterMin.layer) return '点「最小化」没关掉确认层'
|
|
1579
|
+
if (!afterMin.minimized) return '点「最小化」没把面板收起来'
|
|
1580
|
+
if (!afterMin.panelAlive) return '点「最小化」把面板 DOM 也拆了(那等于关了面板)'
|
|
1581
|
+
if (afterMin.tabs !== afterMin.before) return '点「最小化」把会话结束了:' + String(afterMin.before) + ' → ' + String(afterMin.tabs)
|
|
1582
|
+
const layer = q('.tt_confirmLayer')
|
|
1583
|
+
if (layer === null) return '有活会话时点 ✕ 没弹确认'
|
|
1584
|
+
const okText = (q('.tt_confirmOk') || {}).textContent || ''
|
|
1585
|
+
if (!okText.includes('1')) return '主按钮没写明会结束几条会话:' + okText
|
|
1586
|
+
if (!says(layer, '最小化', 'Minimize')) return '确认里没给出「最小化」这条出路(hint 与按钮都算)'
|
|
1587
|
+
/*
|
|
1588
|
+
* issue #8 的口径守卫(2026-10-10 核对挖出来的那处出入):判据是「活会话」= **还开着**
|
|
1589
|
+
* (`close-guard.js` 只看 `exited !== true`),**判不出命令在不在跑**——面板上挂着一个停在
|
|
1590
|
+
* 提示符的空 shell,与跑着 `pnpm build` 的那条,在判据眼里一模一样。所以文案不许把这批会话
|
|
1591
|
+
* 说成「正在运行 / running sessions」:那是替实现吹一个它做不到的牛,报告者照回帖原文验收时
|
|
1592
|
+
* 就会发现对不上。要改这条断言,先把「在跑」这个态真的接上线(宿主已有 `runningOf`,见
|
|
1593
|
+
* `src/index.ts` 与 ROADMAP 的 0.28.0 那节),别直接改字。
|
|
1594
|
+
*
|
|
1595
|
+
* 措辞本身改过一轮(2026-10-10 晚些,用户看过右键菜单截图后问「什么叫还没退出」):
|
|
1596
|
+
* 「还没退出」→「还开着」——**判据一字未动**,只是把内部判据词换成用户词(与菜单里
|
|
1597
|
+
* 已有的「已退出」成对)。下面那句正向断言跟着改,正向要钉的仍是「不许超出判据」。
|
|
1598
|
+
*/
|
|
1599
|
+
const layerText = String(layer.textContent || '')
|
|
1600
|
+
if (/正在运行|running sessions|are running/.test(layerText)) {
|
|
1601
|
+
return '确认文案把这批会话说成「正在运行」了,而判据只是「还开着」(#8 口径):' + layerText
|
|
1602
|
+
}
|
|
1603
|
+
if (!says(layer, '还开着', 'still open')) return '确认文案没说清这批会话是「还开着」的:' + layerText
|
|
1604
|
+
// ✕ 的 tooltip 是同一句话的另一个出口(`btn.closePanelTitle`),一并钉
|
|
1605
|
+
const closeTitle = (q('.tt_close') || {}).title || ''
|
|
1606
|
+
if (/在跑|正在运行|running sessions/.test(closeTitle)) {
|
|
1607
|
+
return '✕ 的 tooltip 说成「在跑 / 正在运行」了(#8 口径同样只到「还开着」):' + closeTitle
|
|
1608
|
+
}
|
|
1609
|
+
return null
|
|
1610
|
+
}
|
|
1611
|
+
},
|
|
1612
|
+
/*
|
|
1613
|
+
* 关面板确认的反面:只剩已退出的标签时**不问**,直接关。
|
|
1614
|
+
*
|
|
1615
|
+
* 为什么要有这一条:判据若写成「有标签就问」,用户关掉一个跑完的终端也会被拦一次,
|
|
1616
|
+
* 确认会被点成习惯(那时真正的拦阻就失效了)。反向验证:把判据换成 `tabs.size > 0`
|
|
1617
|
+
* → 本场景红。
|
|
1618
|
+
*/
|
|
1619
|
+
async 'close-confirm-empty'() {
|
|
1620
|
+
await openPanel()
|
|
1621
|
+
const el = activeTabEl()
|
|
1622
|
+
emit({ t: 'exit', sid: sidOf(el), code: 0 })
|
|
1623
|
+
await sleep(200)
|
|
1624
|
+
q('.tt_close').click()
|
|
1625
|
+
await sleep(250)
|
|
1626
|
+
window.__previewAssert = async () => {
|
|
1627
|
+
if (q('.tt_confirmLayer') !== null) return '只剩已退出的标签也弹了确认(纯噪音)'
|
|
1628
|
+
if (modal() !== null) return '没有活会话时点 ✕ 没关掉面板'
|
|
1629
|
+
return null
|
|
1630
|
+
}
|
|
1142
1631
|
},
|
|
1143
1632
|
/* 连接错误遮罩 */
|
|
1144
1633
|
async error() {
|
|
@@ -1895,6 +2384,203 @@
|
|
|
1895
2384
|
return null
|
|
1896
2385
|
}
|
|
1897
2386
|
},
|
|
2387
|
+
|
|
2388
|
+
/*
|
|
2389
|
+
* 批量关标签 ①(0.29.0):用户报告「现在一个个关闭体验不是特别好」。
|
|
2390
|
+
*
|
|
2391
|
+
* 「⋯」的显隐判据从「溢出 **或** 有死标签」再放宽一格到「**标签 ≥ 2 个就在**」——
|
|
2392
|
+
* 它现在同时是「关闭其他 / 关闭左侧 / 关闭右侧」的可见入口(右键是快路径,但不该是唯一出路)。
|
|
2393
|
+
* 门槛正好留在 2:只剩一个标签时没有任何可管理的对象,仍要藏起来(D89 的「单标签时
|
|
2394
|
+
* 『+』紧贴页签」和 tab-list 里那条「不溢出时不该可见」也正因此原样成立)。
|
|
2395
|
+
*
|
|
2396
|
+
* 钉三件事:① 单标签时入口不可见;② 3 个标签、**不溢出**时它也在;③ 菜单里有
|
|
2397
|
+
* 「关闭其他标签(2)」,而活动标签是最后一个 → **「关闭右侧」这一行不该出现**
|
|
2398
|
+
* (目标为空的行不出现,点了才不会空转)。
|
|
2399
|
+
*/
|
|
2400
|
+
async 'tab-bulk-menu'() {
|
|
2401
|
+
window.__PREVIEW_CONFIG.maxSessions = 12
|
|
2402
|
+
await openPanel()
|
|
2403
|
+
await sleep(200)
|
|
2404
|
+
const moreAtOneTab = q('.tt_tabMore')
|
|
2405
|
+
const visibleAtOneTab = moreAtOneTab === null ? null : moreAtOneTab.getBoundingClientRect().width > 0
|
|
2406
|
+
for (let i = 0; i < 2; i += 1) {
|
|
2407
|
+
await clickAdd()
|
|
2408
|
+
await clickMenuItem('本地终端', 'Local terminal')
|
|
2409
|
+
await waitFor(() => tabs().length === i + 2, 4000)
|
|
2410
|
+
}
|
|
2411
|
+
await sleep(220)
|
|
2412
|
+
const strip = q('.tt_tabs')
|
|
2413
|
+
const overflow = strip === null ? null : strip.scrollWidth > strip.clientWidth + 1
|
|
2414
|
+
const more = q('.tt_tabMore')
|
|
2415
|
+
const moreVisible = more === null ? null : more.getBoundingClientRect().width > 0
|
|
2416
|
+
if (moreVisible === true) {
|
|
2417
|
+
more.click()
|
|
2418
|
+
await waitFor(() => q('.tt_tabMenu'))
|
|
2419
|
+
await sleep(120)
|
|
2420
|
+
}
|
|
2421
|
+
window.__previewAssert = async () => {
|
|
2422
|
+
if (tabs().length !== 3) return '夹具失效:期望 3 个标签,实际 ' + String(tabs().length)
|
|
2423
|
+
if (visibleAtOneTab !== false) return '只有一个标签时「⋯」不该可见(实测 ' + String(visibleAtOneTab) + ')'
|
|
2424
|
+
if (overflow !== false) return '夹具失效:三个标签就溢出了,量不到「不溢出也常驻」'
|
|
2425
|
+
if (moreVisible !== true) return '标签 ≥ 2 个时「⋯」没出现(批量入口够不着)'
|
|
2426
|
+
const menu = q('.tt_tabMenu')
|
|
2427
|
+
if (menu === null) return '「⋯」菜单没打开'
|
|
2428
|
+
const row = (zh, en) => qa('.tt_addMenuItem').find((el) => says(el, zh, en))
|
|
2429
|
+
const others = row('关闭其他标签', 'Close other tabs')
|
|
2430
|
+
if (others === undefined) return '「⋯」里没有「关闭其他标签」这一行'
|
|
2431
|
+
if (!others.textContent.includes('2')) return '「关闭其他」没写明条数:' + String(others.textContent)
|
|
2432
|
+
// 批量行副文案是同一口径的第四个出口(`list.subLive`):同样只能说「还开着」
|
|
2433
|
+
if (/正在运行|are running/.test(String(others.textContent))) {
|
|
2434
|
+
return '批量行副文案说成「正在运行」了(#8 口径只到「还开着」):' + String(others.textContent)
|
|
2435
|
+
}
|
|
2436
|
+
if (!says(others, '还开着', 'still open')) {
|
|
2437
|
+
return '批量行副文案没说清这批标签是「还开着」的:' + String(others.textContent)
|
|
2438
|
+
}
|
|
2439
|
+
// 活动标签是最后一个 → 「关闭左侧」有目标(左边两个)、「关闭右侧」没有
|
|
2440
|
+
const left = row('关闭左侧标签', 'Close tabs to the left')
|
|
2441
|
+
if (left === undefined) return '活动标签在末尾时「关闭左侧标签」该出现(左边还有两个)'
|
|
2442
|
+
if (!left.textContent.includes('2')) return '「关闭左侧」条数不对:' + String(left.textContent)
|
|
2443
|
+
if (row('关闭右侧标签', 'Close tabs to the right') !== undefined) {
|
|
2444
|
+
return '活动标签是最后一个,「关闭右侧」这一行不该出现(目标为空 = 点了空转)'
|
|
2445
|
+
}
|
|
2446
|
+
if (row('清理已退出', 'Clean up exited') !== undefined) return '没有已退出的标签,不该有清理行'
|
|
2447
|
+
return null
|
|
2448
|
+
}
|
|
2449
|
+
},
|
|
2450
|
+
|
|
2451
|
+
/*
|
|
2452
|
+
* 批量关标签 ②(0.29.0):**右键**一个标签 = 以它为参照。
|
|
2453
|
+
*
|
|
2454
|
+
* 为什么是右键而不是「+」后面再加一个菜单按钮:「关闭其他 / 关闭左侧 / 关闭右侧」必须以
|
|
2455
|
+
* 某个标签为参照,而「+」本身就是一个菜单触发器(`openAddMenu`),两个挨着的内容不同的
|
|
2456
|
+
* 菜单按钮是明确的歧义源(理由全文见 client-src 的 `openTabContextMenu`)。
|
|
2457
|
+
*
|
|
2458
|
+
* 这里右键**中间**那个(3 个标签):菜单要同时有「关闭此标签」「关闭其他标签(2)」
|
|
2459
|
+
* 「关闭左侧标签(1)」「关闭右侧标签(1)」,且落在光标附近而不是被夹到左上角。
|
|
2460
|
+
*/
|
|
2461
|
+
async 'tab-context-menu'() {
|
|
2462
|
+
window.__PREVIEW_CONFIG.maxSessions = 12
|
|
2463
|
+
await openPanel()
|
|
2464
|
+
for (let i = 0; i < 2; i += 1) {
|
|
2465
|
+
await clickAdd()
|
|
2466
|
+
await clickMenuItem('本地终端', 'Local terminal')
|
|
2467
|
+
await waitFor(() => tabs().length === i + 2, 4000)
|
|
2468
|
+
}
|
|
2469
|
+
await sleep(220)
|
|
2470
|
+
const all = tabs()
|
|
2471
|
+
const mid = all[1]
|
|
2472
|
+
const rect = mid.getBoundingClientRect()
|
|
2473
|
+
const at = { x: Math.round(rect.left + 24), y: Math.round(rect.bottom) }
|
|
2474
|
+
mid.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: at.x, clientY: at.y }))
|
|
2475
|
+
await waitFor(() => q('.tt_tabCtxMenu'))
|
|
2476
|
+
await sleep(120)
|
|
2477
|
+
window.__previewAssert = async () => {
|
|
2478
|
+
if (tabs().length !== 3) return '夹具失效:期望 3 个标签,实际 ' + String(tabs().length)
|
|
2479
|
+
const menu = q('.tt_tabCtxMenu')
|
|
2480
|
+
if (menu === null) return '右键标签没弹出菜单'
|
|
2481
|
+
if (q('.tt_tabMenu') !== null) return '右键时「⋯」列表还挂着(两个浮层叠在一起)'
|
|
2482
|
+
if (q('.tt_addMenu:not(.tt_tabCtxMenu)') !== null) return '右键时「+」菜单还挂着(两个浮层叠在一起)'
|
|
2483
|
+
const row = (zh, en) => qa('.tt_addMenuItem').find((el) => says(el, zh, en))
|
|
2484
|
+
if (row('关闭此标签', 'Close this tab') === undefined) return '菜单里没有「关闭此标签」'
|
|
2485
|
+
const others = row('关闭其他标签', 'Close other tabs')
|
|
2486
|
+
if (others === undefined) return '菜单里没有「关闭其他标签」'
|
|
2487
|
+
if (!others.textContent.includes('2')) return '「关闭其他」条数不对(参照在中间,另有两个):' + String(others.textContent)
|
|
2488
|
+
const left = row('关闭左侧标签', 'Close tabs to the left')
|
|
2489
|
+
if (left === undefined) return '参照在中间时「关闭左侧」该出现'
|
|
2490
|
+
if (!left.textContent.includes('1')) return '「关闭左侧」条数不对(参照左边还有一个):' + String(left.textContent)
|
|
2491
|
+
const right = row('关闭右侧标签', 'Close tabs to the right')
|
|
2492
|
+
if (right === undefined) return '参照在中间时「关闭右侧」该出现'
|
|
2493
|
+
if (!right.textContent.includes('1')) return '「关闭右侧」条数不对(参照右边还有一个):' + String(right.textContent)
|
|
2494
|
+
const box = menu.getBoundingClientRect()
|
|
2495
|
+
if (Math.abs(box.left - at.x) > 320 || Math.abs(box.top - at.y) > 120) {
|
|
2496
|
+
return '菜单没落在光标附近:光点 (' + String(at.x) + ',' + String(at.y) + ') → 菜单 (' + String(Math.round(box.left)) + ',' + String(Math.round(box.top)) + ')'
|
|
2497
|
+
}
|
|
2498
|
+
return null
|
|
2499
|
+
}
|
|
2500
|
+
},
|
|
2501
|
+
|
|
2502
|
+
/*
|
|
2503
|
+
* 批量关标签 ③(0.29.0):功能面 + 确认层。
|
|
2504
|
+
*
|
|
2505
|
+
* 右键第 1 个 → 「关闭其他标签(2)」:这一下要结束 **2 条活会话**,所以先弹确认
|
|
2506
|
+
* (判据在 `client-src/tab-bulk.js`:只关一条与点它自己的 ✕ 等价,不弹);点「结束」后
|
|
2507
|
+
* 只留下被右键的那一个,**而且它要成为活动标签**(关掉活动标签时 closeTab 自己会接续)。
|
|
2508
|
+
*/
|
|
2509
|
+
async 'tab-context-close-others'() {
|
|
2510
|
+
window.__PREVIEW_CONFIG.maxSessions = 12
|
|
2511
|
+
await openPanel()
|
|
2512
|
+
for (let i = 0; i < 2; i += 1) {
|
|
2513
|
+
await clickAdd()
|
|
2514
|
+
await clickMenuItem('本地终端', 'Local terminal')
|
|
2515
|
+
await waitFor(() => tabs().length === i + 2, 4000)
|
|
2516
|
+
}
|
|
2517
|
+
await sleep(220)
|
|
2518
|
+
const refSid = sidOf(tabs()[0])
|
|
2519
|
+
const rect = tabs()[0].getBoundingClientRect()
|
|
2520
|
+
tabs()[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: Math.round(rect.left + 24), clientY: Math.round(rect.bottom) }))
|
|
2521
|
+
await waitFor(() => q('.tt_tabCtxMenu'))
|
|
2522
|
+
await sleep(120)
|
|
2523
|
+
const others = await waitFor(() => qa('.tt_addMenuItem').find((el) => says(el, '关闭其他标签', 'Close other tabs')))
|
|
2524
|
+
others.click()
|
|
2525
|
+
await waitFor(() => q('.tt_confirmLayer'))
|
|
2526
|
+
await sleep(150)
|
|
2527
|
+
const layer = q('.tt_confirmLayer')
|
|
2528
|
+
const title = layer === null ? '' : String(layer.querySelector('.tt_confirmTitle').textContent)
|
|
2529
|
+
const okText = layer === null ? '' : String(layer.querySelector('.tt_confirmOk').textContent)
|
|
2530
|
+
if (layer !== null) layer.querySelector('.tt_confirmOk').click()
|
|
2531
|
+
await sleep(320)
|
|
2532
|
+
const left = tabs()
|
|
2533
|
+
const state = {
|
|
2534
|
+
count: left.length,
|
|
2535
|
+
kept: left.length === 1 ? sidOf(left[0]) : null,
|
|
2536
|
+
active: sidOf(q('.tt_tab[data-active]') || {}),
|
|
2537
|
+
ctxMenu: q('.tt_tabCtxMenu') !== null,
|
|
2538
|
+
}
|
|
2539
|
+
window.__previewAssert = async () => {
|
|
2540
|
+
if (state.count !== 1) return '「关闭其他」没关干净:还剩 ' + String(state.count) + ' 个标签'
|
|
2541
|
+
if (state.kept !== refSid) return '留下的不是被右键的那个标签(' + String(state.kept) + ' ≠ ' + String(refSid) + ')'
|
|
2542
|
+
if (state.active !== refSid) return '关完之后活动标签没接续到留下的那个'
|
|
2543
|
+
if (state.ctxMenu) return '关完之后右键菜单还挂着'
|
|
2544
|
+
if (!title.includes('2')) return '确认层没写清这一下要结束几条会话:' + title
|
|
2545
|
+
if (!okText.includes('2')) return '确认按钮没写清条数:' + okText
|
|
2546
|
+
return null
|
|
2547
|
+
}
|
|
2548
|
+
},
|
|
2549
|
+
|
|
2550
|
+
/*
|
|
2551
|
+
* 批量关标签 ④(0.29.0):中键 = 直接关(浏览器 / 编辑器的通用手感,零新增 UI)。
|
|
2552
|
+
* 与 ✕ 同一条落点(`closeTab`),所以**不问**。
|
|
2553
|
+
*/
|
|
2554
|
+
async 'tab-mid-click'() {
|
|
2555
|
+
window.__PREVIEW_CONFIG.maxSessions = 12
|
|
2556
|
+
await openPanel()
|
|
2557
|
+
for (let i = 0; i < 2; i += 1) {
|
|
2558
|
+
await clickAdd()
|
|
2559
|
+
await clickMenuItem('本地终端', 'Local terminal')
|
|
2560
|
+
await waitFor(() => tabs().length === i + 2, 4000)
|
|
2561
|
+
}
|
|
2562
|
+
await sleep(220)
|
|
2563
|
+
const victim = sidOf(tabs()[1])
|
|
2564
|
+
const before = tabs().length
|
|
2565
|
+
tabs()[1].dispatchEvent(new MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 1 }))
|
|
2566
|
+
await sleep(300)
|
|
2567
|
+
const after = tabs().length
|
|
2568
|
+
const gone = !tabs().some((el) => sidOf(el) === victim)
|
|
2569
|
+
const askLayer = q('.tt_confirmLayer') !== null
|
|
2570
|
+
// 再中键一次量「连着关」(留下 1 个标签:截图要看清的还是标签栏)
|
|
2571
|
+
const second = tabs()[1]
|
|
2572
|
+
if (second !== undefined) second.dispatchEvent(new MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 1 }))
|
|
2573
|
+
await sleep(300)
|
|
2574
|
+
const finalCount = tabs().length
|
|
2575
|
+
window.__previewAssert = async () => {
|
|
2576
|
+
if (before !== 3) return '夹具失效:期望 3 个标签,实际 ' + String(before)
|
|
2577
|
+
if (!gone) return '中键没有关掉标签'
|
|
2578
|
+
if (after !== 2) return '中键关了不止一个标签:' + String(after)
|
|
2579
|
+
if (askLayer) return '中键关一个标签不该弹确认(与点 ✕ 同一条路)'
|
|
2580
|
+
if (finalCount !== 1) return '连着中键关两次只该剩 1 个标签,实际 ' + String(finalCount)
|
|
2581
|
+
return null
|
|
2582
|
+
}
|
|
2583
|
+
},
|
|
1898
2584
|
}
|
|
1899
2585
|
|
|
1900
2586
|
const name = new URLSearchParams(location.search).get('scenario') || 'local'
|