@mzzsfy/dsh-shell-select 0.4.0 → 0.4.3

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,14 @@
1
+ // 后台 job owner 必须传 agent id(场景:run_in_background 报 "[object Object] has no live agent"):源级守卫
2
+ import test from 'node:test'
3
+ import assert from 'node:assert/strict'
4
+ import { readFileSync } from 'node:fs'
5
+ import { join, dirname } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ const here = dirname(fileURLToPath(import.meta.url))
9
+ const source = readFileSync(join(here, '..', 'src', 'tool.mjs'), 'utf8')
10
+
11
+ test('后台 job owner 传 agent id 而非 agent 对象', () => {
12
+ assert.match(source, /owner: exec\.agent\.id/)
13
+ assert.doesNotMatch(source, /owner: exec\.agent \}/)
14
+ })
@@ -26,14 +26,12 @@ function cardModel(catalog = null) {
26
26
  lastSegment,
27
27
  parseExitTail: extractLogic('parseExitTail'),
28
28
  hasSpillNotice: extractLogic('hasSpillNotice'),
29
+ parseShellMark: extractLogic('parseShellMark'),
30
+ stripShellMark: extractLogic('stripShellMark'),
29
31
  clientDisplayName: extractLogic('clientDisplayName', { clientCatalog: catalog }),
30
32
  })
31
33
  }
32
34
 
33
- function parseEnvText() {
34
- return extractLogic('parseEnvText')
35
- }
36
-
37
35
  test('运行时长格式:秒→m:ss,一小时以上→h:mm:ss,负值钳 0', () => {
38
36
  const formatDuration = extractLogic('formatDuration')
39
37
  assert.equal(formatDuration(0), '0:00')
@@ -44,10 +42,6 @@ test('运行时长格式:秒→m:ss,一小时以上→h:mm:ss,负值钳 0', () =
44
42
  assert.equal(formatDuration(-5), '0:00')
45
43
  })
46
44
 
47
- function invalidEnvLines() {
48
- return extractLogic('invalidEnvLines')
49
- }
50
-
51
45
  // 运行中调用块(官方形态:无 kind 字段)
52
46
  function runningBlock(argsRaw) {
53
47
  return { callId: 'c1', name: 'shell', argsRaw }
@@ -155,6 +149,76 @@ test('S17d 后台状态中性:不断言运行中,点为中性图标(渲染守卫
155
149
  assert.doesNotMatch(source, /case 'background': return en \? 'Background' : '后台运行'/)
156
150
  })
157
151
 
152
+ test('S18 头部单行:cwd 与客户端徽章强制不换行(渲染守卫)', () => {
153
+ // BDD:Given 头部行宽受限,When cwd 或客户端名过长,Then 二者均不内部折行(cwd 压缩出省略号,徽章保持完整)
154
+ assert.match(source, /\.sls-tv__cwd \{[^}]*white-space:nowrap/)
155
+ assert.match(source, /\.sls-tv__cwd \{[^}]*overflow:hidden/)
156
+ assert.match(source, /\.sls-tv__cwd \{[^}]*text-overflow:ellipsis/)
157
+ assert.match(source, /\.sls-tv__badge \{[^}]*white-space:nowrap/)
158
+ assert.match(source, /\.sls-tv__pill \{[^}]*white-space:nowrap/)
159
+ assert.match(source, /\.sls-tv__duration \{[^}]*white-space:nowrap/)
160
+ assert.match(source, /\.sls-tv__pulse \{[^}]*white-space:nowrap/)
161
+ })
162
+
163
+ test('S19 行首无展开箭头:官方组件无展开箭头,可展开性由指针与整行点击承载(渲染守卫)', () => {
164
+ // BDD:Given 官方 ToolRow 无展开箭头,When 行渲染,Then 不存在 chevron 节点及其样式与状态钩子
165
+ assert.doesNotMatch(source, /sls-tv__chev/)
166
+ assert.doesNotMatch(source, /IconChevron/)
167
+ assert.doesNotMatch(source, /data-open/)
168
+ })
169
+
170
+ test('S20 徽章执行事实:结果标记优先于查看时配置读数', () => {
171
+ // BDD:Given 缺省客户端调用时 default 为 pwsh,查看时配置 default 已改 git-bash,When 派生卡片模型,Then 徽章显示标记承载的 pwsh,输出不含标记行
172
+ const model = cardModel({ default: 'git-bash', byId: { 'git-bash': 'git-bash' } })(
173
+ settledBlock(ARGS, 'out\n[shell: pwsh]\n[exit code: 0]'), SESSION_CWD)
174
+ assert.equal(model.kind, 'terminal')
175
+ assert.equal(model.shellName, 'pwsh')
176
+ assert.equal(model.output, 'out')
177
+ })
178
+
179
+ test('S20b 徽章执行事实:标记在场不依赖配置面,catalog 缺失仍显示', () => {
180
+ // BDD:Given 配置读取失败(catalog null),When 块带执行事实标记,Then 徽章仍按标记显示(执行事实不依赖查看时读数)
181
+ const model = cardModel(null)(
182
+ settledBlock(ARGS, 'out\n[shell: git-bash]\n[exit code: 2]'), SESSION_CWD)
183
+ assert.equal(model.shellName, 'git-bash')
184
+ assert.equal(model.exitCode, 2)
185
+ })
186
+
187
+ test('S20c 旧块无标记:回退显式参数 → 查看时 default(S15 链不回归)', () => {
188
+ // BDD:Given 历史块无标记时代产物,When 派生模型,Then 沿用显式参数/查看时 default 推导
189
+ const catalog = { default: 'git-bash', byId: { 'git-bash': 'git-bash', pwsh: 'pwsh' } }
190
+ const fallback = cardModel(catalog)(settledBlock(ARGS, 'out\n[exit code: 0]'), SESSION_CWD)
191
+ assert.equal(fallback.shellName, 'git-bash')
192
+ const explicit = cardModel(catalog)(
193
+ settledBlock(JSON.stringify({ command: 'ls', description: 'list', shell: 'pwsh' }), 'out\n[exit code: 0]'), SESSION_CWD)
194
+ assert.equal(explicit.shellName, 'pwsh')
195
+ })
196
+
197
+ test('S20d 后台 ack:标记入徽章,输出剥离标记行', () => {
198
+ // BDD:Given 后台调用 ack 携带执行事实标记,When 派生模型,Then shellName 按标记,输出仅剩 ack 原文
199
+ const model = cardModel({ default: 'git-bash', byId: {} })(settledBlock(
200
+ JSON.stringify({ command: 'node server.js', description: 'Boot server', run_in_background: true }),
201
+ 'started background job shell-11\n[shell: pwsh]'), SESSION_CWD)
202
+ assert.equal(model.kind, 'background')
203
+ assert.equal(model.jobId, 'shell-11')
204
+ assert.equal(model.shellName, 'pwsh')
205
+ assert.equal(model.output, 'started background job shell-11')
206
+ })
207
+
208
+ test('S20e generic 输出剥离标记行(isError 路径)', () => {
209
+ // BDD:Given isError 块文本含标记,When 回退 generic,Then 输出不含标记行
210
+ const model = cardModel(null)(settledBlock(ARGS, 'boom\n[shell: pwsh]', { isError: true }), SESSION_CWD)
211
+ assert.equal(model.kind, 'generic')
212
+ assert.equal(model.output, 'boom')
213
+ })
214
+
215
+ test('S20f 运行中块:无结果文本,徽章按显式参数/查看时 default 推导(现状)', () => {
216
+ // BDD:Given 调用进行中无结果,When 派生模型,Then 徽章沿用配置读数(执行事实落定后由标记校正)
217
+ const catalog = { default: 'git-bash', byId: { 'git-bash': 'git-bash' } }
218
+ const model = cardModel(catalog)(runningBlock(ARGS), SESSION_CWD)
219
+ assert.equal(model.shellName, 'git-bash')
220
+ })
221
+
158
222
  test('S12c isError 与空结果仍走 generic(回归)', () => {
159
223
  const errored = cardModel()(settledBlock(ARGS, 'boom', { isError: true }), SESSION_CWD)
160
224
  assert.equal(errored.kind, 'generic')
@@ -167,15 +231,87 @@ test('S13 注册面:shell/pwsh/bash 三 key 且 priority -1(文本守卫)', () =
167
231
  assert.match(source, /key: toolKey, priority: -1/)
168
232
  })
169
233
 
170
- test('K=V 往返:值含 = 与空格无损,空行忽略,重复键后行胜', () => {
171
- const parse = parseEnvText()
172
- assert.deepEqual(parse('A=1\nB=x=y z\n\nA=2'), { A: '2', B: 'x=y z' })
173
- assert.deepEqual(parse(''), {})
174
- assert.deepEqual(parse(undefined), {})
234
+ // ── S21 六态徽章矩阵:前台/后台 × shell(git-bash/pwsh) × pwsh 官方 ──
235
+ // 背景:官方 pwsh 工具调用被增强卡接管,但其输出无 [shell: ...] 标记、参数无
236
+ // shell 字段,徽章兜底落查看时默认客户端读数 → 官方 pwsh 调用误标 git-bash。
237
+
238
+ const BG_ARGS = JSON.stringify({ command: 'node server.js', description: 'Boot server', run_in_background: true })
239
+
240
+ test('S21a shell 前台 git-bash:执行事实标记入徽章', () => {
241
+ // BDD:Given shell 工具经 git-bash 客户端前台执行,When 派生卡片模型,Then 徽章显示标记承载的 git-bash
242
+ const model = cardModel(null)(settledBlock(ARGS, 'out\n[shell: git-bash]\n[exit code: 0]'), SESSION_CWD, 'shell')
243
+ assert.equal(model.kind, 'terminal')
244
+ assert.equal(model.shellName, 'git-bash')
245
+ })
246
+
247
+ test('S21b shell 前台 pwsh:执行事实标记入徽章', () => {
248
+ // BDD:Given shell 工具经 pwsh 客户端前台执行失败,When 派生卡片模型,Then 徽章 pwsh 且退出码保留
249
+ const model = cardModel(null)(settledBlock(ARGS, 'out\n[shell: pwsh]\n[exit code: 2]'), SESSION_CWD, 'shell')
250
+ assert.equal(model.shellName, 'pwsh')
251
+ assert.equal(model.exitCode, 2)
252
+ })
253
+
254
+ test('S21c shell 后台 git-bash:ack 标记入徽章,jobId 解析', () => {
255
+ // BDD:Given shell 工具后台启动于 git-bash,When 派生 ack 卡,Then 徽章 git-bash 且 jobId 取自 ack
256
+ const model = cardModel(null)(settledBlock(BG_ARGS, 'started background job shell-4\n[shell: git-bash]'), SESSION_CWD, 'shell')
257
+ assert.equal(model.kind, 'background')
258
+ assert.equal(model.jobId, 'shell-4')
259
+ assert.equal(model.shellName, 'git-bash')
260
+ })
261
+
262
+ test('S21d shell 后台 pwsh:ack 标记入徽章', () => {
263
+ // BDD:Given shell 工具后台启动于 pwsh,When 派生 ack 卡,Then 徽章 pwsh
264
+ const model = cardModel(null)(settledBlock(BG_ARGS, 'started background job shell-5\n[shell: pwsh]'), SESSION_CWD, 'shell')
265
+ assert.equal(model.kind, 'background')
266
+ assert.equal(model.jobId, 'shell-5')
267
+ assert.equal(model.shellName, 'pwsh')
268
+ })
269
+
270
+ test('S21e pwsh 官方前台:徽章钉死 pwsh,不落默认客户端读数', () => {
271
+ // BDD:Given 官方 pwsh 工具调用(无标记无 shell 参数)且查看时默认客户端为 git-bash,
272
+ // When 派生卡片模型,Then 徽章 pwsh(工具名即执行事实,读数兜底只属 shell 工具历史块)
273
+ const model = cardModel({ default: 'git-bash', byId: { 'git-bash': 'git-bash' } })(
274
+ settledBlock(ARGS, 'On branch main\n[exit code: 0]'), SESSION_CWD, 'pwsh')
275
+ assert.equal(model.kind, 'terminal')
276
+ assert.equal(model.shellName, 'pwsh')
277
+ })
278
+
279
+ test('S21f pwsh 官方后台:徽章钉死 pwsh,官方 pwsh 前缀 jobId 解析', () => {
280
+ // BDD:Given 官方 pwsh 工具后台启动(kind pwsh → 任务号 pwsh-N),When 派生 ack 卡,Then 徽章 pwsh 且 jobId=pwsh-3
281
+ const model = cardModel({ default: 'git-bash', byId: { 'git-bash': 'git-bash' } })(
282
+ settledBlock(BG_ARGS, 'started background job pwsh-3'), SESSION_CWD, 'pwsh')
283
+ assert.equal(model.kind, 'background')
284
+ assert.equal(model.jobId, 'pwsh-3')
285
+ assert.equal(model.shellName, 'pwsh')
286
+ })
287
+
288
+ test('S21g pwsh 官方运行中:徽章钉死 pwsh(落定前不误标)', () => {
289
+ // BDD:Given 官方 pwsh 调用进行中无结果文本,When 派生 running 卡,Then 徽章 pwsh 而非默认客户端读数
290
+ const model = cardModel({ default: 'git-bash', byId: { 'git-bash': 'git-bash' } })(runningBlock(ARGS), SESSION_CWD, 'pwsh')
291
+ assert.equal(model.status, 'running')
292
+ assert.equal(model.shellName, 'pwsh')
293
+ })
294
+
295
+ test('S21h bash 官方 key 同规钉死(同类预防)', () => {
296
+ // BDD:Given 官方 bash 工具调用且默认客户端读数为 git-bash,When 派生卡片模型,Then 徽章 bash
297
+ const model = cardModel({ default: 'git-bash', byId: { 'git-bash': 'git-bash' } })(
298
+ settledBlock(ARGS, 'out\n[exit code: 0]'), SESSION_CWD, 'bash')
299
+ assert.equal(model.shellName, 'bash')
300
+ })
301
+
302
+ test('S21i toolKey 贯通:注册循环注入 + 行组件传入模型(渲染守卫)', () => {
303
+ // BDD:Given 卡片按 key 注册,When 渲染,Then key 以 toolKey 注入组件并传入模型派生(漏任何一环徽章退回读数兜底)
304
+ assert.match(source, /React\.createElement\(ShellToolRow, \{ \.\.\.props, toolKey \}\)/)
305
+ assert.match(source, /shellCardModel\(block, cwd, toolKey\)/)
175
306
  })
176
307
 
177
- test('invalidEnvLines:缺 = 行报出,空行不报', () => {
178
- const invalid = invalidEnvLines()
179
- assert.deepEqual(invalid('A=1\nbroken\n\n noSep '), ['broken', 'noSep'])
180
- assert.deepEqual(invalid('A=1'), [])
308
+ test('S21j 钉死键集与注册键集同源:PINNED_CLIENT 恒等于 TOOLVIEW_KEYS 去 shell(渲染守卫)', () => {
309
+ // BDD:Given 官方钉死客户端事实在 client.js 双处字面编码(TOOLVIEW_KEYS 与 PINNED_CLIENT),
310
+ // When 派生两处键集,Then 钉死键集恒等于注册键集去掉 shell——注册面新增官方工具 key 而
311
+ // 漏配钉死映射时此处红,误标 bug 不静默复发
312
+ const toolviewKeys = /const TOOLVIEW_KEYS = \[([^\]]*)\]/.exec(source)[1]
313
+ .split(',').map((item) => item.trim().replace(/^'|'$/g, '')).filter((key) => key !== '' && key !== 'shell')
314
+ const pinnedKeys = /const PINNED_CLIENT = \{([^}]*)\}/.exec(source)[1]
315
+ .split(',').map((pair) => pair.trim().split(':')[0].trim().replace(/^'|'$/g, '')).filter((key) => key !== '')
316
+ assert.deepEqual(pinnedKeys.sort(), toolviewKeys.sort())
181
317
  })
@@ -23,11 +23,6 @@ test('标记值为完整 npm 包名', () => {
23
23
  assert.match(source, /setAttribute\('data-plugin', '@mzzsfy\/dsh-shell-select'\)/)
24
24
  })
25
25
 
26
- test('settings.section 注册与 id', () => {
27
- assert.match(source, /settings\.section/)
28
- assert.match(source, /id: 'shell-select'/)
29
- })
30
-
31
26
  test('tool.call.toolview 注册:shell 族三 key + priority -1', () => {
32
27
  assert.match(source, /'tool\.call\.toolview'/)
33
28
  assert.match(source, /const TOOLVIEW_KEYS = \['shell', 'pwsh', 'bash'\]/)
@@ -43,55 +38,4 @@ test('卡片数据链:官方同构派生(argsRaw + content 尾部退出标记)+
43
38
  test('primitives 缺席时图标降级自绘(require 有 try/catch 兜底)', () => {
44
39
  assert.match(source, /require\('@deepseek-ai\/dsh-client-ui-primitives'\)/)
45
40
  assert.match(source, /catch \{\s*return null\s*\}/)
46
- })
47
-
48
- test('扩展字段守卫:login/distro/env 进设置页数据链(wholesale replace 防静默重置)', () => {
49
- // toSection(保存)与 toEntries(加载)必须同时携带 login/distro;
50
- // env 走 envText(K=V 每行)编辑态往返:保存侧 parseEnvText,加载侧 envText
51
- for (const field of ['login', 'distro']) {
52
- const writes = (source.match(new RegExp(`^\\s*${field}: .*$`, 'gm')) ?? []).length
53
- assert.ok(writes >= 2, `toSection/toEntries 应各携带 ${field}(发现 ${writes} 处)`)
54
- }
55
- assert.match(source, /env: parseEnvText\(entry\.envText\)/)
56
- assert.match(source, /envText: Object\.entries\(entry\.env \?\? \{\}\)/)
57
- assert.match(source, /登录壳/)
58
- assert.match(source, /发行版/)
59
- })
60
-
61
- test('deny 黑名单进设置页数据链:逐条规则编辑器(deny 绝对,allow 已移除)', () => {
62
- // 数据链:state 数组 → 保存直落 deny;加载侧 patternText 按条解析(Trim 去空白)
63
- assert.match(source, /denyRules/)
64
- assert.match(source, /deny: denyRules/)
65
- assert.match(source, /patternText\(section\.deny\)/)
66
- // 行级即时校验 LOGIC 段 + 保存侧复用
67
- assert.match(source, /denyPatternIssues/)
68
- assert.match(source, /LOGIC-BEGIN denyPatternIssues/)
69
- // 交互结构:规则行 + 删除 + 添加 + 空态提示
70
- assert.match(source, /sls-rule/)
71
- assert.match(source, /添加规则/)
72
- assert.match(source, /未配置.*命中拦截不生效/)
73
- // 术语与僵尸防线
74
- assert.match(source, /命令黑名单/)
75
- assert.doesNotMatch(source, /denyText/)
76
- assert.doesNotMatch(source, /allowText|豁免名单|拒绝名单/)
77
- })
78
-
79
- // LOGIC 段行为测试:行级正则校验(提取形态照 client-card.test.mjs)
80
- function extractLogic(name) {
81
- const pattern = new RegExp('// LOGIC-BEGIN ' + name + '\\n([\\s\\S]*?)\\n\\s*// LOGIC-END ' + name)
82
- const match = source.match(pattern)
83
- if (!match) throw new Error('client.js 缺少 LOGIC 段: ' + name)
84
- return new Function('return (' + match[1].trim() + ')')()
85
- }
86
-
87
- test('denyPatternIssues:合法与空规则无问题', () => {
88
- const issues = extractLogic('denyPatternIssues')(['^format\\s', 'rm -rf\\s+(?!\\S*node_modules)', ''])
89
- assert.deepEqual(issues, [])
90
- })
91
-
92
- test('denyPatternIssues:非法规则定位到行(行号 = 规则序)', () => {
93
- const issues = extractLogic('denyPatternIssues')(['^format\\s', 'rm -rf\\s+(?!', 'shutdown'])
94
- assert.equal(issues.length, 1)
95
- assert.equal(issues[0].index, 1)
96
- assert.match(issues[0].error, /Invalid|regular|正则/)
97
41
  })
@@ -10,7 +10,7 @@ import { dirname, join } from 'node:path'
10
10
  const here = dirname(fileURLToPath(import.meta.url))
11
11
  const source = readFileSync(join(here, '..', 'src', 'client.js'), 'utf8')
12
12
 
13
- function reactStub() {
13
+ function reactStub(expanded = false) {
14
14
  // 函数组件即时执行(模拟渲染语义):组件体内的未定义引用/坏访问当场抛出
15
15
  const createElement = (type, props, ...children) => {
16
16
  if (typeof type === 'function') return type(props ?? {})
@@ -18,14 +18,14 @@ function reactStub() {
18
18
  }
19
19
  return {
20
20
  createElement,
21
- useState: (init) => [typeof init === 'function' ? init() : init, () => {}],
21
+ useState: (init) => [expanded || (typeof init === 'function' ? init() : init), () => {}],
22
22
  useEffect: () => {},
23
23
  }
24
24
  }
25
25
 
26
26
  // 加载 client.js 并执行 apply,返回 slots 注册面
27
- function loadClient() {
28
- const React = reactStub()
27
+ function loadClient(expanded = false) {
28
+ const React = reactStub(expanded)
29
29
  const registrations = []
30
30
  const slots = {
31
31
  inject: (_name, factory) => factory(),
@@ -70,6 +70,39 @@ function settledBlock(argsRaw, text) {
70
70
  }
71
71
  }
72
72
 
73
+ test('直接 pwsh 接管:折叠与展开都有独立徽章,复制输出剥离事实标记', () => {
74
+ // Given 已接管 pwsh 结果;When折叠/展开渲染;Then徽章可见且输出不泄露内部标记。
75
+ const args = JSON.stringify({ command: 'Get-Date', description: 'probe' })
76
+ for (const expanded of [false, true]) {
77
+ const component = toolviewOf(loadClient(expanded), 'pwsh')
78
+ const block = settledBlock(args, 'out\n[exit code: 2]')
79
+ block.content.push({ type: 'shell-select-takeover', tool: 'pwsh' })
80
+ const element = component({ block, cwd: 'C:\\repo' })
81
+ const nodes = []
82
+ const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) return node.forEach(walk); nodes.push(node); node.children?.forEach(walk) }
83
+ walk(element)
84
+ assert.ok(nodes.some(node => node.props?.className === 'sls-tv__takeover' && node.children.includes('插件接管')))
85
+ assert.doesNotMatch(JSON.stringify(element), /\[shell-select: pwsh takeover\]/)
86
+ if (expanded) assert.ok(nodes.some(node => node.props?.className === 'sls-tv__badge' && node.children.includes('pwsh')))
87
+ }
88
+ })
89
+
90
+ test('历史 stdout 恰含标记也不得冒充接管事实或丢失输出', () => {
91
+ // Given历史pwsh stdout含旧标记;When展开;Then没有接管徽章且原文保留。
92
+ const element = toolviewOf(loadClient(true), 'pwsh')({ block: settledBlock(JSON.stringify({ command: 'Write-Output marker', description: 'history' }), '[shell-select: pwsh takeover]'), cwd: 'C:\\repo' })
93
+ assert.doesNotMatch(JSON.stringify(element), /插件接管/)
94
+ assert.match(JSON.stringify(element), /\[shell-select: pwsh takeover\]/)
95
+ })
96
+
97
+ test('未标记历史 pwsh 与普通 shell 不显示接管徽章', () => {
98
+ // Given普通/历史结果;When渲染;Then不由工具名或当前配置臆测接管。
99
+ const args = JSON.stringify({ command: 'echo x', description: 'probe' })
100
+ for (const key of ['pwsh', 'shell']) {
101
+ const element = toolviewOf(loadClient(true), key)({ block: settledBlock(args, 'out'), cwd: 'C:\\repo' })
102
+ assert.doesNotMatch(JSON.stringify(element), /插件接管/)
103
+ }
104
+ })
105
+
73
106
  test('渲染守卫:settled terminal 调用组件执行不抛(引用完整)', () => {
74
107
  const component = toolviewOf(loadClient(), 'shell')
75
108
  const element = component({
@@ -108,18 +141,10 @@ test('渲染守卫:后台 ack 块(默认收起行 + 任务号徽标)组件执行
108
141
  assert.notEqual(element, undefined)
109
142
  })
110
143
 
111
- test('设置卡注册:settings.section 单卡 id shell-select', () => {
112
- const registrations = loadClient()
113
- const card = registrations.find((item) => item.meta.name === 'settings.section')
114
- assert.notEqual(card, undefined)
115
- assert.equal(card.meta.id, 'shell-select')
116
- })
117
-
118
144
  test('图标名守卫:0.1.7 primitives 字重后缀名在场,尺寸后缀旧名不在首选位', () => {
119
145
  // 0.1.7 图标改名:尺寸从名字移除(IconApiOutline14 → IconApiOutlineRegular/Medium),
120
146
  // 旧名 miss 使 IconApi 落淡灰 fallback 被当成"空白 icon"(实测事故)
121
147
  assert.match(source, /'IconApiOutlineRegular'/)
122
- assert.match(source, /'IconChevronDownOutlineRegular'/)
123
148
  assert.match(source, /'IconInspectOutlineRegular'/)
124
149
  assert.doesNotMatch(source, /createElementOf\('IconApiOutline14'/)
125
150
  })
@@ -157,7 +182,7 @@ test('hooks 恒序守卫:组件内 hook 调用必须全部位于第一个提前
157
182
  const m = /return h\(/.exec(scope)
158
183
  return m === null ? Number.POSITIVE_INFINITY : beginLine + scope.slice(0, m.index).split('\n').length - 1
159
184
  }
160
- for (const name of ['ShellToolRow', 'GenericShellRow', 'CopyButton', 'RunningDuration', 'ShellSelectApp']) {
185
+ for (const name of ['ShellToolRow', 'GenericShellRow', 'CopyButton', 'RunningDuration']) {
161
186
  const { hookLines } = extract(name)
162
187
  const scopeBody = source.slice(source.indexOf(`function ${name}(`))
163
188
  const end = scopeBody.indexOf('\n function ', 1)
@@ -1,4 +1,4 @@
1
- // 条目级 env 与 wsl distro:schema 默认、argv 形、entryFor 透传。BDD 场景见 docs/feat-shell-select-optim/plan.md S5-S10。
1
+ // 条目级 env 与 wsl distro:schema 默认、argv 形、entryFor 透传。BDD 场景见 docs/feat-shell-select-optim/plan.md S5-S10。
2
2
 
3
3
  import { test } from 'node:test'
4
4
  import assert from 'node:assert/strict'
@@ -69,6 +69,7 @@ function stubCtxFor(config) {
69
69
  const registered = { sections: [], tools: [], promptSections: [], routes: [] }
70
70
  return {
71
71
  reflect: { provide: () => {} },
72
+ on: () => () => {},
72
73
  logger: { warn: () => {} },
73
74
  effect: (fn) => { registered.routes.push(fn) },
74
75
  get() { return { register: () => {} } },
@@ -3,7 +3,8 @@
3
3
 
4
4
  import { test } from 'node:test'
5
5
  import assert from 'node:assert/strict'
6
- import { resolveConfig, defaultConfig, entryById, requireEntry, buildArgv, KINDS, RESOLVED_AUTO } from '../src/config.mjs'
6
+ import { resolveConfig, resolveConfigStrict, coerceServiceable, defaultConfig, entryById, requireEntry, buildArgv, KINDS, RESOLVED_AUTO } from '../src/config.mjs'
7
+ import { assertServiceableConfig } from '../src/config.mjs'
7
8
 
8
9
  test('schema 应用出厂默认:三客户端 + 默认 pwsh', () => {
9
10
  const applied = resolveConfig({})
@@ -38,6 +39,13 @@ test('落盘坏形态防御:volatile 字段被序列化成对象时降级默认,
38
39
  const valid = resolveConfig({ shells: [{ id: 'pwsh', kind: 'pwsh' }], default: 'pwsh', deny: ['rm -rf'], cwd: 'C:\\tmp', timeoutMs: 5000 })
39
40
  assert.equal(valid.shells.length, 1)
40
41
  assert.equal(valid.default, 'pwsh')
42
+
43
+ // 执行面 strict:同款坏形态必须炸(宿主 resolve 产物须先解箱;静默降级
44
+ // 曾让行 config 永不生效,0.2.0-rc.2 真机实证)。宽容路径(上方)保留。
45
+ assert.throws(() => resolveConfigStrict(poisoned), /形态非法.*shells/)
46
+ // 合法输入与 undefined 缺省:strict 与宽容同产出
47
+ assert.deepEqual(resolveConfigStrict(valid), valid)
48
+ assert.deepEqual(resolveConfigStrict(undefined), defaultConfig())
41
49
  assert.deepEqual(valid.deny, ['rm -rf'])
42
50
  assert.equal(valid.cwd, 'C:\\tmp')
43
51
  assert.equal(valid.timeoutMs, 5000)
@@ -47,8 +55,38 @@ test('落盘坏形态防御:volatile 字段被序列化成对象时降级默认,
47
55
  }
48
56
  })
49
57
 
50
- test('冻结输入防御:volatile ref 深冻结快照喂入不改写不抛(挂载路径)', () => {
51
- // 事故:cosmokit createVolatile 的 snapshot 对 volatile 值逐层 Object.freeze,
58
+ // --- coerceServiceable:任意用户输入产出可服务配置(插件不因配置死) ---
59
+
60
+ test('coerce:悬空 default 归 shells[0],空/全非法 shells 回落出厂默认', () => {
61
+ const { config, warnings } = coerceServiceable({ shells: [{ id: 'pwsh', kind: 'pwsh' }], default: 'nope' })
62
+ assert.equal(config.default, 'pwsh')
63
+ assert.ok(warnings.some((line) => line.includes('default')))
64
+ const emptied = coerceServiceable({ shells: [{ id: 'x', kind: 'fish' }], default: 'x' })
65
+ assert.deepEqual(emptied.config.shells.map((entry) => entry.id), ['pwsh', 'git-bash', 'cmd'])
66
+ assert.equal(emptied.config.default, 'pwsh')
67
+ assert.ok(emptied.warnings.length >= 2)
68
+ })
69
+
70
+ test('coerce:预算字段非正数降默认,deny 剔除不可编译条目,合法输入零告警原样', () => {
71
+ const { config, warnings } = coerceServiceable({
72
+ shells: [{ id: 'pwsh', kind: 'pwsh' }],
73
+ default: 'pwsh',
74
+ timeoutMs: -5,
75
+ graceMs: Number.MAX_SAFE_INTEGER,
76
+ deny: ['rm -rf', '(unclosed', '', 42],
77
+ })
78
+ assert.equal(config.timeoutMs, undefined)
79
+ assert.equal(config.graceMs, undefined)
80
+ assert.deepEqual(config.deny, ['rm -rf'])
81
+ assert.equal(warnings.length, 3)
82
+ const clean = coerceServiceable({ shells: [{ id: 'pwsh', kind: 'pwsh' }], default: 'pwsh' })
83
+ assert.equal(clean.warnings.length, 0)
84
+ // 产物必过可服务校验:任意输入不产出会炸挂载的配置
85
+ assertServiceableConfig(resolveConfig(clean.config))
86
+ assertServiceableConfig(resolveConfig(config))
87
+ })
88
+
89
+ test('冻结输入防御:volatile ref 深冻结快照喂入不改写不抛(挂载路径)', () => { // 事故:cosmokit createVolatile 的 snapshot 对 volatile 值逐层 Object.freeze,
52
90
  // 行 Config ref .get() 产物是深冻结对象;sanitizeConfigEntry 引用透传,
53
91
  // schemastery resolve 原地改写入参(填默认)撞冻结 env 抛
54
92
  // "Cannot assign to read only property 'MSYSTEM'" → 执行器挂载失败,
@@ -0,0 +1,53 @@
1
+ // FIND-020-3 回归:deny 命中返回对象必须通过工具输出 foreground oneOf 分支校验
2
+ // (additionalProperties:false)——历史缺陷:blocked/blockedBy 声明外字段致宿主判
3
+ // INVALID_TOOL_OUTPUT,拒绝文案在真机不可达。
4
+
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { readFileSync } from 'node:fs'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { dirname, join } from 'node:path'
10
+ import { DenyError } from '../src/denylist.mjs'
11
+
12
+ const here = dirname(fileURLToPath(import.meta.url))
13
+ const source = readFileSync(join(here, '..', 'src', 'tool.mjs'), 'utf8')
14
+
15
+ // 前台输出分支属性表与 additionalProperties 与 src/tool.mjs 同源抽取
16
+ function extractForegroundProperties(src) {
17
+ const start = src.indexOf('function foregroundOutputProperties()')
18
+ const body = src.slice(start, src.indexOf('\n}\n', start))
19
+ return body
20
+ }
21
+
22
+ function pickStringProps(propsSource) {
23
+ const names = [...propsSource.matchAll(/^\s{4}(\w+):/gm)].map((m) => m[1])
24
+ return names
25
+ }
26
+
27
+ test('deniedForeground 返回对象逐字段落 foreground 分支声明内', () => {
28
+ const propsSource = extractForegroundProperties(source)
29
+ const declared = pickStringProps(propsSource)
30
+ assert.ok(declared.includes('kind') && declared.includes('stderr'), '属性表抽取自净(schema 自身声明)')
31
+ // deniedForeground 构造器字段全部在声明表内(拒绝实现不再携带声明外字段)
32
+ const fnStart = source.indexOf('function deniedForeground(')
33
+ const fnBody = source.slice(fnStart, source.indexOf('\n}\n', fnStart))
34
+ const used = [...fnBody.matchAll(/^\s{4}(\w+):/gm)].map((m) => m[1])
35
+ const extra = used.filter((k) => !declared.includes(k))
36
+ assert.deepEqual(extra, [], `deniedForeground 不得携带声明外字段: ${extra.join(',')}`)
37
+ assert.doesNotMatch(fnBody, /blocked:/)
38
+ assert.doesNotMatch(fnBody, /blockedBy:/)
39
+ })
40
+
41
+ test('两执行分支 catch 均走 deniedForeground(不再内联声明外字段对象)', () => {
42
+ assert.equal((source.match(/deniedForeground\(error, entry\.id\)/g) ?? []).length, 2)
43
+ assert.doesNotMatch(source, /kind: 'foreground', shell: entry\.id, blocked: true/)
44
+ })
45
+
46
+ test('拒绝文案仍含契约锚(模型可见 [blocked by shell-select: matches deny pattern …])', () => {
47
+ const error = new DenyError('echo\\\\s', 'echo x')
48
+ const fnStart = source.indexOf('function deniedForeground(')
49
+ const fnBody = source.slice(fnStart, source.indexOf('\n}\n', fnStart))
50
+ assert.match(fnBody, /blockedMarker\(error\)/)
51
+ assert.match(source, /function blockedMarker\(error\)/)
52
+ assert.ok(error.pattern.length > 0)
53
+ })
@@ -1,4 +1,4 @@
1
- // executor 官方 seam 集成(桩 ctx):BDD 场景见本文件与 README「官方工具兼容」。
1
+ // executor 官方 seam 集成(桩 ctx):BDD 场景见本文件与 README「官方工具兼容」。
2
2
  // 背景:preset 注入的官方 tool-pwsh 经 cordis 服务代理(ctx.shell)调用执行器,
3
3
  // 代理把方法 this 重定向到阴影对象;执行器公开面必须与官方 PwshLocalExecutor
4
4
  // 同构地使用公有字段/方法(#私有在阴影 receiver 下触发品牌检查错误)。
@@ -38,6 +38,7 @@ function stubCtx({ sandboxMode = 'danger-full-access', spawn = stubSpawn() } = {
38
38
  }
39
39
  const ctx = {
40
40
  reflect: { provide: () => {} },
41
+ on: () => () => {},
41
42
  logger: { warn: () => {} },
42
43
  get(service) {
43
44
  if (service === 'webServer') return { register: (item) => registered.routes.push(item.path) }
@@ -4,7 +4,14 @@
4
4
  import { test } from 'node:test'
5
5
  import assert from 'node:assert/strict'
6
6
  import ShellSelectExecutor from '../src/executor.mjs'
7
- import { resolveConfig, assertServiceableConfig } from '../src/config.mjs'
7
+ import { resolveConfig, assertServiceableConfig, defaultConfig } from '../src/config.mjs'
8
+
9
+ test('执行器激活后挂模型工具投影', () => {
10
+ // Given 有模型服务;When构造执行器;Then组装事件由执行器生命周期持有。
11
+ const { ctx, registered } = stubCtx()
12
+ new ShellSelectExecutor(ctx, baseConfig())
13
+ assert.equal(registered.listeners?.filter((row) => row.name === 'system-prompt/assemble').length, 1)
14
+ })
8
15
 
9
16
  // --- 桩具 ---
10
17
 
@@ -59,6 +66,11 @@ function stubCtx({ withSettings = true, sandboxMode = 'danger-full-access', spaw
59
66
  }
60
67
  fn(child)
61
68
  },
69
+ on(name, handler) {
70
+ registered.listeners ??= []
71
+ registered.listeners.push({ name, handler })
72
+ return () => {}
73
+ },
62
74
  subprocess: spawn,
63
75
  sandbox: {
64
76
  confine: (argv, policy) => ({
@@ -125,7 +137,8 @@ test('构造:注册 shell+pwsh 双工具与 systemPrompt 段,路由不再由主
125
137
  const shell = registered.tools.find((tool) => tool.name === 'shell')
126
138
  const pwsh = registered.tools.find((tool) => tool.name === 'pwsh')
127
139
  assert.match(shell.description, /git-bash/)
128
- assert.match(pwsh.description, /PowerShell/)
140
+ // 兼容工具描述一句话:不要调用,使用 shell
141
+ assert.equal(pwsh.description, 'Do not call; use `shell`.')
129
142
  assert.ok(!('shell' in pwsh.parameters.properties))
130
143
  assert.ok('shell' in shell.parameters.properties)
131
144
  assert.equal(registered.promptSections.length, 1)
@@ -407,3 +420,84 @@ test('refresh:换源后执行面即时收紧(已删 id 拒绝,新清单生效)',
407
420
  assert.equal(executor.entryFor(undefined).id, 'pwsh')
408
421
  assert.equal(registered.tools.length, toolsBefore)
409
422
  })
423
+
424
+ // 真机宿主形态:行 config 经宿主按 static Config resolve,volatile 字段是
425
+ // boxed ref({get}),非普通对象。构造/refresh 直存该形态时 resolveConfig 的
426
+ // 类型对账会把 ref 全部当非法输入丢弃,执行面静默回落出厂默认(行 config
427
+ // 永不生效,真机 0.2.0-rc.2 实证)。回归锁定:boxed 形态必须解箱生效。
428
+
429
+ // cordis resolve 产物的同构 boxed 形态:每字段值包 {get}
430
+ function boxFields(config) {
431
+ return Object.fromEntries(Object.entries(config).map(([key, value]) => [key, { get: () => value }]))
432
+ }
433
+
434
+ test('构造:boxed volatile 字段(宿主 resolve 产物)解箱生效,不回落出厂默认', () => {
435
+ const { ctx, registered } = stubCtx()
436
+ const userConfig = {
437
+ shells: baseConfig().shells.filter((entry) => entry.id !== 'cmd'),
438
+ default: 'git-bash',
439
+ }
440
+ const executor = new ShellSelectExecutor(ctx, boxFields(userConfig))
441
+ const listing = executor.listShells()
442
+ assert.equal(listing.default, 'git-bash')
443
+ assert.deepEqual(listing.shells.map((entry) => entry.id), ['pwsh', 'git-bash'])
444
+ assert.equal(executor.entryFor(undefined).id, 'git-bash')
445
+ const shell = registered.tools.find((tool) => tool.name === 'shell')
446
+ assert.match(shell.description, /git-bash \(bash\)$|default client is "git-bash"/)
447
+ assert.doesNotMatch(shell.description, /cmd \(cmd\)/)
448
+ })
449
+
450
+ test('refresh:boxed volatile 字段换源同样解箱生效', () => {
451
+ const { ctx } = stubCtx()
452
+ const executor = new ShellSelectExecutor(ctx, baseConfig())
453
+ executor.refresh(boxFields({
454
+ shells: baseConfig().shells,
455
+ default: 'git-bash',
456
+ }))
457
+ assert.equal(executor.entryFor(undefined).id, 'git-bash')
458
+ const listing = executor.listShells()
459
+ assert.equal(listing.default, 'git-bash')
460
+ assert.equal(listing.shells.length, 3)
461
+ })
462
+
463
+ test('构造:混合形态(部分字段 boxed 部分普通)逐字段解箱生效', () => {
464
+ const { ctx } = stubCtx()
465
+ const executor = new ShellSelectExecutor(ctx, {
466
+ shells: boxFields({ shells: baseConfig().shells }).shells,
467
+ default: 'git-bash',
468
+ timeoutMs: 30000,
469
+ })
470
+ assert.equal(executor.entryFor(undefined).id, 'git-bash')
471
+ assert.equal(executor.config.timeoutMs, 30000)
472
+ })
473
+
474
+ test('入参缺省:null/undefined 落出厂默认,不抛', () => {
475
+ const { ctx } = stubCtx()
476
+ // 出厂默认 path=RESOLVED_AUTO('') → entryFor 走真实磁盘探测,linux CI 无
477
+ // pwsh.exe 必抛 no executable(出厂清单其余条目同);node 自身全平台存在,
478
+ // 以显式 path 覆写探测面,断言目标(id 落定与缺省容错)不受影响。
479
+ // refresh(null) 会把 #entry 归零回出厂(auto 路径),其后 entryFor 同样
480
+ // 需要探测面覆写:refresh 收 bypass 配置,null/undefined 容错语义(归零
481
+ // 不抛)由 refresh 调用本身不抛保证
482
+ const autoBypass = (entryConfig) => ({
483
+ ...entryConfig,
484
+ shells: entryConfig.shells.map((item) => ({ ...item, path: NODE_EXE })),
485
+ })
486
+ const fromNull = new ShellSelectExecutor(ctx, autoBypass(defaultConfig()))
487
+ assert.equal(fromNull.entryFor(undefined).id, 'pwsh')
488
+ fromNull.refresh(autoBypass(defaultConfig()))
489
+ assert.equal(fromNull.entryFor(undefined).id, 'pwsh')
490
+ const fromUndefined = new ShellSelectExecutor(ctx, autoBypass(defaultConfig()))
491
+ assert.equal(fromUndefined.entryFor(undefined).id, 'pwsh')
492
+ })
493
+
494
+ test('fail-loud:解箱后仍非法的形态炸在挂载点,不静默回落出厂默认', () => {
495
+ const { ctx } = stubCtx()
496
+ // ref 容器但解箱值非法(模拟宿主 resolve 形态契约变化):strict 必须抛
497
+ assert.throws(
498
+ () => new ShellSelectExecutor(ctx, { shells: { get: () => 'not-array' }, default: 'pwsh' }),
499
+ /形态非法.*shells/,
500
+ )
501
+ // 同形态宽容路径(resolveConfig)不抛——降级语义仅保留给持久化读回
502
+ assert.doesNotThrow(() => new ShellSelectExecutor(ctx, baseConfig()))
503
+ })