@yfwu2020/dsh-selection-explain 0.2.0 → 0.4.1

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,481 @@
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
+ /**
165
+ * 像用户那样点一下:mousedown → mouseup → click。
166
+ * 只派发 click 是"程序化点击",浏览器不会走聚焦/选区那套默认行为 ——
167
+ * 而浮标靠 mousedown 的 preventDefault 保住选区,正是那条路要验。
168
+ */
169
+ function realClick(node) {
170
+ var opts = { bubbles: true, cancelable: true, view: window, detail: 1 }
171
+ node.dispatchEvent(new MouseEvent("mousedown", opts))
172
+ node.dispatchEvent(new MouseEvent("mouseup", opts))
173
+ node.dispatchEvent(new MouseEvent("click", opts))
174
+ }
175
+ /** 浮标是不是**最上层**(z-index 低于面板时 elementFromPoint 会返回面板)。 */
176
+ function onTop(node) {
177
+ var rect = node.getBoundingClientRect()
178
+ if (!rect.width || !rect.height) return false
179
+ var hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
180
+ return hit === node || node.contains(hit)
181
+ }
182
+
183
+ async function main() {
184
+ var mount = document.getElementById('mount')
185
+ var hook = null
186
+ try {
187
+ var modules = captured.factory(function (name) { return name === 'react' ? React : {} })
188
+ modules.apply(ctx)
189
+ check('插件装载(槽注册 + apply)', !!registration && registration.options.id === '@yfwu2020/dsh-selection-explain', registration && registration.options.id)
190
+ registration.component().props.ref(mount)
191
+ hook = window.__dshSelectionExplain
192
+ check('浮层挂进 mount', !!document.querySelector('.dsh-sel-layer'), '')
193
+ } catch (error) {
194
+ check('插件装载(槽注册 + apply)', false, error && error.message)
195
+ finish()
196
+ return
197
+ }
198
+
199
+ var panel = document.querySelector('.dsh-sel-panel')
200
+ var quoteBtn = hook.quoteNode()
201
+ var quotesBox = document.querySelector('.dsh-sel-quotes')
202
+
203
+ function chipCount() { return hook.quotes().length }
204
+ function dropQuotes() {
205
+ var xs = quotesBox.querySelectorAll('.dsh-sel-quotechip-x')
206
+ for (var i = xs.length - 1; i >= 0; i -= 1) clickOn(xs[i])
207
+ }
208
+ /** 等面板"安顿下来"(开窗自动聚焦那一下会清掉选区,等它过去再划)。 */
209
+ async function settlePanel() {
210
+ var box = document.querySelector(".dsh-sel-askbox")
211
+ await waitFor(function () { return box && document.activeElement === box }, 800)
212
+ await sleep(30)
213
+ }
214
+ /** 划一段 → 点引用 → 返回那一段引用(并清场)。find() 返回 { node, needle }。 */
215
+ async function quoteOnce(label, find) {
216
+ await settlePanel()
217
+ var target = find()
218
+ if (!target || !target.node) { check(label + ':找到目标节点', false, '未找到'); return null }
219
+ var range = selectIn(target.node, target.needle)
220
+ check(label + ':能建出真选区', !!range, range ? range.toString().slice(0, 24) : '未找到目标文字')
221
+ if (!range) return null
222
+ mouseUpOn(target.node)
223
+ var shown = await waitFor(function () { return hook.quoteState().visible === true }, 1500)
224
+ check(label + ':浮出「❝ 引用」', shown === true, JSON.stringify(hook.quoteState()))
225
+ if (!shown) return null
226
+ check(label + ':浮标在最上层(不被面板盖住)', onTop(quoteBtn), '')
227
+ clickOn(quoteBtn)
228
+ await sleep(40)
229
+ var quotes = hook.quotes()
230
+ var picked = quotes.length ? quotes[quotes.length - 1] : null
231
+ check(label + ':进了引用区', chipCount() === 1 && !!picked, JSON.stringify(quotes.map(function (q) { return q.label + ':' + q.text.slice(0, 12) })))
232
+ dropQuotes()
233
+ await sleep(30)
234
+ return picked
235
+ }
236
+ function sectionContent(key) {
237
+ return document.querySelector('.dsh-sel-sec[data-sec="' + key + '"] .dsh-sel-c')
238
+ }
239
+
240
+ // ── 打开面板(fetch 桩给固定两节内容)──
241
+ hook.open('冒烟探针', '上下文片段 ABC', '冒烟')
242
+ await waitFor(function () { return hook.state().phase === 'done' }, 4000)
243
+ check('首轮渲染完成', hook.state().phase === 'done', hook.state().phase)
244
+
245
+ // ① 翻译节
246
+ var picked1 = await quoteOnce('翻译节划词', function () {
247
+ return { node: sectionContent('translation'), needle: '迁移跑得比预期久' }
248
+ })
249
+ var transTitle = document.querySelector('.dsh-sel-sec[data-sec="translation"] .dsh-sel-sh span')
250
+ var transTitleText = transTitle ? transTitle.textContent : ''
251
+ check(
252
+ '翻译节的来源写明是哪一节(用该节自己的标题)',
253
+ !!picked1 && !!transTitleText && picked1.label === '小窗「' + transTitleText + '」节',
254
+ picked1 && picked1.label + ' vs ' + transTitleText,
255
+ )
256
+
257
+ // ② 结论条(.dsh-sel-callout,也在翻译节里)
258
+ await quoteOnce('结论条划词', function () {
259
+ return { node: document.querySelector('.dsh-sel-callout'), needle: '进度延迟' }
260
+ })
261
+
262
+ // ③ 展开详解 → 详解节
263
+ hook.expand()
264
+ await waitFor(function () { return (hook.parts().detail || '').length > 0 && hook.state().phase === 'done' }, 4000)
265
+ check('详解节渲染出来了', !!sectionContent('detail'), '')
266
+ var picked3 = await quoteOnce('详解节划词', function () {
267
+ return { node: sectionContent('detail'), needle: '也可以被划词引用' }
268
+ })
269
+ check('详解节的来源写明是哪一节', !!picked3 && picked3.label.indexOf('详解') >= 0, picked3 && picked3.label)
270
+
271
+ // ④ 跨节选择:从翻译节拖到详解节
272
+ {
273
+ var from = sectionContent('translation')
274
+ var to = sectionContent('detail')
275
+ var texts = []
276
+ var walker = document.createTreeWalker(from, NodeFilter.SHOW_TEXT, null)
277
+ var node
278
+ while ((node = walker.nextNode())) if (node.nodeValue.trim()) texts.push(node)
279
+ if (texts.length && to.firstChild) {
280
+ var range = document.createRange()
281
+ range.setStart(texts[0], 0)
282
+ var endNode = to.firstChild.nodeType === 3 ? to.firstChild : to
283
+ range.setEnd(endNode, Math.min(4, (endNode.nodeValue || '').length))
284
+ var sel = window.getSelection()
285
+ sel.removeAllRanges()
286
+ sel.addRange(range)
287
+ mouseUpOn(to)
288
+ var shown = await waitFor(function () { return hook.quoteState().visible === true }, 1500)
289
+ check('跨节选择:也浮出「❝ 引用」', shown === true, JSON.stringify(hook.quoteState()))
290
+ if (shown) {
291
+ clickOn(quoteBtn)
292
+ await sleep(40)
293
+ check('跨节选择:进了引用区', chipCount() === 1, JSON.stringify(hook.quotes().map(function (q) { return q.text.length })))
294
+ dropQuotes()
295
+ await sleep(30)
296
+ }
297
+ } else {
298
+ check('跨节选择:也浮出「❝ 引用」', false, '没找到可跨的文本节点')
299
+ }
300
+ }
301
+
302
+ // ⑤ 面板**滚动之后**再划词(长内容 + 滚动是真实常态)
303
+ {
304
+ var body = document.querySelector('.dsh-sel-body')
305
+ body.scrollTop = Math.min(120, Math.max(0, body.scrollHeight - body.clientHeight))
306
+ await sleep(30)
307
+ var picked5 = await quoteOnce('滚动后划翻译节', function () {
308
+ return { node: sectionContent('translation'), needle: '迁移跑得比预期久' }
309
+ })
310
+ check('滚动之后划词照常能用', !!picked5, '')
311
+ }
312
+
313
+ // ⑥ 代码注释节(「## 注释」+ <pre>,划词落在 pre 里)
314
+ {
315
+ hook.close()
316
+ hook.open('function add(a, b) { return a + b }', 'const x = 1', '代码块')
317
+ await waitFor(function () { return hook.state().phase === 'done' }, 4000)
318
+ var header = document.querySelector('.dsh-sel-sec[data-sec="translation"] .dsh-sel-sh')
319
+ check('代码用例切到「注释」节', !!header && header.textContent.indexOf('注释') >= 0, header ? header.textContent : '未找到')
320
+ await quoteOnce('代码块里划词', function () {
321
+ return { node: document.querySelector('.dsh-sel-pre'), needle: 'const x' }
322
+ })
323
+ }
324
+
325
+ // ⑦ 对话气泡(不带上下文,但要写清出处)
326
+ {
327
+ hook.close()
328
+ hook.open('冒烟探针', '上下文片段 ABC', '冒烟')
329
+ await waitFor(function () { return hook.state().phase === 'done' }, 4000)
330
+ hook.ask('冒烟追问')
331
+ await waitFor(function () { return hook.state().asking === false }, 4000)
332
+ var bubbles = document.querySelectorAll('.dsh-sel-chatlog .dsh-sel-bubble-bot')
333
+ var bot = bubbles[bubbles.length - 1]
334
+ check('追问气泡渲染出来了', !!bot && bot.textContent.indexOf('冒烟用例') >= 0, bot ? bot.textContent.slice(0, 24) : '未找到')
335
+ var picked7 = await quoteOnce('对话气泡划词', function () { return { node: bot, needle: '冒烟用例' } })
336
+ check('对话引用写清了出处(第 N 轮 + 角色)', !!picked7 && /第 [0-9]+ 轮/.test(picked7.label), picked7 && picked7.label)
337
+ check('对话引用不带上下文(小窗对话本来就在历史里)', !!picked7 && !picked7.context, picked7 && JSON.stringify(picked7.context))
338
+ var all = bot.querySelector('.dsh-sel-bubquote')
339
+ check('气泡末尾有「引用整条」', !!all, '')
340
+ if (all) {
341
+ clickOn(all)
342
+ await sleep(40)
343
+ var quoted = hook.quotes()
344
+ var last = quoted.length ? quoted[quoted.length - 1] : null
345
+ check('「引用整条」也不带上下文', !!last && !last.context, last && JSON.stringify(last.context))
346
+ check('「引用整条」也写清出处', !!last && /第 [0-9]+ 轮/.test(last.label), last && last.label)
347
+ dropQuotes()
348
+ await sleep(30)
349
+ }
350
+ }
351
+
352
+ // ⑧ 主界面划词(小窗开着)→ 会话里的引用(带 host 上下文)
353
+ {
354
+ var docp = document.getElementById('docp')
355
+ var picked8 = await quoteOnce('主界面划词', function () { return { node: docp, needle: 'the migration ran long' } })
356
+ check('主界面引用标成会话里的', !!picked8 && picked8.session === true, picked8 && String(picked8.session))
357
+ }
358
+
359
+ // ⑨ 点「✦ 解读」后立刻收掉本文档选区(用户报的:小窗亮起时旧选区又触发「❝ 引用」浮标)
360
+ {
361
+ hook.close()
362
+ await sleep(80)
363
+ var planBtn = document.querySelector('.dsh-sel-btn:not(.dsh-sel-quotebtn)')
364
+ var docp2 = document.getElementById('docp')
365
+ selectIn(docp2, 'the migration ran long')
366
+ mouseUpOn(docp2)
367
+ var planShown = await waitFor(function () {
368
+ return planBtn && planBtn.style.display !== 'none' && planBtn.getBoundingClientRect().width > 0
369
+ }, 1500)
370
+ check('主界面划词:浮出「✦ 解读」', planShown === true, planBtn ? planBtn.style.display : '未找到按钮')
371
+ if (planShown) {
372
+ realClick(planBtn)
373
+ // ⚠️ 这里**同步**检查,一下都不 await:开窗自动聚焦那一下是异步的(要等输入框可见),
374
+ // 所以"选区这会儿已经没了"只可能是我们自己在点的时候清的 —— 它骗不过这一条。
375
+ var live = window.getSelection()
376
+ check(
377
+ '点「解读」后选区立刻收掉(同步检查,不等聚焦那一下)',
378
+ !!live && (live.rangeCount === 0 || live.isCollapsed === true),
379
+ live ? 'rangeCount=' + live.rangeCount + ' collapsed=' + live.isCollapsed : 'null',
380
+ )
381
+ check('选区记录也一并作废(不再拿它当"当前选区")', hook.selection() === null, JSON.stringify(hook.selection()))
382
+ await sleep(120)
383
+ check('旧选区没有让「❝ 引用」浮标复活', !onTop(quoteBtn), String(quoteBtn.style.display))
384
+ // 用户报症状的那一步:小窗亮着时在输入框里按方向键(keyup 会触发一次选区检查)
385
+ var askBox = document.querySelector('.dsh-sel-askbox')
386
+ if (askBox) {
387
+ askBox.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft', bubbles: true }))
388
+ await sleep(120)
389
+ check('开窗后按方向键也不再冒出引用浮标', !onTop(quoteBtn), String(quoteBtn.style.display))
390
+ }
391
+ hook.close()
392
+ await sleep(60)
393
+ }
394
+ }
395
+
396
+ finish()
397
+ }
398
+
399
+ main().catch(function (error) {
400
+ check('冒烟脚本自身没抛错', false, (error && error.message) || String(error))
401
+ finish()
402
+ })
403
+ </script>
404
+ </body></html>`
405
+
406
+ const server = createServer((req, res) => {
407
+ const url = (req.url || '/').split('?')[0]
408
+ if (url === '/client.js') {
409
+ res.writeHead(200, { 'content-type': 'application/javascript; charset=utf-8' })
410
+ res.end(readFileSync(BUNDLE))
411
+ return
412
+ }
413
+ if (url === '/' || url === '/smoke.html') {
414
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
415
+ res.end(PAGE)
416
+ return
417
+ }
418
+ res.writeHead(404, { 'content-type': 'text/plain' })
419
+ res.end('not found')
420
+ })
421
+
422
+ await new Promise((done) => server.listen(0, '127.0.0.1', done))
423
+ const port = server.address().port
424
+
425
+ function parseDump(dump) {
426
+ const matched = /<pre id="out">([\s\S]*?)<\/pre>/.exec(dump || '')
427
+ if (!matched) return null
428
+ const text = matched[1]
429
+ .replace(/&quot;/g, '"')
430
+ .replace(/&lt;/g, '<')
431
+ .replace(/&gt;/g, '>')
432
+ .replace(/&amp;/g, '&')
433
+ try {
434
+ return JSON.parse(text)
435
+ } catch (error) {
436
+ return null
437
+ }
438
+ }
439
+
440
+ let results = null
441
+ let dump = ''
442
+ try {
443
+ for (let attempt = 1; attempt <= 2 && !results; attempt += 1) {
444
+ const out = await new Promise((done) => {
445
+ execFile(
446
+ chrome,
447
+ [
448
+ '--headless',
449
+ '--disable-gpu',
450
+ '--no-first-run',
451
+ '--no-default-browser-check',
452
+ '--window-size=1200,900',
453
+ '--virtual-time-budget=25000',
454
+ '--dump-dom',
455
+ `http://127.0.0.1:${port}/smoke.html`,
456
+ ],
457
+ { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 90000 },
458
+ (error, stdout) => done(stdout || ''),
459
+ )
460
+ })
461
+ dump = out
462
+ results = parseDump(dump)
463
+ }
464
+ } finally {
465
+ if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
466
+ server.close()
467
+ }
468
+
469
+ let failed = 0
470
+ if (!results) {
471
+ console.log('FAIL 浏览器里没拿到结果(<pre id="out">)')
472
+ console.log(dump.slice(0, 400))
473
+ failed += 1
474
+ } else {
475
+ for (const item of results) {
476
+ console.log(`${item.ok ? 'PASS' : 'FAIL'} ${item.name}${item.extra ? ' — ' + item.extra : ''}`)
477
+ if (!item.ok) failed += 1
478
+ }
479
+ }
480
+ console.log(`\n=== 小窗面板冒烟结束:${results ? results.length - failed : 0} 通过 / ${failed} 失败 ===`)
481
+ process.exit(failed === 0 ? 0 : 1)