@yfwu2020/dsh-selection-explain 0.9.1 → 0.10.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/scripts/build.sh CHANGED
@@ -126,5 +126,22 @@ node --check src/client/index.js
126
126
  mkdir -p lib
127
127
  cp src/client/index.js lib/client.js
128
128
 
129
+ # ── ⑤ 原生探针(macOS 专用)──
130
+ # 「检测输入法正在语音输入」靠读 CoreAudio 的进程对象列表(macOS 14.4+),
131
+ # Node 没有 FFI,所以编一个小二进制放进 lib/native/,host 需要时 spawn 它
132
+ # (见 src/index.ts 里的 micPresence)。
133
+ # 编不出来**不算构建失败**:功能降级成 available:false(和"没装识别服务"一个待遇),
134
+ # 其余功能一律不受影响;非 macOS 直接跳过。
135
+ mkdir -p lib/native
136
+ if [ "$(uname -s)" = "Darwin" ] && command -v clang >/dev/null 2>&1; then
137
+ if clang -O2 -framework CoreAudio -framework CoreFoundation native/mic-probe.c -o lib/native/mic-probe; then
138
+ echo "=== 原生探针:lib/native/mic-probe ==="
139
+ else
140
+ echo "build: 原生探针编译失败 —— 麦克风占用检测将不可用(不影响其它功能)" >&2
141
+ fi
142
+ else
143
+ echo "build: 跳过原生探针(非 macOS 或没有 clang)—— 麦克风占用检测不可用"
144
+ fi
145
+
129
146
  echo "=== 构建完成 ==="
130
147
  ls -l lib
@@ -0,0 +1,9 @@
1
+ #!/bin/sh
2
+ # 假探针(宿主生命周期测试用):只记两笔 —— 起来了 / 被杀了。
3
+ # 真探针要读 CoreAudio,测试里既没必要也跑不稳;这里要验的是**进程什么时候在**。
4
+ echo "start $*" >> "$DSH_SEL_FAKE_PROBE_LOG"
5
+ echo '{"capturing":false,"processes":[]}'
6
+ trap 'echo stop >> "$DSH_SEL_FAKE_PROBE_LOG"; exit 0' TERM
7
+ # 注意:POSIX sh 里 trap 要等**当前前台命令**跑完才执行,所以这个等待必须短 ——
8
+ # 用 sleep 1 的话,SIGTERM 之后最多要 1 秒才写得出 stop,测试会误判成"没停"。
9
+ while true; do sleep 0.1; done
@@ -49,6 +49,9 @@ const PAGE = `<!doctype html>
49
49
  </style></head>
50
50
  <body>
51
51
  <div id="doc"><p id="docp">Dev: the migration ran long, so we ship Wednesday.</p></div>
52
+ <!-- "别处正在打字"的输入框:必须 fixed 定位 —— 放进文档流会挪动上面那段文字的坐标,
53
+ 而这一页里有一族"依赖坐标的划词用例"(见下面 ① 的注释),挪一下就会撞红。 -->
54
+ <textarea id="other" style="position:fixed;right:8px;bottom:8px;width:150px;height:34px">别处正在打字</textarea>
52
55
  <div id="mount"></div>
53
56
  <pre id="out">PENDING</pre>
54
57
  <script>
@@ -91,12 +94,18 @@ function sse(events) {
91
94
  var body = events.map(function (e) { return 'data: ' + JSON.stringify(e) + '\\n\\n' }).join('')
92
95
  return new Response(body, { headers: { 'content-type': 'text/event-stream' } })
93
96
  }
97
+ // 麦克风占用:默认"没人在录"。⑫ 交接用例要它报"豆包在录"才能把语音卡唤出来。
98
+ var micState = { ok: true, available: true, capturing: false, processes: [], reason: '' }
94
99
  var pillPosStore = null // 内存版 host 存储(/api/pill)
95
100
  var panelSizeStore = null // 内存版 host 存储(/api/panel,只存尺寸)
96
101
  window.fetch = function (input, init) {
97
102
  var url = String(input)
98
103
  var body = {}
99
104
  try { body = init && init.body ? JSON.parse(String(init.body)) : {} } catch (error) { body = {} }
105
+ if (url.indexOf('/selection-explain/api/mic') >= 0) {
106
+ window.__micHits = (window.__micHits || 0) + 1
107
+ return Promise.resolve(new Response(JSON.stringify(micState), { headers: { 'content-type': 'application/json' } }))
108
+ }
100
109
  if (url.indexOf('/selection-explain/api/ping') >= 0) {
101
110
  return Promise.resolve(new Response(JSON.stringify({ ok: true, route: { provider: 'fixture', model: 'fixture' }, reasoningEffortByStage: { translation: 'low', detail: 'high', chat: 'high' }, limits: {} }), { headers: { 'content-type': 'application/json' } }))
102
111
  }
@@ -285,8 +294,23 @@ async function main() {
285
294
  var range = selectIn(target.node, target.needle)
286
295
  check(label + ':能建出真选区', !!range, range ? range.toString().slice(0, 24) : '未找到目标文字')
287
296
  if (!range) return null
288
- mouseUpOn(target.node)
289
- var shown = await waitFor(function () { return hook.quoteState().visible === true }, 1500)
297
+ // ⚠️ 合成选区 vs 面板渲染有竞态:面板晚一步重绘会把刚选中的节点换掉,插件据此判定
298
+ // "那段文字没了"并收起浮标(这是对的),用例于是偶发红 —— 真浏览器冒烟在 CI 与本机
299
+ // 都复现过(改动前的基线同样 192/2)。所以这里**重试合成选区**(最多 3 次,每次都重新
300
+ // 取节点、重建 range),而不是放宽断言。
301
+ var shown = false
302
+ for (var attempt = 0; attempt < 3 && !shown; attempt += 1) {
303
+ if (attempt > 0) {
304
+ await settlePanel()
305
+ var fresh = find()
306
+ if (!fresh || !fresh.node) break
307
+ target = fresh
308
+ range = selectIn(target.node, target.needle)
309
+ if (!range) break
310
+ }
311
+ mouseUpOn(target.node)
312
+ shown = await waitFor(function () { return hook.quoteState().visible === true }, 1500)
313
+ }
290
314
  check(label + ':浮出「❝ 引用」', shown === true, JSON.stringify(hook.quoteState()))
291
315
  if (!shown) return null
292
316
  check(label + ':浮标在最上层(不被面板盖住)', onTop(quoteBtn), '')
@@ -613,15 +637,27 @@ async function main() {
613
637
  var node
614
638
  while ((node = walker.nextNode())) if (node.nodeValue.trim()) texts.push(node)
615
639
  if (texts.length && to.firstChild) {
616
- var range = document.createRange()
617
- range.setStart(texts[0], 0)
618
- var endNode = to.firstChild.nodeType === 3 ? to.firstChild : to
619
- range.setEnd(endNode, Math.min(4, (endNode.nodeValue || '').length))
620
- var sel = window.getSelection()
621
- sel.removeAllRanges()
622
- sel.addRange(range)
623
- mouseUpOn(to)
624
- var shown = await waitFor(function () { return hook.quoteState().visible === true }, 1500)
640
+ // 与 quoteOnce 同一套重试(合成选区 vs 面板渲染的竞态)
641
+ var shown = false
642
+ for (var attempt2 = 0; attempt2 < 3 && !shown; attempt2 += 1) {
643
+ if (attempt2 > 0) await settlePanel()
644
+ var fromNow = sectionContent('translation')
645
+ var toNow = sectionContent('detail')
646
+ var textsNow = []
647
+ var walker2 = document.createTreeWalker(fromNow, NodeFilter.SHOW_TEXT, null)
648
+ var nodeNow
649
+ while ((nodeNow = walker2.nextNode())) if (nodeNow.nodeValue.trim()) textsNow.push(nodeNow)
650
+ if (!textsNow.length || !toNow.firstChild) break
651
+ var range = document.createRange()
652
+ range.setStart(textsNow[0], 0)
653
+ var endNode = toNow.firstChild.nodeType === 3 ? toNow.firstChild : toNow
654
+ range.setEnd(endNode, Math.min(4, (endNode.nodeValue || '').length))
655
+ var sel = window.getSelection()
656
+ sel.removeAllRanges()
657
+ sel.addRange(range)
658
+ mouseUpOn(toNow)
659
+ shown = await waitFor(function () { return hook.quoteState().visible === true }, 1500)
660
+ }
625
661
  check('跨节选择:也浮出「❝ 引用」', shown === true, JSON.stringify(hook.quoteState()))
626
662
  if (shown) {
627
663
  clickOn(quoteBtn)
@@ -1441,11 +1477,199 @@ async function main() {
1441
1477
  await sleep(30)
1442
1478
  }
1443
1479
 
1480
+
1481
+ // 内容把面板撑出视口 → 必须自动收回(回复框不能被顶到屏幕外)
1482
+ //
1483
+ // 真实场景:开窗那一刻"放得下吗"是按当时的高度算的(常常只有一行翻译在流),
1484
+ // 之后正文长到 max-height,面板从固定的 top 往下挂 —— 它是 position:fixed、页面不滚,
1485
+ // 顶出去那截(回复框)就点不到了。
1486
+ //
1487
+ // ⚠️ 复核挂在**绘制之后**(内容这时才在 DOM 里)。所以这里灌完内容要再走一次真实绘制,
1488
+ // 不能用 askValue 那类只改状态、不重绘的钩子(踩过:断言量到的一直是注入前的旧位置)。
1489
+ {
1490
+ hook.open('溢出探测', '上下文片段', '冒烟')
1491
+ await sleep(140)
1492
+ var panelEl3 = document.querySelector('.dsh-sel-panel')
1493
+ var bodyEl3 = panelEl3.querySelector('.dsh-sel-body')
1494
+ var filler = document.createElement('div')
1495
+ filler.style.height = Math.round(window.innerHeight * 0.9) + 'px'
1496
+ filler.style.flex = '0 0 auto'
1497
+ bodyEl3.appendChild(filler)
1498
+ // 再开一次 = 走一遍真实的 showPanel + paint(内容已经在 DOM 里,复核这帧就能看见)
1499
+ hook.open('溢出探测', '上下文片段', '冒烟')
1500
+ await sleep(180)
1501
+ var overRect = panelEl3.getBoundingClientRect()
1502
+ check('内容把面板撑出视口时自动收回可视区(回复框不会被顶到屏幕外)',
1503
+ overRect.bottom <= window.innerHeight - 2,
1504
+ 'bottom=' + Math.round(overRect.bottom) + ' vh=' + window.innerHeight)
1505
+ filler.remove()
1506
+ hook.open('溢出探测', '上下文片段', '冒烟')
1507
+ await sleep(80)
1508
+ hook.close()
1509
+ await sleep(40)
1510
+ }
1511
+
1512
+ // ⑫ 卡片 → 小窗的交接(方案二「缩放长出」):真浏览器里量真矩形 + 真过渡。
1513
+ // 桩 DOM 里只能验"写进去的 transform 字符串",这里补上"浏览器真的从那一格长出来"。
1514
+ {
1515
+ hook.close()
1516
+ await sleep(60)
1517
+ var docp4 = document.getElementById('docp')
1518
+ selectIn(docp4, 'the migration ran long')
1519
+ mouseUpOn(docp4)
1520
+ // 让 host 报"豆包在录":插件轮询到就把卡片弹出来(这是真实的触发路径)
1521
+ micState = { ok: true, available: true, capturing: true, processes: [{ pid: 513, bundleId: 'com.bytedance.inputmethod.doubaoime', name: 'DoubaoIme' }], reason: '' }
1522
+ var cardEl = await waitFor(function () {
1523
+ var el = document.querySelector('.dsh-sel-vcard')
1524
+ return el && getComputedStyle(el).display !== 'none' && el.getBoundingClientRect().width > 0 ? el : null
1525
+ }, 2500)
1526
+ check('(准备)输入法开麦 → 语音卡自己弹出来', !!cardEl, cardEl ? '' : '没等到卡片')
1527
+ if (cardEl) {
1528
+ var vbox = cardEl.querySelector('.dsh-sel-vcard-box')
1529
+ var vsend = cardEl.querySelector('.dsh-sel-vcard-send')
1530
+ vbox.value = '这是 在推迟 还是提前'
1531
+ vbox.dispatchEvent(new Event('input', { bubbles: true }))
1532
+ await sleep(60)
1533
+ var cardRect = cardEl.getBoundingClientRect()
1534
+ var panelEl4 = document.querySelector('.dsh-sel-panel')
1535
+ // ⚠️ 不能用"点完下一帧读计算值"来抓起点:这个冒烟页跑在 --virtual-time-budget 下,
1536
+ // 虚拟时钟会往前跳,rAF 回来时过渡可能已经跑完了(实测读到 matrix=none)。
1537
+ // 改成**记录 style 属性的每一次写入**(MutationObserver 的 attributeOldValue 给的是
1538
+ // 写之前的值)—— 于是"起点把面板压到卡片那一格"这件事与时钟无关,直接可读可验。
1539
+ var styleWrites = []
1540
+ var mo = new MutationObserver(function (records) {
1541
+ for (var i = 0; i < records.length; i += 1) styleWrites.push(records[i].oldValue || '')
1542
+ })
1543
+ mo.observe(panelEl4, { attributes: true, attributeFilter: ['style'], attributeOldValue: true })
1544
+ clickOn(vsend)
1545
+ // 期望值必须在**同一个任务里**读完:插件也是在这次点击里量的几何
1546
+ // (openPanelWith 内层跑完、内容已换新 → 量高度;随后流式回答才会把面板撑高)。
1547
+ // 面板的 offset* 是布局值,不受正在跑的 transform 影响。
1548
+ var expW = panelEl4.offsetWidth
1549
+ var expH = panelEl4.offsetHeight
1550
+ var expLeft = panelEl4.offsetLeft
1551
+ var expTop = panelEl4.offsetTop
1552
+ await sleep(150)
1553
+ mo.disconnect()
1554
+ var startStyle = null
1555
+ for (var wi = 0; wi < styleWrites.length; wi += 1) {
1556
+ if (styleWrites[wi].indexOf('transform: translate(') >= 0) startStyle = styleWrites[wi]
1557
+ }
1558
+ // ⚠️ 页面脚本是**模板字符串**里的:正则里的 \d / \( 会被模板字面量吃掉反斜杠
1559
+ // (写成 [\d.] 到了页面里变成 [d.],永远匹配不上 —— 实测就是这么红的)。
1560
+ // 所以这里只用不带反斜杠的字符类 [0-9.] 取数,并且从 transform 那一段开始取,
1561
+ // 免得把 z-index / left / top 上的数字也吃进来。
1562
+ var seg = startStyle ? startStyle.slice(startStyle.indexOf('transform: translate(')) : ''
1563
+ var nums = seg.match(/-?[0-9.]+/g) || []
1564
+ var m = nums.length >= 4 ? [seg.slice(0, 60), nums[0], nums[1], nums[2], nums[3]] : null
1565
+ // 期望值:面板的**布局**值(offset*,不受动画影响)与卡片矩形的比值/差值
1566
+ var sx = cardRect.width / expW
1567
+ var sy = cardRect.height / expH
1568
+ var dx = cardRect.left - expLeft
1569
+ var dy = cardRect.top - expTop
1570
+ check('交接起点:面板被压到卡片那一格(缩放 + 位移,量的是真矩形)',
1571
+ !!m && Math.abs(Number(m[3]) - sx) < 0.01 && Math.abs(Number(m[4]) - sy) < 0.01 &&
1572
+ Math.abs(Number(m[1]) - dx) < 1 && Math.abs(Number(m[2]) - dy) < 1,
1573
+ m ? '起点=' + m[0] + ' 期望≈translate(' + dx.toFixed(1) + 'px,' + dy.toFixed(1) + 'px) scale(' + sx.toFixed(4) + ',' + sy.toFixed(4) + ')'
1574
+ : '没观察到起点写入;style 写入次数=' + styleWrites.length)
1575
+ check('交接期间 transform-origin 在左上角(否则缩放会把面板甩出去)',
1576
+ !!startStyle && startStyle.indexOf('transform-origin: 0px 0px') >= 0,
1577
+ startStyle ? startStyle.slice(0, 120) : '(没抓到)')
1578
+ var sawTransition = styleWrites.some(function (v) { return v.indexOf('transition: transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1)') >= 0 })
1579
+ check('交接只动 transform、260ms 同一族曲线(不动 width/left/top)',
1580
+ sawTransition || String(panelEl4.style.transition).indexOf('transform 260ms') === 0,
1581
+ '写入=' + JSON.stringify(styleWrites.map(function (v) { return (v.match(/transition:[^;]*/) || [''])[0] }).filter(Boolean).slice(0, 2)))
1582
+ await sleep(520)
1583
+ var settledMatrix = getComputedStyle(panelEl4).transform
1584
+ check('交接结束:回到恒等(无 transform)',
1585
+ settledMatrix === 'none' || settledMatrix === 'matrix(1, 0, 0, 1, 0, 0)', String(settledMatrix))
1586
+ check('交接收尾:内联 transform / transition / transformOrigin 清干净',
1587
+ !panelEl4.style.transform && !panelEl4.style.transition && !panelEl4.style.transformOrigin,
1588
+ JSON.stringify({ tf: panelEl4.style.transform, tr: panelEl4.style.transition, og: panelEl4.style.transformOrigin }))
1589
+ // 收尾:麦克风"松开",别影响后面的用例
1590
+ micState = { ok: true, available: true, capturing: false, processes: [], reason: '' }
1591
+ await sleep(80)
1592
+ hook.close()
1593
+ await sleep(40)
1594
+ }
1595
+ }
1596
+
1597
+ // ⑬ 小窗开着时开麦 = 点一下「❝ 引用」(2026-10-09 定的行为)。
1598
+ // 真浏览器里验的是**接线**:引用浮标亮着才轮到轮询、开麦后引用真的进了引用区、
1599
+ // 焦点真的落到小窗输入框(IME 上屏要往那儿落)、而且**不弹语音卡**。
1600
+ {
1601
+ var recording = { ok: true, available: true, capturing: true, processes: [{ pid: 513, bundleId: 'com.bytedance.inputmethod.doubaoime', name: 'DoubaoIme' }], reason: '' }
1602
+ var notRecording = { ok: true, available: true, capturing: false, processes: [], reason: '' }
1603
+ micState = notRecording
1604
+ hook.close()
1605
+ await sleep(60)
1606
+ hook.open('引用开麦用例', '开麦引用的上下文片段', '引用开麦')
1607
+ await waitFor(function () { return hook.state().phase === 'done' }, 3000)
1608
+ var quoteBtn = document.querySelector('.dsh-sel-quotebtn')
1609
+ var quotesBox = document.querySelector('.dsh-sel-quotes')
1610
+ var vcardEl = document.querySelector('.dsh-sel-vcard')
1611
+ var askEl = document.querySelector('.dsh-sel-askbox')
1612
+
1613
+ // ① 没有引用浮标时不轮询(用户定的:探针只跟着浮标活)
1614
+ var hitsIdle = window.__micHits || 0
1615
+ await sleep(500)
1616
+ check('没有引用浮标时不轮询麦克风', (window.__micHits || 0) === hitsIdle, hitsIdle + ' → ' + (window.__micHits || 0))
1617
+
1618
+ // ② 主界面划词 → 引用浮标亮 → 开始问
1619
+ var docp5 = document.getElementById('docp')
1620
+ selectIn(docp5, 'the migration ran long')
1621
+ mouseUpOn(docp5)
1622
+ var lit = await waitFor(function () {
1623
+ return quoteBtn && quoteBtn.style.display !== 'none' && getComputedStyle(quoteBtn).display !== 'none'
1624
+ }, 1500)
1625
+ check('(前提)引用浮标亮着', lit === true, quoteBtn ? getComputedStyle(quoteBtn).display : '未找到浮标')
1626
+ var hitsFloat = window.__micHits || 0
1627
+ await sleep(500)
1628
+ check('引用浮标亮着时才问麦克风', (window.__micHits || 0) > hitsFloat, hitsFloat + ' → ' + (window.__micHits || 0))
1629
+
1630
+ // ③ 开麦 → 引用挂进来 + 焦点进小窗输入框 + 不弹语音卡
1631
+ var quotesBefore = (hook.quotes() || []).length
1632
+ micState = recording
1633
+ var quoted = await waitFor(function () { return (hook.quotes() || []).length > quotesBefore }, 2000)
1634
+ var got = hook.quotes() || []
1635
+ check('开麦 → 选中的那段自动挂进引用', quoted === true && /migration ran long/.test(got[got.length - 1].text), JSON.stringify(got.map(function (q) { return q.text })))
1636
+ check('开麦 → 光标进了小窗输入框(输入法上屏的字落在这儿)',
1637
+ document.activeElement === askEl, document.activeElement ? (document.activeElement.className || document.activeElement.tagName) : 'null')
1638
+ check('开麦 → **不**弹语音卡(面板开着时字直接落进小窗)',
1639
+ !vcardEl || getComputedStyle(vcardEl).display === 'none', vcardEl ? getComputedStyle(vcardEl).display : '没有卡片元素')
1640
+ await sleep(120)
1641
+ check('开麦 → 引用浮标自己收了', quoteBtn.style.display === 'none' || getComputedStyle(quoteBtn).display === 'none', quoteBtn.style.display)
1642
+ check('开麦 → 引用区真的画出来了', quotesBox && quotesBox.getAttribute('data-show') === '1', quotesBox ? quotesBox.getAttribute('data-show') : '未找到引用区')
1643
+
1644
+ // ④ 别处正在打字 → 不加引用、不抢焦点(守卫,与语音卡那条完全一致)
1645
+ micState = notRecording
1646
+ await sleep(400)
1647
+ var otherEl = document.getElementById('other')
1648
+ otherEl.focus()
1649
+ var docp6 = document.getElementById('docp')
1650
+ selectIn(docp6, 'the migration ran long')
1651
+ mouseUpOn(docp6)
1652
+ await waitFor(function () { return quoteBtn && quoteBtn.style.display !== 'none' }, 1200)
1653
+ var quotesGuard = (hook.quotes() || []).length
1654
+ micState = recording
1655
+ await sleep(600)
1656
+ check('别处正在打字:不加引用', (hook.quotes() || []).length === quotesGuard, JSON.stringify((hook.quotes() || []).map(function (q) { return q.text })))
1657
+ check('别处正在打字:不抢焦点(人家的听写不能被拽进小窗)',
1658
+ document.activeElement === otherEl, document.activeElement ? (document.activeElement.id || document.activeElement.className || document.activeElement.tagName) : 'null')
1659
+
1660
+ // 收尾
1661
+ micState = notRecording
1662
+ if (otherEl) otherEl.blur()
1663
+ hook.close()
1664
+ await sleep(60)
1665
+ }
1666
+
1444
1667
  finish()
1445
1668
  }
1446
1669
 
1447
1670
  main().catch(function (error) {
1448
1671
  check('冒烟脚本自身没抛错', false, (error && error.message) || String(error))
1672
+
1449
1673
  finish()
1450
1674
  })
1451
1675
  </script>