@yfwu2020/dsh-selection-explain 0.9.1 → 0.10.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,129 @@
1
+ /**
2
+ * 四格 → 模型真实档位:映射规则的确定性单测(不碰网络、不依赖宿主)。
3
+ *
4
+ * 为什么要有这一份:设置页那四个格子(关/低/高/最高)是**插件自己的词汇**,而每个模型
5
+ * 声明的档位词汇表各不相同 —— 本机 74 个模型上就数出七种(off>low>high>max、
6
+ * low>high>max、high>max、off>minimal>low>medium>high、off>low>medium>high>xhigh>max、
7
+ * low>medium>high>xhigh、minimal>low>medium>high>xhigh、只有 max、以及一档都不声明)。
8
+ * 映射是**运行时按模型声明现算**的(没有任何按模型写死的表),所以这份用例既锁住
9
+ * "见过的那几种",也用**随机生成、插件作者没见过的词汇表**验两条不变量:
10
+ * ① 落点永远是这个模型声明过的档位(否则 dsh-llm 直接抛 UNSUPPORTED_REASONING_EFFORT);
11
+ * ② 关 ≤ 低 ≤ 高 ≤ 最高(按声明的先后顺序,声明都是升序)。
12
+ */
13
+ import { __internals } from '../lib/index.js'
14
+
15
+ const { mapEffortToModel, effortMapOf } = __internals
16
+
17
+ let failed = 0
18
+ function assert(name, ok, detail) {
19
+ if (ok) {
20
+ console.log(' ✓ ' + name)
21
+ } else {
22
+ failed += 1
23
+ console.log(' ✗ ' + name + (detail ? ' — ' + detail : ''))
24
+ }
25
+ }
26
+
27
+ /** 把一个模型声明的 id 列表转成映射函数用的形状(name 就取 id)。 */
28
+ const decl = (ids) => ids.map((id) => ({ id, name: id }))
29
+ /** 四格一次算完,返回 ['关','低','高','最高'] 的落点。 */
30
+ const four = (ids) => {
31
+ const map = effortMapOf(decl(ids))
32
+ return [map.off, map.low, map.high, map.max]
33
+ }
34
+ const show = (ids) => {
35
+ const r = four(ids)
36
+ return `${ids.join('>')} → 关=${r[0]} 低=${r[1]} 高=${r[2]} 最高=${r[3]}`
37
+ }
38
+
39
+ console.log('[1] 本机真实词汇表(2026-10-10 从 /models 抓下来的形状)')
40
+ const REAL = [
41
+ // 最常见:四档,名字与四个格子一一对应 → 恒等映射
42
+ { ids: ['off', 'low', 'high', 'max'], want: ['off', 'low', 'high', 'max'] },
43
+ // 六档,中间多出 medium / xhigh:仍然落在名为 low / high 的那两档上
44
+ { ids: ['off', 'low', 'medium', 'high', 'xhigh', 'max'], want: ['off', 'low', 'high', 'max'] },
45
+ // 没有 off(deepseek-v4.1-flash 这种):关落到它最弱的那档
46
+ { ids: ['low', 'high', 'max'], want: ['low', 'high', 'high', 'max'] },
47
+ // 只有两档(deepseek-v4-pro / glm-5.2):最弱留给关,其余都只能落最强
48
+ { ids: ['high', 'max'], want: ['high', 'max', 'max', 'max'] },
49
+ // 五档且没有 max(glm-5.1 / mimo-*):最高落到它最强的那档
50
+ { ids: ['off', 'minimal', 'low', 'medium', 'high'], want: ['off', 'low', 'medium', 'high'] },
51
+ // 四档但顶档叫 xhigh(qwen3.8-max/flash)
52
+ { ids: ['off', 'low', 'medium', 'xhigh'], want: ['off', 'low', 'medium', 'xhigh'] },
53
+ // 没有 off 但有 xhigh(grok-4.6/4.7)
54
+ { ids: ['low', 'medium', 'high', 'xhigh'], want: ['low', 'medium', 'high', 'xhigh'] },
55
+ // 最少见的一档(kimi-k3 只有 max):四格都只能落它
56
+ { ids: ['max'], want: ['max', 'max', 'max', 'max'] },
57
+ // 完全不支持推理(typesafe/jev-latest):四个格子都空 → 调用方据此不发档位
58
+ { ids: [], want: ['', '', '', ''] },
59
+ // 两档(hy4-preview / kimi-k2.6):关=off,其余落它唯一的高档
60
+ { ids: ['off', 'high'], want: ['off', 'high', 'high', 'high'] },
61
+ ]
62
+ for (const item of REAL) {
63
+ const got = four(item.ids)
64
+ assert(show(item.ids), JSON.stringify(got) === JSON.stringify(item.want), '期望 ' + JSON.stringify(item.want))
65
+ }
66
+
67
+ console.log('\n[2] 没见过的词汇表也要落位(别的 adapter 可能叫 a/b/c、tier1…、或数字)')
68
+ const ALIEN = [
69
+ { ids: ['a', 'b', 'c', 'd'], want: ['a', 'b', 'c', 'd'] },
70
+ { ids: ['tier1', 'tier2', 'tier3', 'tier4', 'tier5'], want: ['tier1', 'tier2', 'tier4', 'tier5'] },
71
+ { ids: ['1', '2', '3'], want: ['1', '2', '2', '3'] },
72
+ { ids: ['x', 'y'], want: ['x', 'y', 'y', 'y'] },
73
+ // 半认半不认:'low' 认得出深度,其余按位置插值
74
+ { ids: ['stage0', 'low', 'stage2', 'stage3'], want: ['stage0', 'low', 'stage2', 'stage3'] },
75
+ ]
76
+ for (const item of ALIEN) {
77
+ const got = four(item.ids)
78
+ assert(show(item.ids), JSON.stringify(got) === JSON.stringify(item.want), '期望 ' + JSON.stringify(item.want))
79
+ }
80
+
81
+ console.log('\n[3] 两条不变量:落点必须已声明;关 ≤ 低 ≤ 高 ≤ 最高(按声明顺序)')
82
+ /** 造一批"升序"的词汇表:长度 1..7,名字在几套风格之间轮换(含插件作者没见过的)。 */
83
+ const NAMERS = [
84
+ (i) => ['off', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'][i] ?? `known${i}`,
85
+ (i) => ['none', 'minimal', 'light', 'mid', 'high', 'max', 'highest'][i] ?? `style${i}`,
86
+ (i) => `tier${i + 1}`,
87
+ (i) => String.fromCharCode(97 + i), // a, b, c…
88
+ (i) => `档位${i + 1}`,
89
+ (i) => `stage${i * 2}`,
90
+ ]
91
+ let cases = 0
92
+ let bad = 0
93
+ for (let n = 1; n <= 7; n += 1) {
94
+ for (let style = 0; style < NAMERS.length; style += 1) {
95
+ const ids = []
96
+ for (let i = 0; i < n; i += 1) ids.push(NAMERS[style](i))
97
+ const got = four(ids)
98
+ cases += 1
99
+ // ① 每个落点都必须是这个模型声明过的 id
100
+ for (const value of got) {
101
+ if (!ids.includes(value)) {
102
+ bad += 1
103
+ console.log(' ✗ 未声明的落点:' + show(ids) + ' 里出现了 ' + JSON.stringify(value))
104
+ break
105
+ }
106
+ }
107
+ // ② 单调:按下标比较(声明都是升序)
108
+ const order = got.map((value) => ids.indexOf(value))
109
+ const monotone = order.every((value, index) => index === 0 || order[index - 1] <= value)
110
+ if (!monotone) {
111
+ bad += 1
112
+ console.log(' ✗ 非单调:' + show(ids) + ' 下标=' + JSON.stringify(order))
113
+ }
114
+ // ③ 两端钉死:关 = 最弱、最高 = 最强(列表升序 = 下标顺序)
115
+ if (got[0] !== ids[0] || got[3] !== ids[n - 1]) {
116
+ bad += 1
117
+ console.log(' ✗ 两端没钉住:' + show(ids))
118
+ }
119
+ }
120
+ }
121
+ assert(`${cases} 组合成词汇表全部满足不变量`, bad === 0, `${bad} 组有问题`)
122
+
123
+ console.log('\n[4] 显式值、大小写与连字符都认(adapter 的显示名可能写成 "X-High" / "Very High")')
124
+ assert("显示名 'X-High' 认成最高档之下的那一档", mapEffortToModel([{ id: 'a', name: 'Low' }, { id: 'b', name: 'X-High' }], 'max') === 'b')
125
+ assert("id 大写 'OFF' 也算关", mapEffortToModel([{ id: 'OFF', name: 'Off' }, { id: 'HIGH', name: 'High' }], 'off') === 'OFF')
126
+ assert('没见过的规范值按"关"处理(不会越界)', mapEffortToModel(decl(['off', 'low', 'high']), 'something-else') === 'off')
127
+
128
+ console.log(failed === 0 ? '\n全部通过 ✓' : `\n${failed} 项失败 ✗`)
129
+ process.exit(failed === 0 ? 0 : 1)
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 档位重试策略的确定性单测(不碰网络、不依赖宿主)。
3
+ *
4
+ * 为什么必须有这一份:这套逻辑的输入是**上游错误的文案**,而它曾经把
5
+ * 「偶发上游 400」误判成「这个档位不被支持」,连锁出三个错:
6
+ * ① 给用户一句错诊断(「该档位不支持」);
7
+ * ② 把这次偶发失败**永久记进** leaky-off.json(以后再也不发这个档);
8
+ * ③ 客户端清掉用户选的档位。
9
+ * 2026-10-09 实测(活宿主 /analyze,交错发送排除时间漂移):
10
+ * opencode-go/step-5-preview-free:effort=low 成功 5/8、effort=high 成功 5/8
11
+ * —— **同一档位时好时坏,档位不是变量**;对照 longcat-2.5-preview-free 6/6 成功。
12
+ * 所以下面第一条断言就是那个回归闸门。
13
+ *
14
+ * 另一条被锁住的事实:老代码重试时把档位**整个去掉**(省略 reasoning),
15
+ * 实测 20 次重试 0 次成功 —— 对"必须开推理"的端点,省略等于把推理关掉。
16
+ * 新策略永远给一个显式档位(pickNextEffort 不会返回空,除非真的没档可换)。
17
+ */
18
+ import { __internals } from '../lib/index.js'
19
+
20
+ const { looksLikeUnsupportedTier, looksLikeReasoningRequired, pickNextEffort } = __internals
21
+
22
+ let failed = 0
23
+ function assert(name, ok, detail) {
24
+ if (ok) {
25
+ console.log(' ✓ ' + name)
26
+ } else {
27
+ failed += 1
28
+ console.log(' ✗ ' + name + (detail ? ' — ' + detail : ''))
29
+ }
30
+ }
31
+
32
+ // ── ① 那条真实的上游文案 ──
33
+ const STEP5_FLAKY =
34
+ '400: {"type":"server_error","message":"Upstream request failed: [400] Reasoning is mandatory for this endpoint and cannot be disabled."}'
35
+
36
+ console.log('[1] 偶发上游错误不能被当成"档位不支持"')
37
+ assert('step-5 那句原文 → 不是"档位无效"', looksLikeUnsupportedTier(STEP5_FLAKY) === false)
38
+ assert('step-5 那句原文 → 是"必须开推理"(只在发关档时才算数)', looksLikeReasoningRequired(STEP5_FLAKY) === true)
39
+ assert(
40
+ '同一句在发 high 时不该被当成档位问题(我们没关推理)',
41
+ // 调用方的判据:rejected !== 'off' 时用 looksLikeUnsupportedTier
42
+ looksLikeUnsupportedTier(STEP5_FLAKY) === false,
43
+ )
44
+
45
+ console.log('\n[2] 真正"档位不被支持"的措辞要认得出来')
46
+ for (const text of [
47
+ 'provider "opencode-go" model "x" does not support reasoning effort "max"',
48
+ 'invalid reasoning effort: max',
49
+ 'unsupported reasoning_effort value',
50
+ '该模型不支持 reasoning 档位',
51
+ 'thinking 参数无效',
52
+ 'Unrecognized effort "medium"',
53
+ ]) {
54
+ assert('认得:' + text.slice(0, 42), looksLikeUnsupportedTier(text) === true)
55
+ }
56
+
57
+ console.log('\n[3] 无关错误不要误判成档位问题')
58
+ for (const text of [
59
+ 'Upstream request failed: [500] internal error',
60
+ 'network timeout',
61
+ '401 unauthorized: invalid api key',
62
+ 'Rate limit exceeded',
63
+ '模型调用失败',
64
+ ]) {
65
+ assert('不误判:' + text.slice(0, 34), looksLikeUnsupportedTier(text) === false)
66
+ }
67
+
68
+ console.log('\n[4] 换档:永远给显式档位,且不重复发同一个档')
69
+ const DECLARED = ['off', 'low', 'high', 'max']
70
+ assert('off 被拒 → 低(最接近关的可接受档)', pickNextEffort(DECLARED, null, 'off', new Set(['off'])) === 'low')
71
+ assert('low 被拒 → 高', pickNextEffort(DECLARED, null, 'low', new Set(['low'])) === 'high')
72
+ assert('high 被拒 → 最大', pickNextEffort(DECLARED, null, 'high', new Set(['high'])) === 'max')
73
+ assert(
74
+ 'max 被拒 → 退到高(**不是**退回关:off 恰恰最容易被拒)',
75
+ pickNextEffort(DECLARED, null, 'max', new Set(['max'])) === 'high',
76
+ )
77
+ assert(
78
+ '模型声明的 defaultEffort 优先',
79
+ pickNextEffort(DECLARED, 'medium', 'high', new Set(['high'])) === 'medium',
80
+ )
81
+ assert(
82
+ 'defaultEffort 就是被拒的那个 → 继续沿阶梯找',
83
+ pickNextEffort(DECLARED, 'high', 'high', new Set(['high'])) === 'max',
84
+ )
85
+ assert(
86
+ '没档位可换时返回空串(调用方据此不重试,而不是省略档位)',
87
+ pickNextEffort(['high'], null, 'high', new Set(['high'])) === '',
88
+ )
89
+ assert(
90
+ '不会发模型没声明的档(只声明 off/low 时不去发 high)',
91
+ pickNextEffort(['off', 'low'], null, 'low', new Set(['low'])) === 'off',
92
+ )
93
+ assert(
94
+ '连着重试两次:两次都不重复(off → low → high)',
95
+ (() => {
96
+ const tried = new Set(['off'])
97
+ const a = pickNextEffort(DECLARED, null, 'off', tried)
98
+ tried.add(a)
99
+ const b = pickNextEffort(DECLARED, null, a, tried)
100
+ return a === 'low' && b === 'high'
101
+ })(),
102
+ )
103
+ assert(
104
+ '清单还没加载(declared 空)时也能换:走完整阶梯',
105
+ pickNextEffort([], null, 'low', new Set(['low'])) === 'high',
106
+ )
107
+
108
+ console.log(failed === 0 ? '\n全部通过 ✓' : `\n${failed} 项失败 ✗`)
109
+ process.exit(failed === 0 ? 0 : 1)
@@ -1,9 +1,12 @@
1
1
  /**
2
- * 流式文本手术刀的确定性单测:思考过滤器 / 工具调用残渣过滤器 / 泄漏思考判别。
2
+ * 流式文本手术刀的确定性单测:思考过滤器 / 泄漏思考判别 / 工具清单解析。
3
3
  *
4
4
  * 为什么单独一个文件:这三件事都只能靠"模型吐什么"来发现(思考内联、DSML 残渣都是实测撞出来的),
5
5
  * 而模型每次发挥不同 —— 靠真实请求验证会时灵时不灵。这里把 lib/index.js 里的纯函数直接拿来打表。
6
6
  *
7
+ * 注:原先这里还有一节"工具调用残渣过滤器" —— 那个过滤器已经**整体删除**(不再做文本层清洗,
8
+ * 改为照主会话的做法:工具每轮都带在请求里,模型走协议层表达调用,正文里不会有这种标记)。
9
+ *
7
10
  * 跑法:node scripts/test-filters.mjs(已挂进 npm test)
8
11
  */
9
12
  import { createRequire } from 'node:module'
@@ -13,7 +16,7 @@ import { dirname, resolve } from 'node:path'
13
16
  const HERE = dirname(fileURLToPath(import.meta.url))
14
17
  const require = createRequire(import.meta.url)
15
18
  const { __internals } = require(resolve(HERE, '..', 'lib', 'index.js'))
16
- const { createThinkFilter, createResidueFilter, splitLeakedReasoning, looksLikeLeakedReasoning, resolveToolNames } = __internals
19
+ const { createThinkFilter, splitLeakedReasoning, looksLikeLeakedReasoning, resolveToolNames } = __internals
17
20
 
18
21
  let pass = 0
19
22
  let fail = 0
@@ -68,92 +71,6 @@ const M = VB + VB + 'DSML' + VB + VB
68
71
  assert('think:没有标签时原样输出(不误伤)', a.text + b.text === '普通正文,没有思考标签' && a.think + b.think === '', '')
69
72
  }
70
73
 
71
- // ───────────────────────── 工具调用残渣过滤器 ─────────────────────────
72
- {
73
- // 实测形态①:整块包裹
74
- const f = createResidueFilter()
75
- const block = `<${M}tool_calls>\n<${M}invoke name="read">\n<${M}parameter name="path">/tmp/a.json</${M}parameter>\n</${M}invoke>\n</${M}tool_calls>`
76
- const out = f.push(`前面有正文。${block}后面还有正文。` ) + f.flush()
77
- assert('残渣:整块包裹的 DSML 被丢掉、前后正文保留', out === '前面有正文。后面还有正文。', JSON.stringify(out))
78
- }
79
- {
80
- // 实测形态②:marker 与标签名之间有空格 + 没有外层包裹(漏得最多的那种)
81
- const f = createResidueFilter()
82
- const raw = `结论在这里。\n<${M} calls>\n<${M} invoke name="glob">\n<${M} parameter name="include" string="true">*.json</${M} parameter>\n</${M} invoke>\n</${M} calls>\n就这样。`
83
- const out = f.push(raw) + f.flush()
84
- // 残渣块自带换行,整块删掉后会留一个空行 —— 语义上没问题(正文两段仍在),断言里把连续空行归一
85
- assert('残渣:带空格的 calls/invoke/parameter 三件套被丢掉', out.replace(/\n{2,}/g, '\n') === '结论在这里。\n就这样。', JSON.stringify(out))
86
- }
87
- {
88
- // 实测形态③:单个孤立标签 + 参数值(没有 invoke/calls 包裹)
89
- const f = createResidueFilter()
90
- const raw = `正文 A。<${M} parameter name="limit" string="false">40</${M} parameter>正文 B。`
91
- const out = f.push(raw) + f.flush()
92
- assert('残渣:孤立的 parameter 标签也被丢掉', out === '正文 A。正文 B。', JSON.stringify(out))
93
- }
94
- {
95
- // 未闭合(流被截断)→ 从标记起全部丢掉
96
- const f = createResidueFilter()
97
- const out = f.push(`结论在这里。\n<${M} invoke name="read">`) + f.flush()
98
- assert('残渣:未闭合的块整段丢掉(不留在正文里)', out === '结论在这里。\n', JSON.stringify(out))
99
- }
100
- {
101
- // 逐字喂(最坏分块):marker 被切碎也不能漏
102
- const f = createResidueFilter()
103
- const whole = `答案 A。<${M} calls><${M} invoke name="grep">x</${M} invoke></${M} calls>答案 B。`
104
- let out = ''
105
- for (const ch of whole) out += f.push(ch)
106
- out += f.flush()
107
- assert('残渣:逐字符喂也不漏(marker 被切碎)', out === '答案 A。答案 B。', JSON.stringify(out))
108
- }
109
- {
110
- // 标签被切在中间(40+ 字的标签,只留 32 字尾巴时会漏前半截)
111
- const f = createResidueFilter()
112
- const tag = `<${M} parameter name="include" string="true">`
113
- const at = 24 // 切在 '<MARK parameter name="incl' 中间
114
- const part1 = '正文 A。' + tag.slice(0, at)
115
- const part2 = tag.slice(at) + '值' + `</${M} parameter>` + '正文 B。'
116
- const out = f.push(part1) + f.push(part2) + f.flush()
117
- assert('残渣:标签被切在中间也不漏(尾部要扣住半个标签)', out === '正文 A。正文 B。', JSON.stringify(out))
118
- }
119
- {
120
- const f = createResidueFilter()
121
- const out = f.push('前<ds_safety_tool_call><tool_name>web_search</tool_name></ds_safety_tool_call>后') + f.flush()
122
- assert('残渣:官方 ds_safety_tool_call 同样被丢掉', out === '前后', JSON.stringify(out))
123
- }
124
- {
125
- const f = createResidueFilter()
126
- const text = '正常一段话:<parameter> 不是 DSML 标记;invoke 与 calls 只是英文词。'
127
- const out = f.push(text) + f.flush()
128
- assert('残渣:普通文本不受影响(没有 marker 的尖括号不误伤)', out === text, JSON.stringify(out))
129
- }
130
-
131
- {
132
- // 本轮吐了未闭合的残渣块 → 只在本轮内作废;flush 之后新一轮必须能正常输出
133
- // (共用一个过滤器时,这里会让"后续所有轮次的正文"被吞掉:实测表现为"只完成了工具查询,没有产出正文")
134
- const f = createResidueFilter()
135
- const first = f.push(`本轮旁白。<${M} invoke name="grep">`) + f.flush()
136
- const second = f.push('下一轮的正文,必须原样出来。') + f.flush()
137
- assert('残渣:未闭合块只在本轮内作废(flush 后重置深度)', first === '本轮旁白。' && second === '下一轮的正文,必须原样出来。', JSON.stringify({ first, second }))
138
- }
139
- {
140
- // 同一轮内,未闭合块之后的内容仍然要被丢掉(不能因为"怕误伤"就放行)
141
- const f = createResidueFilter()
142
- const out = f.push(`本轮旁白。<${M} invoke name="read">后面的参数碎片`) + f.flush()
143
- assert('残渣:同一轮内未闭合块之后的内容仍被丢掉', out === '本轮旁白。', JSON.stringify(out))
144
- }
145
-
146
- {
147
- // dropped():统计被丢掉的字数(用来判断"末尾残渣删掉后句子没写完")
148
- const f = createResidueFilter()
149
- const out = f.push(`结论写到一半就<${M} invoke name="read">`) + f.flush()
150
- assert('残渣:dropped() 统计被丢掉的字数', out === '结论写到一半就' && f.dropped() > 0, `dropped=${f.dropped()}`)
151
- const clean = createResidueFilter()
152
- clean.push('干净文本,没有残渣。')
153
- clean.flush()
154
- assert('残渣:干净文本的 dropped() 为 0(不误判成截断)', clean.dropped() === 0, `dropped=${clean.dropped()}`)
155
- }
156
-
157
74
  // ───────────────────────── 泄漏思考判别 ─────────────────────────
158
75
  {
159
76
  // 三次实测撞到的真实样本
@@ -0,0 +1,158 @@
1
+ /**
2
+ * 麦克风探针的生命周期:划词即启动 → 没人看就停 → 再划词再启动。
3
+ *
4
+ * 为什么单测它:探针是个常驻原生进程(每秒读 4 次 CoreAudio),原来一旦启动就留到插件卸载 ——
5
+ * 而插件装着的绝大多数时间没人划词,等于白跑。现在跟着页面的轮询走:
6
+ * 页面只在"有选区 / 卡片开着"时轮询,探针也就只在那时活着。
7
+ *
8
+ * 验三条:
9
+ * ① 有人来问才起(之前一直没人问 → 不起);
10
+ * ② 一直有人问 → **不会**停(划词期间不能抖);
11
+ * ③ 没人问满 MIC_IDLE_MS → 停,且状态回落 available=false(不假装还在听);
12
+ * ④ 停了之后再来问 → 能重新起(micStarted 必须复位,否则第二次永远起不来)。
13
+ *
14
+ * 用法:node scripts/test-mic-idle.mjs
15
+ */
16
+ import { createServer } from 'node:http'
17
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
18
+ import { tmpdir } from 'node:os'
19
+ import { join } from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ // ⚠️ 这个功能**只在 macOS 上有**:探针读的是 CoreAudio(宿主在非 darwin 上直接报
23
+ // available=false,见 src/index.ts 的平台门禁)。别的平台上这些用例必然全红 ——
24
+ // 与真浏览器冒烟同一套约定:**跑不了就 SKIP、退出码 0**,别把发布流水线卡在一台
25
+ // 没有这个能力的机器上(2026-10-10 实测:ubuntu CI 上 6/7 条红,v0.9.1 那次 CI 之后
26
+ // 才把本用例加进 npm test 链,所以这是第一次真跑到 Linux 上)。
27
+ if (process.platform !== 'darwin') {
28
+ console.log('SKIP 麦克风探针是 macOS 专属(宿主在非 darwin 上直接报 available=false)—— 本机跳过')
29
+ process.exit(0)
30
+ }
31
+
32
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-mic-idle-'))
33
+ const logPath = join(dir, 'probe.log')
34
+ // ⚠️ 环境变量必须在 **import 之前** 设好:宿主那几个常量是在模块求值时读的,
35
+ // 而 ESM 的 import 会被提升到测试体之前 —— 直接 import 的话,这里的赋值就晚了。
36
+ process.env.DSH_SEL_FAKE_PROBE_LOG = logPath
37
+ process.env.DSH_SEL_MIC_PROBE = fileURLToPath(new URL('./fixtures/fake-mic-probe.sh', import.meta.url))
38
+ process.env.DSH_SEL_MIC_IDLE_MS = '150'
39
+ process.env.DSH_SEL_MIC_CHECK_MS = '40'
40
+ const { apply } = await import('../lib/index.js')
41
+
42
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
43
+ const logText = async () => {
44
+ try {
45
+ return await readFile(logPath, 'utf8')
46
+ } catch {
47
+ return ''
48
+ }
49
+ }
50
+ const starts = async () => ((await logText()).match(/start/g) || []).length
51
+ const stops = async () => ((await logText()).match(/stop/g) || []).length
52
+
53
+ const routes = new Map()
54
+ const disposers = []
55
+ const ctx = {
56
+ llm: { listProviders: () => [] },
57
+ get: () => undefined,
58
+ on: () => () => {},
59
+ effect: (fn) => {
60
+ const dispose = fn()
61
+ disposers.push(dispose)
62
+ return dispose
63
+ },
64
+ logger: { info() {}, warn() {} },
65
+ webServer: {
66
+ register: (route) => {
67
+ routes.set(route.path, route.handler)
68
+ return () => routes.delete(route.path)
69
+ },
70
+ },
71
+ }
72
+
73
+ let passed = 0
74
+ let failed = 0
75
+ function check(label, ok, detail) {
76
+ if (ok) {
77
+ passed += 1
78
+ console.log(`PASS ${label}${detail ? ' — ' + detail : ''}`)
79
+ } else {
80
+ failed += 1
81
+ console.log(`FAIL ${label}${detail ? ' — ' + detail : ''}`)
82
+ }
83
+ }
84
+
85
+ try {
86
+ apply(ctx, {})
87
+ const server = createServer((req, res) => {
88
+ const handler = routes.get(new URL(req.url, 'http://localhost').pathname)
89
+ if (!handler) {
90
+ res.writeHead(404)
91
+ res.end()
92
+ return
93
+ }
94
+ Promise.resolve(handler(req, res)).catch(() => {
95
+ if (!res.headersSent) res.writeHead(500)
96
+ res.end()
97
+ })
98
+ })
99
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
100
+ const origin = 'http://127.0.0.1:' + server.address().port
101
+ const ask = async () => (await fetch(`${origin}/selection-explain/api/mic`)).json()
102
+
103
+ // ① 没人问的时候,探针不该存在
104
+ await sleep(120)
105
+ check('没人来问 → 探针不启动', (await starts()) === 0, 'start 次数=' + (await starts()))
106
+
107
+ // ② 有人问 → 起
108
+ const first = await ask()
109
+ await sleep(120)
110
+ check(
111
+ '第一次来问 → 探针启动,且状态是 available',
112
+ first.ok === true && first.available === true && (await starts()) === 1,
113
+ `available=${first.available} start=${await starts()}`,
114
+ )
115
+
116
+ check(
117
+ '探针以约定的采样间隔启动(--watch 125:发现"开麦"的延迟减半)',
118
+ (await logText()).includes('--watch 125'),
119
+ JSON.stringify((await logText()).split('\n')[0]),
120
+ )
121
+
122
+ // ③ 一直有人问 → 不停(划词期间不能抖)
123
+ for (let i = 0; i < 5; i += 1) {
124
+ await ask()
125
+ await sleep(40)
126
+ }
127
+ check('持续有人问 → 探针不重启也不停', (await starts()) === 1 && (await stops()) === 0, `start=${await starts()} stop=${await stops()}`)
128
+
129
+ // ④ 没人问满 IDLE → 停
130
+ await sleep(600)
131
+ check('没人问满 MIC_IDLE_MS → 探针停掉', (await stops()) === 1, 'stop 次数=' + (await stops()))
132
+
133
+ // ⑤ 再来问 → 能重新起(micStarted 必须被复位,否则第二次永远起不来)
134
+ const again = await ask()
135
+ await sleep(160)
136
+ check(
137
+ '再划词 → 探针重新启动',
138
+ again.available === true && (await starts()) === 2,
139
+ `available=${again.available} start 次数=${await starts()}`,
140
+ )
141
+
142
+ server.close()
143
+ } finally {
144
+ for (const dispose of disposers) {
145
+ try {
146
+ dispose()
147
+ } catch {
148
+ /* 忽略 */
149
+ }
150
+ }
151
+ await sleep(400)
152
+ const after = await stops()
153
+ check('插件卸载 → 探针被停掉', after >= 1, 'stop 次数=' + after)
154
+ await rm(dir, { recursive: true, force: true })
155
+ }
156
+
157
+ console.log(`\n麦克风探针生命周期:${passed} 通过 / ${failed} 失败`)
158
+ process.exit(failed ? 1 : 0)