@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,772 @@
1
+ /**
2
+ * 语音输入的**真浏览器**冒烟测试(headless Chrome + 假麦克风 + 真 SenseVoice)。
3
+ *
4
+ * 为什么非要真浏览器:这条链路上有一大半是**浏览器自己的行为**,DOM 桩测不出来 ——
5
+ * · getUserMedia 的授权与真实音轨;
6
+ * · MediaRecorder 产出的容器(Chrome 是 webm/opus)`decodeAudioData` 能不能解;
7
+ * · `OfflineAudioContext` 的重采样(源采样率/声道由浏览器决定,不是我们能假设的);
8
+ * · 我们手写的 WAV 头在**真引擎**里生成的字节,host 的 validateWave 认不认。
9
+ * 桩里这四步全是假的(见 scripts/test-client.mjs 的那一节),只有真引擎说了算。
10
+ *
11
+ * 做法(自包含,不需要 DSH 的登录 token):
12
+ * ① 用 `say` + `ffmpeg` 造一段真人语音的规范 WAV(macOS;没有就 SKIP);
13
+ * ② 起一个临时 http 服务:页面与 lib/client.js 由它提供,analyze 等路由给本地夹具
14
+ * (冒烟不烧模型额度),**识别路由转发给真的 DSH host**(真 SenseVoice 转写);
15
+ * ③ 起 headless Chrome,用 `--use-file-for-fake-audio-capture` 把那段话当麦克风输入;
16
+ * ④ 用真的鼠标事件点麦克风 → 说 3.5 秒 → 再点一下 → 断言文字进了输入框。
17
+ *
18
+ * 页面里跑的是**真的 lib/client.js**(不是抄一份代码):只补一层最小的客户端运行时
19
+ * 桩(ModuleLoader / React.createElement / slots 注册)——面板本身是命令式 DOM,
20
+ * 补完这一层就是完整的真 UI。
21
+ *
22
+ * 用法:node scripts/smoke-voice.mjs
23
+ * CHROME=/path/to/chrome node scripts/smoke-voice.mjs
24
+ * SPEECH_WAV=/tmp/v.wav node scripts/smoke-voice.mjs (用自己的语音样本)
25
+ * SEL_ORIGIN=http://127.0.0.1:3080 node scripts/smoke-voice.mjs
26
+ * SMOKE_SHOT=/tmp/v.png node scripts/smoke-voice.mjs (截图落点)
27
+ * 退出码:全部 PASS(或 SKIP)= 0。
28
+ */
29
+ import { spawn, execFile } from 'node:child_process'
30
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
31
+ import { createServer } from 'node:http'
32
+ import { tmpdir } from 'node:os'
33
+ import { dirname, join, resolve } from 'node:path'
34
+ import { fileURLToPath } from 'node:url'
35
+ import { promisify } from 'node:util'
36
+ import { validateWave } from '../lib/index.js'
37
+
38
+ const execFileAsync = promisify(execFile)
39
+ const HERE = dirname(fileURLToPath(import.meta.url))
40
+ const BUNDLE = resolve(HERE, '..', 'lib', 'client.js')
41
+ const ORIGIN = process.env.SEL_ORIGIN || 'http://127.0.0.1:3080'
42
+ const SPEECH_API = '/selection-explain/api/speech'
43
+
44
+ let failed = 0
45
+ const assert = (label, ok, extra) => {
46
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' — ' + extra : ''}`)
47
+ if (!ok) failed += 1
48
+ }
49
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
50
+
51
+ function findChrome() {
52
+ const candidates = [
53
+ process.env.CHROME,
54
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
55
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
56
+ '/usr/bin/google-chrome',
57
+ '/usr/bin/chromium',
58
+ '/usr/bin/chromium-browser',
59
+ ].filter(Boolean)
60
+ for (const item of candidates) if (existsSync(item)) return item
61
+ return ''
62
+ }
63
+
64
+ /** 造一段真人语音的规范 WAV(say → ffmpeg 到 16kHz 单声道 PCM16)。 */
65
+ async function ensureSpeechWav(dir) {
66
+ if (process.env.SPEECH_WAV) {
67
+ if (!existsSync(process.env.SPEECH_WAV)) throw new Error('SPEECH_WAV 不存在:' + process.env.SPEECH_WAV)
68
+ return process.env.SPEECH_WAV
69
+ }
70
+ if (process.platform !== 'darwin') throw new Error('非 macOS:请用 SPEECH_WAV 指定一段 16kHz 单声道 WAV')
71
+ const wav = join(dir, 'speech.wav')
72
+ /** 一句短的(≤3 秒):好让"停顿"在录音里早一点出现。 */
73
+ const say = async (text, out) => {
74
+ const aiff = join(dir, out + '.aiff')
75
+ const one = join(dir, out + '.wav')
76
+ await execFileAsync('/usr/bin/say', ['-v', 'Tingting', '-o', aiff, text], { timeout: 60000 })
77
+ await execFileAsync(
78
+ 'ffmpeg',
79
+ ['-y', '-v', 'error', '-i', aiff, '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', '-fflags', '+bitexact', '-map_metadata', '-1', one],
80
+ { timeout: 60000 },
81
+ )
82
+ return readFileSync(one)
83
+ }
84
+ // 两句不同的话 + 各自 1.3 秒停顿:Chrome 循环播放这个文件,麦克风里于是周期性地出现**真实停顿**,
85
+ // 冒烟才会走到"停顿定稿"和"定稿之后继续出字"——正是用户报的那条路(只跟第一句、后面不吐字)。
86
+ const first = await say('你好,这是一段语音输入的测试。', 'speech-a')
87
+ const second = await say('帮我解释一下划词解读。', 'speech-b')
88
+ const gap = Buffer.alloc(16000 * 2 * 1.3)
89
+ const pieces = [first.subarray(44), gap, second.subarray(44), gap]
90
+ const body = Buffer.concat(pieces)
91
+ const headerOut = Buffer.from(first.subarray(0, 44))
92
+ headerOut.writeUInt32LE(36 + body.length, 4)
93
+ headerOut.writeUInt32LE(body.length, 40)
94
+ writeFileSync(wav, Buffer.concat([headerOut, body]))
95
+ return wav
96
+ }
97
+
98
+ // ───────────────────────── 前置检查 ─────────────────────────
99
+
100
+ const chrome = findChrome()
101
+ if (!chrome) {
102
+ console.log('SKIP 没找到 Chrome/Chromium(设 CHROME=/path/to/chrome 可指定)')
103
+ process.exit(0)
104
+ }
105
+
106
+ /** 真识别服务在不在?(在就转发真 host,不在就用夹具,只验浏览器侧那半条链路) */
107
+ let realSpeech = false
108
+ let hostCatalog = null
109
+ try {
110
+ const response = await fetch(`${ORIGIN}${SPEECH_API}`)
111
+ hostCatalog = response.ok ? await response.json() : null
112
+ realSpeech = !!(hostCatalog && hostCatalog.available)
113
+ } catch (error) {
114
+ hostCatalog = null
115
+ }
116
+ if (hostCatalog && !hostCatalog.available) {
117
+ console.log(`(host 在跑但没有就绪的识别器:${JSON.stringify(hostCatalog.providers.map((p) => p.id + ':' + p.phase))})`)
118
+ }
119
+
120
+ const workDir = mkdtempSync(join(tmpdir(), 'dsh-voice-smoke-'))
121
+ let speechWav = ''
122
+ try {
123
+ speechWav = await ensureSpeechWav(workDir)
124
+ } catch (error) {
125
+ console.log('SKIP 造不出语音样本(' + String(error.message || error).slice(0, 140) + ')')
126
+ rmSync(workDir, { recursive: true, force: true })
127
+ process.exit(0)
128
+ }
129
+ console.log(`=== 语音样本:${speechWav}(${readFileSync(speechWav).length} 字节)===`)
130
+ console.log(`=== 识别:${realSpeech ? '真 host(' + hostCatalog.providers.map((p) => p.id + ':' + p.phase).join(', ') + ')' : '夹具(host 不可达 / 没有就绪识别器)'} ===`)
131
+
132
+ // ───────────────────────── 临时服务:页面 + 路由 ─────────────────────────
133
+
134
+ /** 浏览器真发过来的那段音频(真字节,Node 侧再校验一次格式契约)。 */
135
+ let capturedAudio = null
136
+
137
+ const HARNESS = `<!doctype html>
138
+ <html><head><meta charset="utf-8"><title>语音输入冒烟</title>
139
+ <style>body{margin:0;height:100vh;background:#eef1f5;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif}
140
+ #mount{position:relative;height:100vh}</style></head>
141
+ <body>
142
+ <div id="mount"></div>
143
+ <script>window.__ModuleLoader__ = { load: function (spec) { window.__spec = spec } }</script>
144
+ <script src="/lib/client.js"></script>
145
+ <script>
146
+ (function () {
147
+ var React = { createElement: function (type, props) { return { type: type, props: props || {}, children: [] } } }
148
+ if (!window.__spec) { window.__harnessError = 'bundle 没有注册 ModuleLoader 工厂'; return }
149
+ var modules = window.__spec.factory(function (name) {
150
+ if (name === 'react') return React
151
+ throw new Error('unexpected require: ' + name)
152
+ })
153
+ window.__disposers = []
154
+ var ctx = {
155
+ effect: function (fn) { var d = fn(); if (typeof d === 'function') window.__disposers.push(d); return function () {} },
156
+ on: function () {},
157
+ get: function () { return undefined },
158
+ slots: {
159
+ inject: function (key, cb) { return cb() },
160
+ register: function (options, component) { window.__registration = { options: options, component: component }; return function () {} }
161
+ }
162
+ }
163
+ modules.apply(ctx)
164
+ // 浮层组件 → 真 DOM:只用得到 createElement + ref(面板本身全是命令式 DOM)
165
+ var element = window.__registration.component()
166
+ var node = document.createElement(element.type)
167
+ var props = element.props || {}
168
+ for (var key in props) {
169
+ if (key === 'ref' || key === 'children' || key === 'style') continue
170
+ node[key] = props[key]
171
+ }
172
+ if (props.style) for (var s in props.style) node.style[s] = props.style[s]
173
+ document.getElementById('mount').appendChild(node)
174
+ props.ref(node)
175
+ window.__harnessReady = true
176
+ })()
177
+ </script>
178
+ </body></html>`
179
+
180
+ const json = (res, body, status = 200) => {
181
+ const text = JSON.stringify(body)
182
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) })
183
+ res.end(text)
184
+ }
185
+
186
+ /** 把请求原样转给真 DSH host(识别这条路要的是真模型)。 */
187
+ async function proxy(req, res, pathname) {
188
+ const chunks = []
189
+ for await (const chunk of req) chunks.push(chunk)
190
+ const body = Buffer.concat(chunks)
191
+ const upstream = await fetch(`${ORIGIN}${pathname}${new URL(req.url, 'http://x').search}`, {
192
+ method: req.method,
193
+ headers: { 'content-type': req.headers['content-type'] || 'application/json' },
194
+ body: req.method === 'POST' ? body : undefined,
195
+ })
196
+ const text = await upstream.text()
197
+ res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') || 'application/json' })
198
+ res.end(text)
199
+ }
200
+
201
+ const server = createServer((req, res) => {
202
+ const pathname = new URL(req.url, 'http://127.0.0.1').pathname
203
+ void (async () => {
204
+ if (pathname === '/' || pathname === '/harness.html') {
205
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
206
+ res.end(HARNESS)
207
+ return
208
+ }
209
+ if (pathname === '/lib/client.js') {
210
+ res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' })
211
+ res.end(readFileSync(BUNDLE))
212
+ return
213
+ }
214
+ // 小窗开场(analyze):本地夹具,冒烟不烧模型额度
215
+ if (pathname === '/selection-explain/api/analyze') {
216
+ const sse = [
217
+ `data: ${JSON.stringify({ type: 'start', provider: 'smoke', model: 'smoke', stage: 'translation' })}\n\n`,
218
+ `data: ${JSON.stringify({ type: 'delta', text: '## 翻译\n这次数据迁移耗时超出预期,所以我们周三发布。\n' })}\n\n`,
219
+ `data: ${JSON.stringify({ type: 'done', chars: 12 })}\n\n`,
220
+ ].join('')
221
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
222
+ res.end(sse)
223
+ return
224
+ }
225
+ if (pathname === '/selection-explain/api/ping') {
226
+ json(res, { ok: true, plugin: '@yfwu2020/dsh-selection-explain', route: null, reasoningEffortByStage: {}, tools: { enabled: false, offered: [], whitelist: [] } })
227
+ return
228
+ }
229
+ if (pathname === '/selection-explain/api/models') {
230
+ json(res, { ok: true, current: null, stages: null, models: [] })
231
+ return
232
+ }
233
+ if (pathname === '/selection-explain/api/history') {
234
+ json(res, { ok: true, entries: [] })
235
+ return
236
+ }
237
+ if (pathname === '/selection-explain/api/quote-context') {
238
+ json(res, { ok: true, matched: false, context: '', rounds: 0 })
239
+ return
240
+ }
241
+ // 识别:先落一份真字节做格式校验,再转发给真 host(或回夹具)
242
+ if (pathname === `${SPEECH_API}/transcribe`) {
243
+ const chunks = []
244
+ for await (const chunk of req) chunks.push(chunk)
245
+ const body = Buffer.concat(chunks)
246
+ let decoded = null
247
+ try {
248
+ const payload = JSON.parse(body.toString('utf8'))
249
+ decoded = Buffer.from(String(payload.audioBase64 || ''), 'base64')
250
+ capturedAudio = decoded
251
+ } catch (error) {
252
+ capturedAudio = null
253
+ }
254
+ if (!realSpeech) {
255
+ json(res, { ok: true, text: '夹具转写的一段话', providerId: 'fixture', seconds: decoded ? (decoded.length - 44) / 32000 : 0 })
256
+ return
257
+ }
258
+ const upstream = await fetch(`${ORIGIN}${SPEECH_API}/transcribe`, {
259
+ method: 'POST',
260
+ headers: { 'content-type': 'application/json' },
261
+ body,
262
+ })
263
+ const text = await upstream.text()
264
+ res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') || 'application/json' })
265
+ res.end(text)
266
+ return
267
+ }
268
+ if (pathname.startsWith(SPEECH_API)) {
269
+ if (realSpeech) {
270
+ await proxy(req, res, pathname)
271
+ return
272
+ }
273
+ // 夹具替身 = 本机识别器(location 必须是 host-local:写成 cloud 会被"云端不做实时预览"那条闸门关掉,
274
+ // 于是降级模式下实时字幕整层不工作,报出来的错会指向完全无关的地方)
275
+ json(res, { ok: true, available: true, reason: '', error: '', providers: [{ id: 'fixture', name: 'Fixture', location: 'host-local', languages: ['auto'], downloadSources: [], phase: 'ready' }], selection: { providerId: 'fixture', language: 'auto' }, limits: { maxSeconds: 60, maxBytes: 4194304 } })
276
+ return
277
+ }
278
+ res.writeHead(404, { 'content-type': 'text/plain' })
279
+ res.end('not found: ' + pathname)
280
+ })().catch((error) => {
281
+ try {
282
+ json(res, { ok: false, error: String((error && error.message) || error) }, 500)
283
+ } catch (ignored) {
284
+ /* 头已经发出去了 */
285
+ }
286
+ })
287
+ })
288
+
289
+ await new Promise((done) => server.listen(0, '127.0.0.1', done))
290
+ const port = server.address().port
291
+ const pageUrl = `http://127.0.0.1:${port}/`
292
+
293
+ /** 极简 CDP 客户端(Node 24 自带 WebSocket,不引依赖)。 */
294
+ class Cdp {
295
+ constructor(url) {
296
+ this.seq = 0
297
+ this.pending = new Map()
298
+ this.ws = new WebSocket(url)
299
+ this.ready = new Promise((resolve, reject) => {
300
+ this.ws.addEventListener('open', () => resolve())
301
+ this.ws.addEventListener('error', () => reject(new Error('CDP 连接失败')))
302
+ })
303
+ this.ws.addEventListener('message', (event) => {
304
+ let message = null
305
+ try {
306
+ message = JSON.parse(String(event.data))
307
+ } catch (error) {
308
+ return
309
+ }
310
+ if (!message.id || !this.pending.has(message.id)) return
311
+ const entry = this.pending.get(message.id)
312
+ this.pending.delete(message.id)
313
+ if (message.error) entry.reject(new Error(message.error.message || 'CDP 调用失败'))
314
+ else entry.resolve(message.result)
315
+ })
316
+ }
317
+ async send(method, params) {
318
+ await this.ready
319
+ const id = ++this.seq
320
+ return await new Promise((resolve, reject) => {
321
+ this.pending.set(id, { resolve, reject })
322
+ try {
323
+ this.ws.send(JSON.stringify({ id, method, params: params || {} }))
324
+ } catch (error) {
325
+ this.pending.delete(id)
326
+ reject(error)
327
+ }
328
+ setTimeout(() => {
329
+ if (this.pending.has(id)) {
330
+ this.pending.delete(id)
331
+ reject(new Error(`CDP 超时:${method}`))
332
+ }
333
+ }, 30000)
334
+ })
335
+ }
336
+ async eval(expression) {
337
+ const result = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, userGesture: true })
338
+ if (result.exceptionDetails) {
339
+ const details = result.exceptionDetails.exception
340
+ throw new Error('页面里抛错:' + ((details && details.description) || result.exceptionDetails.text))
341
+ }
342
+ return result.result ? result.result.value : undefined
343
+ }
344
+ async waitFor(expression, ms) {
345
+ const started = Date.now()
346
+ for (;;) {
347
+ let value = false
348
+ try {
349
+ value = await this.eval(expression)
350
+ } catch (error) {
351
+ value = false
352
+ }
353
+ if (value) return true
354
+ if (Date.now() - started > ms) return false
355
+ await sleep(150)
356
+ }
357
+ }
358
+ close() {
359
+ try {
360
+ this.ws.close()
361
+ } catch (error) {
362
+ /* noop */
363
+ }
364
+ }
365
+ }
366
+
367
+ /** 只截面板本体(2x)——README 的演示图要的是小窗,不是整页。 */
368
+ async function shotPanel(cdp, file) {
369
+ const rect = await cdp.eval(`(() => {
370
+ const node = document.querySelector('.dsh-sel-panel')
371
+ if (!node) return null
372
+ const box = node.getBoundingClientRect()
373
+ return { x: box.left, y: box.top, width: box.width, height: box.height }
374
+ })()`)
375
+ if (!rect || !rect.width) return ''
376
+ const shot = await cdp.send('Page.captureScreenshot', {
377
+ format: 'png',
378
+ clip: { x: Math.max(0, rect.x - 6), y: Math.max(0, rect.y - 6), width: rect.width + 12, height: rect.height + 12, scale: 2 },
379
+ })
380
+ writeFileSync(file, Buffer.from(shot.data, 'base64'))
381
+ return file
382
+ }
383
+
384
+ /** 点一下某个元素的正中间(真鼠标事件 = 用户手势)。 */
385
+ async function clickSelector(cdp, selector) {
386
+ const rect = await cdp.eval(`(() => {
387
+ const node = document.querySelector(${JSON.stringify(selector)})
388
+ if (!node) return null
389
+ const box = node.getBoundingClientRect()
390
+ return { x: box.left + box.width / 2, y: box.top + box.height / 2, w: box.width, h: box.height, top: box.top, left: box.left }
391
+ })()`)
392
+ if (!rect || !rect.w) return null
393
+ const params = { x: Math.round(rect.x), y: Math.round(rect.y), button: 'left', clickCount: 1 }
394
+ await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...params })
395
+ await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...params })
396
+ await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...params })
397
+ return rect
398
+ }
399
+
400
+ // ───────────────────────── 起 headless Chrome ─────────────────────────
401
+
402
+ const debugPort = 9200 + Math.floor(Math.random() * 300)
403
+ const profile = join(workDir, 'profile')
404
+ const child = spawn(
405
+ chrome,
406
+ [
407
+ '--headless=new',
408
+ '--disable-gpu',
409
+ // 读 --use-file-for-fake-audio-capture 那个文件会走沙箱里的文件读取,headless 下会失败
410
+ // (Chrome 自己提示 "Try disabling the sandbox with --no-sandbox"):
411
+ // 不听劝的话假麦克风一直是**静音**,表现就是"录音成功但识别空"。
412
+ // 这里只跑本地临时页面 + 一次性 profile,没有沙箱需求。
413
+ '--no-sandbox',
414
+ '--no-first-run',
415
+ '--no-default-browser-check',
416
+ '--mute-audio',
417
+ '--window-size=1280,900',
418
+ // 假麦克风:授权自动通过,输入来自上面那段真语音(Chrome 会循环播放)
419
+ '--use-fake-ui-for-media-stream',
420
+ '--use-fake-device-for-media-stream',
421
+ `--use-file-for-fake-audio-capture=${speechWav}`,
422
+ '--autoplay-policy=no-user-gesture-required',
423
+ `--remote-debugging-port=${debugPort}`,
424
+ `--user-data-dir=${profile}`,
425
+ 'about:blank',
426
+ ],
427
+ { stdio: ['ignore', 'ignore', 'pipe'] },
428
+ )
429
+ let chromeLog = ''
430
+ child.stderr.on('data', (chunk) => {
431
+ chromeLog += String(chunk)
432
+ })
433
+
434
+ const cleanup = () => {
435
+ try {
436
+ child.kill('SIGKILL')
437
+ } catch (error) {
438
+ /* noop */
439
+ }
440
+ try {
441
+ if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
442
+ server.close()
443
+ } catch (error) {
444
+ /* noop */
445
+ }
446
+ try {
447
+ rmSync(profile, { recursive: true, force: true })
448
+ rmSync(workDir, { recursive: true, force: true })
449
+ } catch (error) {
450
+ /* noop */
451
+ }
452
+ }
453
+ process.on('exit', cleanup)
454
+
455
+ let version = null
456
+ for (let i = 0; i < 100; i += 1) {
457
+ try {
458
+ const response = await fetch(`http://127.0.0.1:${debugPort}/json/version`)
459
+ if (response.ok) {
460
+ version = await response.json()
461
+ break
462
+ }
463
+ } catch (error) {
464
+ /* 还没起来 */
465
+ }
466
+ await sleep(200)
467
+ }
468
+ if (!version) {
469
+ console.log('SKIP Chrome 没起来(DevTools 端口没响应):' + chromeLog.slice(0, 300))
470
+ cleanup()
471
+ process.exit(0)
472
+ }
473
+ console.log(`=== Chrome:${version.Browser} ===`)
474
+
475
+ const list = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json()
476
+ const target = list.find((item) => item.type === 'page')
477
+ const cdp = new Cdp(target.webSocketDebuggerUrl)
478
+ await cdp.send('Page.enable')
479
+ await cdp.send('Runtime.enable')
480
+ await cdp.send('Page.navigate', { url: pageUrl })
481
+
482
+ // ───────────────────────── 驱动真页面 ─────────────────────────
483
+
484
+ const harnessReady = await cdp.waitFor('!!(window.__harnessReady || window.__harnessError)', 20000)
485
+ const harnessError = await cdp.eval('window.__harnessError || ""')
486
+ assert('页面里跑起来了真的 lib/client.js(最小运行时桩)', harnessReady && !harnessError, harnessError || pageUrl)
487
+ if (!harnessReady || harnessError) {
488
+ console.log('(Chrome 日志尾部:' + chromeLog.slice(-300) + ')')
489
+ cdp.close()
490
+ cleanup()
491
+ process.exit(1)
492
+ }
493
+
494
+ await cdp.eval(`window.__dshSelectionExplain.open('the migration ran long, so we ship Wednesday', '', '语音冒烟')`)
495
+ const panelDone = await cdp.waitFor(`window.__dshSelectionExplain.state().phase === 'done'`, 15000)
496
+ assert('小窗打开、翻译就绪(analyze 走本地夹具)', panelDone, await cdp.eval('window.__dshSelectionExplain.state().phase'))
497
+
498
+ const composerVisible = await cdp.waitFor(
499
+ `(() => { const row = document.querySelector('.dsh-sel-ask'); if (!row || row.style.display === 'none') return false; const mic = document.querySelector('.dsh-sel-mic'); return !!mic && mic.getBoundingClientRect().width > 0 })()`,
500
+ 8000,
501
+ )
502
+ assert('composer 与麦克风按钮可见(真的能点到)', composerVisible, String(composerVisible))
503
+ if (!composerVisible) {
504
+ cdp.close()
505
+ cleanup()
506
+ process.exit(1)
507
+ }
508
+
509
+ const micBox = await clickSelector(cdp, '.dsh-sel-mic')
510
+ assert('用鼠标点到了麦克风(真事件,算用户手势)', !!micBox, micBox ? `${Math.round(micBox.w)}x${Math.round(micBox.h)}` : '找不到按钮')
511
+
512
+ const recording = await cdp.waitFor(`window.__dshSelectionExplain.voice().phase === 'recording'`, 10000)
513
+ const voiceWhileRecording = await cdp.eval('window.__dshSelectionExplain.voice()')
514
+ assert('真浏览器里进入录音态(授权 + MediaRecorder 都成)', recording, JSON.stringify(voiceWhileRecording))
515
+ if (!recording) {
516
+ console.log('(录音没起来:' + JSON.stringify(voiceWhileRecording) + ')')
517
+ cdp.close()
518
+ cleanup()
519
+ process.exit(1)
520
+ }
521
+
522
+ await sleep(1200)
523
+ // 实时字幕:**还在录**的时候输入框里就该有字了(半句预览,真识别)
524
+ const previewArrived = await cdp.waitFor(
525
+ `(() => { const box = document.querySelector('.dsh-sel-askbox'); const v = window.__dshSelectionExplain.voice(); return !!box && box.value.trim().length > 0 && v.live.passes.preview >= 1 })()`,
526
+ 6000,
527
+ )
528
+ const liveState = await cdp.eval(
529
+ `(() => { const box = document.querySelector('.dsh-sel-askbox'); const v = window.__dshSelectionExplain.voice(); return { value: box ? box.value : '', live: v.live } })()`,
530
+ )
531
+ assert('半句预览在录到 1-2 秒时就出字(不是等停止)', previewArrived, JSON.stringify(liveState).slice(0, 200))
532
+ assert(
533
+ '录音中就已经有字了(半句预览:真 SenseVoice 边说边转)',
534
+ liveState.value.trim().length > 0 && liveState.live.passes.preview >= 1,
535
+ JSON.stringify(liveState).slice(0, 220),
536
+ )
537
+ assert(
538
+ '预览文字就是输入框里那段(没有被追加重影)',
539
+ liveState.value.trim() === String(liveState.live.preview || '').trim(),
540
+ JSON.stringify({ value: liveState.value, preview: liveState.live.preview }).slice(0, 200),
541
+ )
542
+
543
+ // 输入框自适应拉伸:真浏览器里量一次(多行要撑开、超长要内部滚动并停在最新一行)
544
+ const layout = await cdp.eval(`(() => {
545
+ const box = document.querySelector('.dsh-sel-askbox')
546
+ const NL = String.fromCharCode(10) // 换行符:这段代码写在 Node 的模板字符串里,反斜杠转义会被提前吃掉
547
+ const read = () => {
548
+ const style = getComputedStyle(box)
549
+ return {
550
+ clientHeight: box.clientHeight,
551
+ scrollHeight: box.scrollHeight,
552
+ height: box.style.height,
553
+ overflowY: style.overflowY,
554
+ atBottom: box.scrollTop + box.clientHeight >= box.scrollHeight - 2,
555
+ }
556
+ }
557
+ const saved = box.value
558
+ box.value = ['第一行', '第二行', '第三行', '第四行'].join(NL)
559
+ box.dispatchEvent(new Event('input', { bubbles: true }))
560
+ const grown = read()
561
+ box.value = Array.from({ length: 40 }, (_, i) => '第 ' + (i + 1) + ' 行').join(NL)
562
+ box.dispatchEvent(new Event('input', { bubbles: true }))
563
+ const capped = read()
564
+ box.value = saved
565
+ box.dispatchEvent(new Event('input', { bubbles: true }))
566
+ return { grown, capped, restored: read() }
567
+ })()`)
568
+ assert(
569
+ '真浏览器:四行草稿把输入框撑开、下面的字没被裁掉',
570
+ layout.grown.clientHeight >= 70 && layout.grown.clientHeight > 30 && layout.grown.scrollHeight <= layout.grown.clientHeight + 2,
571
+ JSON.stringify(layout.grown),
572
+ )
573
+ assert(
574
+ '真浏览器:超过上限(132px)改成内部滚动,并且停在最新一行',
575
+ // 132px 是内容上限,加上 padding-top 4px = 136;超过就内部滚动,并且停在最新一行
576
+ layout.capped.clientHeight <= 140 && layout.capped.scrollHeight > layout.capped.clientHeight && layout.capped.overflowY === 'auto' && layout.capped.atBottom === true,
577
+ JSON.stringify(layout.capped),
578
+ )
579
+ assert('真浏览器:恢复成短草稿后又缩回去(不会一直占着高度)', layout.restored.clientHeight <= 40, JSON.stringify(layout.restored))
580
+
581
+ // 光标:录音时焦点在输入框里、光标停在最新文字末尾(否则看不见"光标在跳")
582
+ const caret = await cdp.eval(`(() => {
583
+ const box = document.querySelector('.dsh-sel-askbox')
584
+ const focused = document.activeElement === box
585
+ return { focused, start: box.selectionStart, end: box.selectionEnd, length: box.value.length }
586
+ })()`)
587
+ assert('录音时输入框有焦点(textarea 没焦点就不画光标)', caret.focused === true, JSON.stringify(caret))
588
+ assert('光标停在最新文字末尾(预览落字时它跟着往后跳)', caret.start === caret.length && caret.end === caret.length && caret.length > 0, JSON.stringify(caret))
589
+
590
+ // 录音中留一张图:录音行(✕ / 波形 / ■)+ 输入框里跟着长出来的字(README 的演示图就是它)
591
+ try {
592
+ const file = await shotPanel(cdp, process.env.SMOKE_SHOT_RECORDING || join(tmpdir(), 'dsh-voice-smoke-recording.png'))
593
+ if (file) console.log(`=== 截图(录音中):${file} ===`)
594
+ } catch (error) {
595
+ /* 截图失败不影响断言 */
596
+ }
597
+ const midVoice = await cdp.eval('window.__dshSelectionExplain.voice()')
598
+ assert('录音中:工具行换成了录音行(✕ | 波形 | ■,与主会话同构)', midVoice.capture === true && midVoice.waveform === true && midVoice.stop === true, JSON.stringify({ capture: midVoice.capture, waveform: midVoice.waveform, stop: midVoice.stop }))
599
+ assert('录音中不显示秒数文案(主会话也是只看波形)', midVoice.activity === '', JSON.stringify(midVoice.activity))
600
+ assert('麦克风电平真的在动(假麦克风里放的是那段真语音)', midVoice.level > 0, String(midVoice.level))
601
+ assert(
602
+ '波形真的在跳(80 根线里有明显高于 2px 基线的)',
603
+ Array.isArray(midVoice.bars) && midVoice.bars.length === 80 && Math.max.apply(null, midVoice.bars) > 4,
604
+ midVoice.bars ? `max=${Math.max.apply(null, midVoice.bars).toFixed(1)}` : 'no bars',
605
+ )
606
+
607
+ // 录音态下的窄窗口:✕ / 波形 / ■ / 发送键 都得在可视区里(多一个按钮最容易挤爆的就是这一行)
608
+ try {
609
+ await cdp.send('Emulation.setDeviceMetricsOverride', { width: 380, height: 760, deviceScaleFactor: 1, mobile: false })
610
+ await sleep(250)
611
+ const narrowRecording = await cdp.eval(`(() => {
612
+ const tools = document.querySelector('.dsh-sel-asktools')
613
+ const row = document.querySelector('.dsh-sel-capture')
614
+ const send = document.querySelector('.dsh-sel-asksend')
615
+ const panel = document.querySelector('.dsh-sel-panel')
616
+ if (!tools || !row || !send || !panel) return null
617
+ const box = (node) => node.getBoundingClientRect()
618
+ const panelBox = box(panel)
619
+ const sendBox = box(send)
620
+ return {
621
+ overflow: tools.scrollWidth - tools.clientWidth,
622
+ sendInPanel: sendBox.right <= panelBox.right + 1 && sendBox.bottom <= panelBox.bottom + 1,
623
+ rowInPanel: box(row).right <= panelBox.right + 1,
624
+ }
625
+ })()`)
626
+ assert('380px 录音态:录音行与发送键都在面板里、不横向溢出', !!narrowRecording && narrowRecording.overflow <= 1 && narrowRecording.sendInPanel && narrowRecording.rowInPanel, JSON.stringify(narrowRecording))
627
+ await cdp.send('Emulation.clearDeviceMetricsOverride')
628
+ await sleep(150)
629
+ } catch (error) {
630
+ console.log('(录音态窄窗口检查跳过:' + String(error.message || error).slice(0, 120) + ')')
631
+ }
632
+ await sleep(4000)
633
+
634
+ // 回归(用户报过:只跟第一句、后面不吐字):样本里带停顿,所以到这里应该已经**定稿过至少一次**,
635
+ // 而定稿之后实时层必须继续跟着出字 —— 这正是当时挂掉的地方("没听清"被当成失败计数)。
636
+ const afterPause = await cdp.eval(
637
+ `(() => { const box = document.querySelector('.dsh-sel-askbox'); const v = window.__dshSelectionExplain.voice(); return { value: box ? box.value : '', live: v.live } })()`,
638
+ )
639
+ assert('真浏览器里走到"停顿定稿"(样本里两句之间停了 1.3 秒)', afterPause.live.passes.commit >= 1 && afterPause.live.committed.length > 0, JSON.stringify(afterPause.live).slice(0, 220))
640
+ assert(
641
+ '定稿之后照样继续出字(停顿那几拍"没听清"不能把实时层关掉)',
642
+ afterPause.live.disabled === false && afterPause.live.reason === '',
643
+ JSON.stringify({ disabled: afterPause.live.disabled, reason: afterPause.live.reason, value: afterPause.value }).slice(0, 200),
644
+ )
645
+ assert(
646
+ '输入框里两句都在:第一句定稿 + 第二句半句预览(用户报的 bug 就是这里断的)',
647
+ /你好|语音|测试/.test(afterPause.value) && /帮我|解释|划词|画词/.test(afterPause.value),
648
+ JSON.stringify(afterPause.value),
649
+ )
650
+
651
+ // 结束录音按的是录音行里的 ■(不再是 🎤 —— 录音中 🎤 让位给录音行,和主会话一样)
652
+ const stopBox = await clickSelector(cdp, '.dsh-sel-stop')
653
+ assert('用鼠标点到了录音行的 ■(停止并识别)', !!stopBox, stopBox ? `${Math.round(stopBox.w)}x${Math.round(stopBox.h)}` : '找不到停止键')
654
+ await cdp.waitFor(`window.__dshSelectionExplain.voice().phase !== 'recording'`, 8000)
655
+
656
+ // 注意:输入框里**早就有字了**(实时预览),所以这里要等的是"整段识别收口完成"
657
+ const finalized = await cdp.waitFor(
658
+ `(() => { const v = window.__dshSelectionExplain.voice(); return v.capture === false && v.phase === 'idle' })()`,
659
+ 40000,
660
+ )
661
+ const finalState = await cdp.eval(
662
+ `(() => { const box = document.querySelector('.dsh-sel-askbox'); return { value: box ? box.value : '', voice: window.__dshSelectionExplain.voice() } })()`,
663
+ )
664
+ assert('停止后整段识别收口(录音行收起、实时层停机)', finalized, JSON.stringify(finalState.voice).slice(0, 160))
665
+ assert('识别出来的文字插进了追问输入框', finalState.value.trim().length > 0, JSON.stringify(finalState.value))
666
+ assert('识别完成后录音行收起、🎤 回来(和主会话一致:成功不另报一句)', finalState.voice.capture === false && finalState.voice.phase === 'idle', JSON.stringify({ capture: finalState.voice.capture, phase: finalState.voice.phase }))
667
+ assert('停止后实时层关掉(不再刷新那段文字)', finalState.voice.live.active === false, JSON.stringify(finalState.voice.live).slice(0, 120))
668
+ assert('没有报错(activity 不是错误色、也不是失败文案)', finalState.voice.activityTone !== 'error' && !/失败|未识别/.test(finalState.voice.activity), `${finalState.voice.activityTone} ${finalState.voice.activity}`)
669
+
670
+ // 浏览器真发过来的那段音频:格式必须和 host 的契约一致(这是桩测不出来的那一环)
671
+ assert('服务端拿到了浏览器录的音频', !!capturedAudio && capturedAudio.length > 44, capturedAudio ? String(capturedAudio.length) + ' 字节' : '没有')
672
+ if (capturedAudio) {
673
+ let seconds = 0
674
+ let formatError = ''
675
+ try {
676
+ seconds = validateWave(capturedAudio, 60)
677
+ } catch (error) {
678
+ formatError = String(error.message || error)
679
+ }
680
+ assert('浏览器产出的 WAV 通过 host 的逐字段校验(16kHz/单声道/PCM16)', formatError === '', formatError || `${seconds.toFixed(2)} 秒`)
681
+ assert('录了 2.5 秒以上(不是点两下就结束的空录音)', seconds >= 2.5, `${seconds.toFixed(2)} 秒`)
682
+ }
683
+
684
+ if (realSpeech) {
685
+ assert(
686
+ '真 SenseVoice 转写的是那句真话(不是空串/乱码)',
687
+ /语音|测试|你好|划词|画词|输入/.test(finalState.value),
688
+ JSON.stringify(finalState.value),
689
+ )
690
+ console.log(` 识别结果:${JSON.stringify(finalState.value)}`)
691
+ } else {
692
+ assert('夹具模式下也走完了"插进输入框"这一步', finalState.value.length > 0, JSON.stringify(finalState.value))
693
+ console.log(' (没连真识别服务:只验了浏览器侧链路与音频格式)')
694
+ }
695
+
696
+ // 窄窗口兜底:composer 工具行多了麦克风之后,窄面板下不能把发送键挤出去
697
+ try {
698
+ await cdp.send('Emulation.setDeviceMetricsOverride', { width: 380, height: 760, deviceScaleFactor: 1, mobile: false })
699
+ await sleep(200)
700
+ const narrow = await cdp.eval(`(() => {
701
+ const tools = document.querySelector('.dsh-sel-asktools')
702
+ const send = document.querySelector('.dsh-sel-asksend')
703
+ const mic = document.querySelector('.dsh-sel-mic')
704
+ if (!tools || !send || !mic) return null
705
+ const row = tools.getBoundingClientRect()
706
+ const sendBox = send.getBoundingClientRect()
707
+ const micBox = mic.getBoundingClientRect()
708
+ return {
709
+ overflow: tools.scrollWidth - tools.clientWidth,
710
+ sendInside: sendBox.right <= row.right + 1 && sendBox.left >= row.left - 1,
711
+ bothInPanel: sendBox.bottom <= document.querySelector('.dsh-sel-panel').getBoundingClientRect().bottom + 1,
712
+ micVisible: micBox.width > 0 && micBox.height > 0,
713
+ }
714
+ })()`)
715
+ assert(
716
+ '380px 窄窗口下:工具行不横向溢出、发送键与麦克风都还在可视区内',
717
+ !!narrow && narrow.overflow <= 1 && narrow.sendInside && narrow.bothInPanel && narrow.micVisible,
718
+ JSON.stringify(narrow),
719
+ )
720
+ await cdp.send('Emulation.clearDeviceMetricsOverride')
721
+ await sleep(150)
722
+ } catch (error) {
723
+ console.log('(窄窗口检查跳过:' + String(error.message || error).slice(0, 120) + ')')
724
+ }
725
+
726
+ try {
727
+ const shotPath = await shotPanel(cdp, process.env.SMOKE_SHOT || join(tmpdir(), 'dsh-voice-smoke.png'))
728
+ if (shotPath) console.log(`=== 截图(已插入):${shotPath} ===`)
729
+ } catch (error) {
730
+ console.log('(截图失败:' + String(error.message || error).slice(0, 120) + ')')
731
+ }
732
+
733
+ // ── Esc 两级 + 开窗聚焦(真按键、真焦点)─────────────────────────────
734
+ async function pressEscape() {
735
+ const base = { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27 }
736
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base })
737
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base })
738
+ }
739
+
740
+ await cdp.eval(`(() => {
741
+ // 先造一个 <button> 并把焦点给它:真实路径上点浮标就是这个情形
742
+ //(Chrome 点按钮会把焦点交给按钮,早先的"焦点不在面板里就不抢"就是这么把自己挡住的)
743
+ const probe = document.createElement('button')
744
+ probe.id = 'smoke-focus-probe'
745
+ document.body.appendChild(probe)
746
+ probe.focus()
747
+ window.__dshSelectionExplain.close()
748
+ window.__dshSelectionExplain.open('voice-probe', '', 'Esc 冒烟')
749
+ return document.activeElement.id
750
+ })()`)
751
+ await cdp.waitFor(`window.__dshSelectionExplain.state().phase === 'done'`, 15000)
752
+ const focused = await cdp.waitFor(`document.activeElement && document.activeElement.className.indexOf('dsh-sel-askbox') >= 0`, 5000)
753
+ assert('焦点在按钮上时开窗,输入框照样拿到焦点(真实路径)', focused, await cdp.eval('String(document.activeElement && document.activeElement.className)'))
754
+
755
+ await clickSelector(cdp, '.dsh-sel-mic')
756
+ await cdp.waitFor(`window.__dshSelectionExplain.voice().phase === 'recording'`, 10000)
757
+ await pressEscape()
758
+ await sleep(400)
759
+ const afterEsc = await cdp.eval(`(() => { const v = window.__dshSelectionExplain.voice(); const box = document.querySelector('.dsh-sel-askbox'); return { phase: v.phase, capture: v.capture, value: box ? box.value : '', open: window.__dshSelectionExplain.quoteState().panelOpen, display: getComputedStyle(document.querySelector('.dsh-sel-panel')).display } })()`)
760
+ assert('录音中按 Esc:只取消录音,面板留着(不再顺手关窗)', afterEsc.phase === 'idle' && afterEsc.capture === false && afterEsc.open === true && afterEsc.display !== 'none', JSON.stringify(afterEsc))
761
+ assert('录音中按 Esc:已经说出来的字留着(取消只停,不删字)', afterEsc.value.trim().length > 0, JSON.stringify(afterEsc.value))
762
+
763
+ await pressEscape()
764
+ await sleep(300)
765
+ const afterEsc2 = await cdp.eval(`window.__dshSelectionExplain.quoteState().panelOpen`)
766
+ assert('不录音时再按 Esc:这才关窗(两级 Esc 的第二级)', afterEsc2 === false, String(afterEsc2))
767
+
768
+ cdp.close()
769
+ cleanup()
770
+
771
+ console.log(`\n=== 语音输入浏览器冒烟:${failed === 0 ? '全部通过' : failed + ' 项失败'} ===`)
772
+ process.exit(failed === 0 ? 0 : 1)