@yfwu2020/dsh-selection-explain 0.1.3 → 0.4.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.
@@ -0,0 +1,433 @@
1
+ /**
2
+ * 小窗面板的**真浏览器**冒烟测试(无头 Chrome)。
3
+ *
4
+ * 为什么需要它:`test-client.mjs` 用的是手搓 DOM 桩,桩上"什么都对"但真实浏览器里
5
+ * 连着踩过三次(都是它测不出来的那类问题):
6
+ * ① `mouseup` 对面板内的目标直接 return —— 小窗里划词毫无反应(桩里 target 用的是 document.body);
7
+ * ② 引用浮标的 z-index 比面板低 —— 浮标画在面板**底下**,看不见也点不着(桩里不跑层叠);
8
+ * ③ 翻译 / 详解两节里划词取不到(桩里没覆盖"节"这条路径)。
9
+ * 所以这里用真 DOM、真选区、真 MouseEvent、真层叠(`elementFromPoint`)跑一遍主要交互。
10
+ *
11
+ * 用法:node scripts/smoke-panel.mjs [lib/client.js 路径]
12
+ * CHROME=/path/to/chrome node scripts/smoke-panel.mjs
13
+ * 退出码:全部 PASS = 0;找不到 Chrome 打印 SKIP 后退出 0(与 smoke-bridge 一致)。
14
+ */
15
+ import { createServer } from 'node:http'
16
+ import { readFileSync, existsSync } from 'node:fs'
17
+ import { dirname, resolve } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { execFile } from 'node:child_process'
20
+
21
+ const HERE = dirname(fileURLToPath(import.meta.url))
22
+ const ROOT = resolve(HERE, '..')
23
+ const BUNDLE = process.argv[2] || resolve(ROOT, 'lib', 'client.js')
24
+
25
+ function findChrome() {
26
+ const candidates = [
27
+ process.env.CHROME,
28
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
29
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
30
+ '/usr/bin/google-chrome',
31
+ '/usr/bin/chromium',
32
+ '/usr/bin/chromium-browser',
33
+ ].filter(Boolean)
34
+ for (const item of candidates) if (existsSync(item)) return item
35
+ return ''
36
+ }
37
+
38
+ const chrome = findChrome()
39
+ if (!chrome) {
40
+ console.log('SKIP 没找到 Chrome/Chromium(设 CHROME=/path/to/chrome 可指定)')
41
+ process.exit(0)
42
+ }
43
+
44
+ const PAGE = `<!doctype html>
45
+ <html lang="zh-CN"><head><meta charset="utf-8"><title>panel smoke</title>
46
+ <style>html,body{margin:0;padding:0;background:#fff}
47
+ /* 给主界面留一块"正文",用来验主界面划词那条路 */
48
+ #doc{padding:40px;font:14px/1.7 -apple-system,"PingFang SC",sans-serif}
49
+ </style></head>
50
+ <body>
51
+ <div id="doc"><p id="docp">Dev: the migration ran long, so we ship Wednesday.</p></div>
52
+ <div id="mount"></div>
53
+ <pre id="out">PENDING</pre>
54
+ <script>
55
+ // ── 自检结果收集 ──
56
+ var RESULTS = []
57
+ function check(name, ok, extra) { RESULTS.push({ name: name, ok: !!ok, extra: extra === undefined ? '' : String(extra).slice(0, 300) }) }
58
+ function finish() { document.getElementById('out').textContent = JSON.stringify(RESULTS) }
59
+ var sleep = function (ms) { return new Promise(function (r) { setTimeout(r, ms) }) }
60
+ function waitFor(fn, ms) {
61
+ var deadline = Date.now() + (ms || 3000)
62
+ return new Promise(function (done) {
63
+ ;(function tick() {
64
+ var value
65
+ try { value = fn() } catch (error) { value = false }
66
+ if (value || Date.now() > deadline) return done(value)
67
+ setTimeout(tick, 25)
68
+ })()
69
+ })
70
+ }
71
+
72
+ // ── 插件 bundle 需要的环境:ModuleLoader / React 垫片 / ctx ──
73
+ var captured = null
74
+ window.__ModuleLoader__ = { load: function (spec) { captured = spec } }
75
+ var React = {
76
+ createElement: function (type, props) {
77
+ var children = Array.prototype.slice.call(arguments, 2)
78
+ var out = { type: type, props: Object.assign({}, props || {}) }
79
+ if (children.length === 1) out.props.children = children[0]
80
+ else if (children.length > 1) out.props.children = children
81
+ return out
82
+ },
83
+ }
84
+
85
+ var fixture = {
86
+ translation: '## 翻译\\n第一段译文:the migration ran long——迁移跑得比预期久,可以被划词引用。\\n\\n> 在本句中:进度延迟。\\n',
87
+ detail: '## 详解\\n详解第二段:这句话在排期上意味着顺延,也可以被划词引用。\\n',
88
+ chat: '这是对追问的回答:冒烟用例。',
89
+ }
90
+ function sse(events) {
91
+ var body = events.map(function (e) { return 'data: ' + JSON.stringify(e) + '\\n\\n' }).join('')
92
+ return new Response(body, { headers: { 'content-type': 'text/event-stream' } })
93
+ }
94
+ window.fetch = function (input, init) {
95
+ var url = String(input)
96
+ var body = {}
97
+ try { body = init && init.body ? JSON.parse(String(init.body)) : {} } catch (error) { body = {} }
98
+ if (url.indexOf('/selection-explain/api/ping') >= 0) {
99
+ 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' } }))
100
+ }
101
+ if (url.indexOf('/selection-explain/api/quote-context') >= 0) {
102
+ return Promise.resolve(new Response(JSON.stringify({ ok: true, matched: true, context: '用户:上一问\\n助手:上一答\\n用户:【' + (body.text || '') + '】\\n助手:下一答', rounds: 2 }), { headers: { 'content-type': 'application/json' } }))
103
+ }
104
+ if (url.indexOf('/selection-explain/api/history') >= 0) {
105
+ return Promise.resolve(new Response(JSON.stringify({ ok: true, entry: null, entries: [] }), { headers: { 'content-type': 'application/json' } }))
106
+ }
107
+ if (url.indexOf('/selection-explain/api/models') >= 0) {
108
+ return Promise.resolve(new Response(JSON.stringify({ ok: true, current: null, stages: null, models: [] }), { headers: { 'content-type': 'application/json' } }))
109
+ }
110
+ if (url.indexOf('/selection-explain/api/analyze') >= 0) {
111
+ if (body.question) return Promise.resolve(sse([{ type: 'start', provider: 'fixture', model: 'fixture', mode: 'chat' }, { type: 'delta', text: fixture.chat }, { type: 'done', chars: 8 }]))
112
+ var fence = String.fromCharCode(96, 96, 96)
113
+ var code = '## 注释\\n' + fence + 'js\\n// 注释:先声明常量\\nconst x = 1\\n' + fence + '\\n\\n> 小结:一句话说清。\\n'
114
+ var text = body.kind === 'code' ? code : body.stage === 'detail' ? fixture.detail : fixture.translation
115
+ return Promise.resolve(sse([{ type: 'start', provider: 'fixture', model: 'fixture', stage: body.stage || 'translation' }, { type: 'delta', text: text }, { type: 'done', chars: text.length }]))
116
+ }
117
+ return Promise.resolve(new Response('{}', { headers: { 'content-type': 'application/json' } }))
118
+ }
119
+
120
+ var disposers = []
121
+ var registration = null
122
+ var ctx = {
123
+ logger: { info: function () {}, warn: function () {} },
124
+ get: function () { return undefined },
125
+ effect: function (fn) { var d = fn(); if (typeof d === 'function') disposers.push(d); return d },
126
+ slots: {
127
+ inject: function (key, callback) { var d = callback(); if (typeof d === 'function') disposers.push(d); return d },
128
+ register: function (options, component) { registration = { options: options, component: component }; return function () {} },
129
+ },
130
+ }
131
+
132
+ // ── 真选区 + 真事件 ──
133
+ </script>
134
+ <script src="/client.js"></script>
135
+ <script>
136
+ function selectIn(node, needle) {
137
+ var text = node.textContent || ''
138
+ var at = needle ? text.indexOf(needle) : 0
139
+ if (at < 0) return null
140
+ var len = needle ? needle.length : Math.min(6, text.length)
141
+ var walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null)
142
+ var acc = 0, startNode = null, startOffset = 0, endNode = null, endOffset = 0, current
143
+ while ((current = walker.nextNode())) {
144
+ var next = acc + current.nodeValue.length
145
+ if (startNode === null && at < next) { startNode = current; startOffset = at - acc }
146
+ if (startNode !== null && at + len <= next) { endNode = current; endOffset = at + len - acc; break }
147
+ acc = next
148
+ }
149
+ if (!startNode || !endNode) return null
150
+ var range = document.createRange()
151
+ range.setStart(startNode, Math.max(0, startOffset))
152
+ range.setEnd(endNode, Math.max(0, endOffset))
153
+ var sel = window.getSelection()
154
+ sel.removeAllRanges()
155
+ sel.addRange(range)
156
+ return range
157
+ }
158
+ function mouseUpOn(node) {
159
+ node.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }))
160
+ }
161
+ function clickOn(node) {
162
+ node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }))
163
+ }
164
+ /** 浮标是不是**最上层**(z-index 低于面板时 elementFromPoint 会返回面板)。 */
165
+ function onTop(node) {
166
+ var rect = node.getBoundingClientRect()
167
+ if (!rect.width || !rect.height) return false
168
+ var hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
169
+ return hit === node || node.contains(hit)
170
+ }
171
+
172
+ async function main() {
173
+ var mount = document.getElementById('mount')
174
+ var hook = null
175
+ try {
176
+ var modules = captured.factory(function (name) { return name === 'react' ? React : {} })
177
+ modules.apply(ctx)
178
+ check('插件装载(槽注册 + apply)', !!registration && registration.options.id === '@yfwu2020/dsh-selection-explain', registration && registration.options.id)
179
+ registration.component().props.ref(mount)
180
+ hook = window.__dshSelectionExplain
181
+ check('浮层挂进 mount', !!document.querySelector('.dsh-sel-layer'), '')
182
+ } catch (error) {
183
+ check('插件装载(槽注册 + apply)', false, error && error.message)
184
+ finish()
185
+ return
186
+ }
187
+
188
+ var panel = document.querySelector('.dsh-sel-panel')
189
+ var quoteBtn = hook.quoteNode()
190
+ var quotesBox = document.querySelector('.dsh-sel-quotes')
191
+
192
+ function chipCount() { return hook.quotes().length }
193
+ function dropQuotes() {
194
+ var xs = quotesBox.querySelectorAll('.dsh-sel-quotechip-x')
195
+ for (var i = xs.length - 1; i >= 0; i -= 1) clickOn(xs[i])
196
+ }
197
+ /** 等面板"安顿下来"(开窗自动聚焦那一下会清掉选区,等它过去再划)。 */
198
+ async function settlePanel() {
199
+ var box = document.querySelector(".dsh-sel-askbox")
200
+ await waitFor(function () { return box && document.activeElement === box }, 800)
201
+ await sleep(30)
202
+ }
203
+ /** 划一段 → 点引用 → 返回那一段引用(并清场)。find() 返回 { node, needle }。 */
204
+ async function quoteOnce(label, find) {
205
+ await settlePanel()
206
+ var target = find()
207
+ if (!target || !target.node) { check(label + ':找到目标节点', false, '未找到'); return null }
208
+ var range = selectIn(target.node, target.needle)
209
+ check(label + ':能建出真选区', !!range, range ? range.toString().slice(0, 24) : '未找到目标文字')
210
+ if (!range) return null
211
+ mouseUpOn(target.node)
212
+ var shown = await waitFor(function () { return hook.quoteState().visible === true }, 1500)
213
+ check(label + ':浮出「❝ 引用」', shown === true, JSON.stringify(hook.quoteState()))
214
+ if (!shown) return null
215
+ check(label + ':浮标在最上层(不被面板盖住)', onTop(quoteBtn), '')
216
+ clickOn(quoteBtn)
217
+ await sleep(40)
218
+ var quotes = hook.quotes()
219
+ var picked = quotes.length ? quotes[quotes.length - 1] : null
220
+ check(label + ':进了引用区', chipCount() === 1 && !!picked, JSON.stringify(quotes.map(function (q) { return q.label + ':' + q.text.slice(0, 12) })))
221
+ dropQuotes()
222
+ await sleep(30)
223
+ return picked
224
+ }
225
+ function sectionContent(key) {
226
+ return document.querySelector('.dsh-sel-sec[data-sec="' + key + '"] .dsh-sel-c')
227
+ }
228
+
229
+ // ── 打开面板(fetch 桩给固定两节内容)──
230
+ hook.open('冒烟探针', '上下文片段 ABC', '冒烟')
231
+ await waitFor(function () { return hook.state().phase === 'done' }, 4000)
232
+ check('首轮渲染完成', hook.state().phase === 'done', hook.state().phase)
233
+
234
+ // ① 翻译节
235
+ var picked1 = await quoteOnce('翻译节划词', function () {
236
+ return { node: sectionContent('translation'), needle: '迁移跑得比预期久' }
237
+ })
238
+ var transTitle = document.querySelector('.dsh-sel-sec[data-sec="translation"] .dsh-sel-sh span')
239
+ var transTitleText = transTitle ? transTitle.textContent : ''
240
+ check(
241
+ '翻译节的来源写明是哪一节(用该节自己的标题)',
242
+ !!picked1 && !!transTitleText && picked1.label === '小窗「' + transTitleText + '」节',
243
+ picked1 && picked1.label + ' vs ' + transTitleText,
244
+ )
245
+
246
+ // ② 结论条(.dsh-sel-callout,也在翻译节里)
247
+ await quoteOnce('结论条划词', function () {
248
+ return { node: document.querySelector('.dsh-sel-callout'), needle: '进度延迟' }
249
+ })
250
+
251
+ // ③ 展开详解 → 详解节
252
+ hook.expand()
253
+ await waitFor(function () { return (hook.parts().detail || '').length > 0 && hook.state().phase === 'done' }, 4000)
254
+ check('详解节渲染出来了', !!sectionContent('detail'), '')
255
+ var picked3 = await quoteOnce('详解节划词', function () {
256
+ return { node: sectionContent('detail'), needle: '也可以被划词引用' }
257
+ })
258
+ check('详解节的来源写明是哪一节', !!picked3 && picked3.label.indexOf('详解') >= 0, picked3 && picked3.label)
259
+
260
+ // ④ 跨节选择:从翻译节拖到详解节
261
+ {
262
+ var from = sectionContent('translation')
263
+ var to = sectionContent('detail')
264
+ var texts = []
265
+ var walker = document.createTreeWalker(from, NodeFilter.SHOW_TEXT, null)
266
+ var node
267
+ while ((node = walker.nextNode())) if (node.nodeValue.trim()) texts.push(node)
268
+ if (texts.length && to.firstChild) {
269
+ var range = document.createRange()
270
+ range.setStart(texts[0], 0)
271
+ var endNode = to.firstChild.nodeType === 3 ? to.firstChild : to
272
+ range.setEnd(endNode, Math.min(4, (endNode.nodeValue || '').length))
273
+ var sel = window.getSelection()
274
+ sel.removeAllRanges()
275
+ sel.addRange(range)
276
+ mouseUpOn(to)
277
+ var shown = await waitFor(function () { return hook.quoteState().visible === true }, 1500)
278
+ check('跨节选择:也浮出「❝ 引用」', shown === true, JSON.stringify(hook.quoteState()))
279
+ if (shown) {
280
+ clickOn(quoteBtn)
281
+ await sleep(40)
282
+ check('跨节选择:进了引用区', chipCount() === 1, JSON.stringify(hook.quotes().map(function (q) { return q.text.length })))
283
+ dropQuotes()
284
+ await sleep(30)
285
+ }
286
+ } else {
287
+ check('跨节选择:也浮出「❝ 引用」', false, '没找到可跨的文本节点')
288
+ }
289
+ }
290
+
291
+ // ⑤ 面板**滚动之后**再划词(长内容 + 滚动是真实常态)
292
+ {
293
+ var body = document.querySelector('.dsh-sel-body')
294
+ body.scrollTop = Math.min(120, Math.max(0, body.scrollHeight - body.clientHeight))
295
+ await sleep(30)
296
+ var picked5 = await quoteOnce('滚动后划翻译节', function () {
297
+ return { node: sectionContent('translation'), needle: '迁移跑得比预期久' }
298
+ })
299
+ check('滚动之后划词照常能用', !!picked5, '')
300
+ }
301
+
302
+ // ⑥ 代码注释节(「## 注释」+ <pre>,划词落在 pre 里)
303
+ {
304
+ hook.close()
305
+ hook.open('function add(a, b) { return a + b }', 'const x = 1', '代码块')
306
+ await waitFor(function () { return hook.state().phase === 'done' }, 4000)
307
+ var header = document.querySelector('.dsh-sel-sec[data-sec="translation"] .dsh-sel-sh')
308
+ check('代码用例切到「注释」节', !!header && header.textContent.indexOf('注释') >= 0, header ? header.textContent : '未找到')
309
+ await quoteOnce('代码块里划词', function () {
310
+ return { node: document.querySelector('.dsh-sel-pre'), needle: 'const x' }
311
+ })
312
+ }
313
+
314
+ // ⑦ 对话气泡(不带上下文,但要写清出处)
315
+ {
316
+ hook.close()
317
+ hook.open('冒烟探针', '上下文片段 ABC', '冒烟')
318
+ await waitFor(function () { return hook.state().phase === 'done' }, 4000)
319
+ hook.ask('冒烟追问')
320
+ await waitFor(function () { return hook.state().asking === false }, 4000)
321
+ var bubbles = document.querySelectorAll('.dsh-sel-chatlog .dsh-sel-bubble-bot')
322
+ var bot = bubbles[bubbles.length - 1]
323
+ check('追问气泡渲染出来了', !!bot && bot.textContent.indexOf('冒烟用例') >= 0, bot ? bot.textContent.slice(0, 24) : '未找到')
324
+ var picked7 = await quoteOnce('对话气泡划词', function () { return { node: bot, needle: '冒烟用例' } })
325
+ check('对话引用写清了出处(第 N 轮 + 角色)', !!picked7 && /第 [0-9]+ 轮/.test(picked7.label), picked7 && picked7.label)
326
+ check('对话引用不带上下文(小窗对话本来就在历史里)', !!picked7 && !picked7.context, picked7 && JSON.stringify(picked7.context))
327
+ var all = bot.querySelector('.dsh-sel-bubquote')
328
+ check('气泡末尾有「引用整条」', !!all, '')
329
+ if (all) {
330
+ clickOn(all)
331
+ await sleep(40)
332
+ var quoted = hook.quotes()
333
+ var last = quoted.length ? quoted[quoted.length - 1] : null
334
+ check('「引用整条」也不带上下文', !!last && !last.context, last && JSON.stringify(last.context))
335
+ check('「引用整条」也写清出处', !!last && /第 [0-9]+ 轮/.test(last.label), last && last.label)
336
+ dropQuotes()
337
+ await sleep(30)
338
+ }
339
+ }
340
+
341
+ // ⑧ 主界面划词(小窗开着)→ 会话里的引用(带 host 上下文)
342
+ {
343
+ var docp = document.getElementById('docp')
344
+ var picked8 = await quoteOnce('主界面划词', function () { return { node: docp, needle: 'the migration ran long' } })
345
+ check('主界面引用标成会话里的', !!picked8 && picked8.session === true, picked8 && String(picked8.session))
346
+ }
347
+
348
+ finish()
349
+ }
350
+
351
+ main().catch(function (error) {
352
+ check('冒烟脚本自身没抛错', false, (error && error.message) || String(error))
353
+ finish()
354
+ })
355
+ </script>
356
+ </body></html>`
357
+
358
+ const server = createServer((req, res) => {
359
+ const url = (req.url || '/').split('?')[0]
360
+ if (url === '/client.js') {
361
+ res.writeHead(200, { 'content-type': 'application/javascript; charset=utf-8' })
362
+ res.end(readFileSync(BUNDLE))
363
+ return
364
+ }
365
+ if (url === '/' || url === '/smoke.html') {
366
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
367
+ res.end(PAGE)
368
+ return
369
+ }
370
+ res.writeHead(404, { 'content-type': 'text/plain' })
371
+ res.end('not found')
372
+ })
373
+
374
+ await new Promise((done) => server.listen(0, '127.0.0.1', done))
375
+ const port = server.address().port
376
+
377
+ function parseDump(dump) {
378
+ const matched = /<pre id="out">([\s\S]*?)<\/pre>/.exec(dump || '')
379
+ if (!matched) return null
380
+ const text = matched[1]
381
+ .replace(/&quot;/g, '"')
382
+ .replace(/&lt;/g, '<')
383
+ .replace(/&gt;/g, '>')
384
+ .replace(/&amp;/g, '&')
385
+ try {
386
+ return JSON.parse(text)
387
+ } catch (error) {
388
+ return null
389
+ }
390
+ }
391
+
392
+ let results = null
393
+ let dump = ''
394
+ try {
395
+ for (let attempt = 1; attempt <= 2 && !results; attempt += 1) {
396
+ const out = await new Promise((done) => {
397
+ execFile(
398
+ chrome,
399
+ [
400
+ '--headless',
401
+ '--disable-gpu',
402
+ '--no-first-run',
403
+ '--no-default-browser-check',
404
+ '--window-size=1200,900',
405
+ '--virtual-time-budget=25000',
406
+ '--dump-dom',
407
+ `http://127.0.0.1:${port}/smoke.html`,
408
+ ],
409
+ { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 90000 },
410
+ (error, stdout) => done(stdout || ''),
411
+ )
412
+ })
413
+ dump = out
414
+ results = parseDump(dump)
415
+ }
416
+ } finally {
417
+ if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
418
+ server.close()
419
+ }
420
+
421
+ let failed = 0
422
+ if (!results) {
423
+ console.log('FAIL 浏览器里没拿到结果(<pre id="out">)')
424
+ console.log(dump.slice(0, 400))
425
+ failed += 1
426
+ } else {
427
+ for (const item of results) {
428
+ console.log(`${item.ok ? 'PASS' : 'FAIL'} ${item.name}${item.extra ? ' — ' + item.extra : ''}`)
429
+ if (!item.ok) failed += 1
430
+ }
431
+ }
432
+ console.log(`\n=== 小窗面板冒烟结束:${results ? results.length - failed : 0} 通过 / ${failed} 失败 ===`)
433
+ process.exit(failed === 0 ? 0 : 1)