@mzzsfy/dsh-shell-select 0.4.1 → 0.4.4
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.
- package/README.md +13 -20
- package/cordis.patch.yml +3 -189
- package/package.json +10 -4
- package/src/api.mjs +7 -1
- package/src/client.js +439 -389
- package/src/executor.mjs +31 -7
- package/src/prompt-projection.mjs +34 -0
- package/src/pwsh-takeover.mjs +23 -0
- package/src/resolve.mjs +35 -23
- package/src/tool.mjs +58 -25
- package/src/web-routes.mjs +3 -0
- package/test/background-owner.test.mjs +14 -0
- package/test/client-card.test.mjs +0 -21
- package/test/client-id.test.mjs +0 -56
- package/test/client-render.test.mjs +38 -12
- package/test/client-settings.test.mjs +251 -0
- package/test/config-entry.test.mjs +2 -1
- package/test/deny-output-schema.test.mjs +53 -0
- package/test/executor-seam.test.mjs +2 -1
- package/test/executor.test.mjs +23 -3
- package/test/official-contract.test.mjs +208 -0
- package/test/path-guidance.test.mjs +77 -0
- package/test/path-rescue.test.mjs +173 -0
- package/test/preset-drift.test.mjs +30 -139
- package/test/prompt-projection.test.mjs +120 -0
- package/test/pwsh-takeover.test.mjs +41 -0
- package/test/resolve.test.mjs +43 -13
- package/test/web-routes.test.mjs +15 -2
- package/test/client-drift.test.mjs +0 -27
- package/test/switch-guard.test.mjs +0 -37
|
@@ -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,13 +141,6 @@ 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"(实测事故)
|
|
@@ -156,7 +182,7 @@ test('hooks 恒序守卫:组件内 hook 调用必须全部位于第一个提前
|
|
|
156
182
|
const m = /return h\(/.exec(scope)
|
|
157
183
|
return m === null ? Number.POSITIVE_INFINITY : beginLine + scope.slice(0, m.index).split('\n').length - 1
|
|
158
184
|
}
|
|
159
|
-
for (const name of ['ShellToolRow', 'GenericShellRow', 'CopyButton', 'RunningDuration'
|
|
185
|
+
for (const name of ['ShellToolRow', 'GenericShellRow', 'CopyButton', 'RunningDuration']) {
|
|
160
186
|
const { hookLines } = extract(name)
|
|
161
187
|
const scopeBody = source.slice(source.indexOf(`function ${name}(`))
|
|
162
188
|
const end = scopeBody.indexOf('\n function ', 1)
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
|
|
6
|
+
const section = () => ({
|
|
7
|
+
default: 'git-bash', deny: ['^format\\s', ' foo ', ' ', 'echo\nsecret'], timeoutMs: 120000,
|
|
8
|
+
shells: [
|
|
9
|
+
{ id: 'git-bash', kind: 'bash', path: '', args: ['-c', 'echo two words', '{command}', '', 'a"b', "a'b", 'C:\\path', 'line\nbreak', 'tab\tcr\r', "$'literal'", 'C:\\tail\\'], env: { NOTE: 'a=b c', MULTI: 'line1\nOTHER=two', PATH: 'C:\\temp\\file', LITERAL: '\\n', QUOTED: '"literal"', CR: 'first\rsecond', TRAIL: 'C:\\temp\\' }, login: true, distro: '' },
|
|
10
|
+
{ id: 'pwsh', kind: 'pwsh', path: '', args: [], env: {}, login: false, distro: '' },
|
|
11
|
+
],
|
|
12
|
+
resolved: { default: 'git-bash', shells: [{ id: 'git-bash', available: true, path: 'C:/Git/bash.exe' }, { id: 'pwsh', available: true }] },
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
async function page({ failLoad = false, failSave = false, failReadBack = false } = {}) {
|
|
16
|
+
const state = [], effects = [], registrations = [], requests = []
|
|
17
|
+
let cursor = 0, effectCursor = 0, loaded
|
|
18
|
+
const React = {
|
|
19
|
+
createElement: (type, props, ...children) => typeof type === 'function' ? type(props ?? {}) : { type, props: props ?? {}, children },
|
|
20
|
+
useState(initial) {
|
|
21
|
+
const at = cursor++
|
|
22
|
+
if (!(at in state)) state[at] = typeof initial === 'function' ? initial() : initial
|
|
23
|
+
return [state[at], (next) => { state[at] = typeof next === 'function' ? next(state[at]) : next }]
|
|
24
|
+
},
|
|
25
|
+
useEffect(fn, deps) {
|
|
26
|
+
const at = effectCursor++
|
|
27
|
+
const prior = effects[at]
|
|
28
|
+
if (!prior || deps.some((value, index) => value !== prior.deps[index])) {
|
|
29
|
+
prior?.cleanup?.()
|
|
30
|
+
effects[at] = { fn, deps, pending: true }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
const window = { __ModuleLoader__: { load: (value) => { loaded = value } } }
|
|
35
|
+
const document = { documentElement: { lang: 'zh' }, createElement: () => ({ setAttribute() {}, remove() {} }), head: { appendChild() {} } }
|
|
36
|
+
let current = section()
|
|
37
|
+
const fetch = async (path, options = {}) => {
|
|
38
|
+
requests.push({ path, ...options })
|
|
39
|
+
if (path.endsWith('/probe')) return { ok: true, json: async () => ({ exists: true }) }
|
|
40
|
+
if (path.endsWith('/detect')) return { ok: true, json: async () => ({ found: [{ path: 'C:/Git/bash.exe' }] }) }
|
|
41
|
+
if (failReadBack && options.method !== 'POST' && requests.some((row) => row.method === 'POST')) return { ok: false, status: 503, json: async () => ({ error: '回读故障' }) }
|
|
42
|
+
if (failLoad && options.method !== 'POST') return { ok: false, status: 503, json: async () => ({ error: '加载故障' }) }
|
|
43
|
+
if (options.method === 'POST') {
|
|
44
|
+
if (failSave) return { ok: false, status: 400, json: async () => ({ error: '写入故障' }) }
|
|
45
|
+
current = { ...JSON.parse(options.body), resolved: current.resolved }
|
|
46
|
+
return { ok: true, json: async () => ({ ok: true, resolved: current.resolved }) }
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, json: async () => structuredClone(current) }
|
|
49
|
+
}
|
|
50
|
+
new Function('window', 'document', 'navigator', 'fetch', source)(window, document, {}, fetch)
|
|
51
|
+
loaded.factory((name) => { if (name === 'react') return React; throw new Error(name) }).apply({
|
|
52
|
+
effect: (fn) => fn(), slots: { inject: (_name, fn) => fn(), register: (meta, component) => { const entry = { meta, component }; registrations.push(entry); return () => registrations.splice(registrations.indexOf(entry), 1) } },
|
|
53
|
+
})
|
|
54
|
+
const view = registrations.find(({ meta }) => meta.name === 'settings.section' && meta.id === 'shell-select')
|
|
55
|
+
assert.ok(view, '设置导航缺少 shell-select 页面')
|
|
56
|
+
assert.equal(view.meta.label, 'Shell 管理')
|
|
57
|
+
const render = () => { cursor = 0; effectCursor = 0; return view.component() }
|
|
58
|
+
const flush = async () => {
|
|
59
|
+
for (const effect of effects) if (effect.pending) { effect.pending = false; effect.cleanup = effect.fn() }
|
|
60
|
+
await new Promise(setImmediate)
|
|
61
|
+
}
|
|
62
|
+
const initial = render()
|
|
63
|
+
await flush()
|
|
64
|
+
const nodes = (tree) => {
|
|
65
|
+
const found = []
|
|
66
|
+
const walk = (node) => { if (Array.isArray(node)) return node.forEach(walk); if (!node || typeof node !== 'object') return; found.push(node); node.children?.forEach(walk) }
|
|
67
|
+
walk(tree)
|
|
68
|
+
return found
|
|
69
|
+
}
|
|
70
|
+
const button = (tree, label) => nodes(tree).find((node) => node.type === 'button' && node.children.includes(label))
|
|
71
|
+
const field = (tree, label) => nodes(tree).find((node) => node.props['aria-label'] === label)
|
|
72
|
+
const dirty = () => {
|
|
73
|
+
const input = field(render(), '客户端 1 id')
|
|
74
|
+
input.props.onChange({ target: { value: input.props.value } })
|
|
75
|
+
}
|
|
76
|
+
return { initial, render, flush, nodes, button, field, dirty, requests, restoreLoad: () => { failLoad = false } }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Given 插件启用;When 打开设置;Then 页面注册并加载当前配置而非出厂值。
|
|
80
|
+
test('设置入口:注册 Shell 管理并展示当前客户端、默认值与加载状态', async () => {
|
|
81
|
+
const p = await page()
|
|
82
|
+
assert.match(JSON.stringify(p.initial), /加载中/)
|
|
83
|
+
const tree = p.render()
|
|
84
|
+
assert.equal(p.field(tree, '客户端 1 默认').props.checked, true)
|
|
85
|
+
assert.equal(p.field(tree, '客户端 1 默认').props.type, 'radio')
|
|
86
|
+
assert.equal(p.field(tree, '客户端 1 id').props.value, 'git-bash')
|
|
87
|
+
assert.equal(p.button(tree, '保存').props.disabled, true)
|
|
88
|
+
assert.equal(p.field(tree, '客户端 1 参数').type, 'input')
|
|
89
|
+
assert.match(p.field(tree, '客户端 1 环境').props.value, /^NOTE=a=b c\nMULTI=/)
|
|
90
|
+
assert.equal(p.field(tree, '黑名单第 2 条').props.value, ' foo ')
|
|
91
|
+
assert.equal(p.field(tree, '默认客户端'), undefined)
|
|
92
|
+
p.field(tree, '客户端 2 默认').props.onChange()
|
|
93
|
+
assert.equal(p.field(p.render(), '客户端 2 默认').props.checked, true)
|
|
94
|
+
assert.equal(p.button(p.render(), '保存').props.disabled, false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Given 已有含空格参数和环境值;When 保存;Then 完整保留客户端字段、deny 与顶层配置。
|
|
98
|
+
test('保存:参数、环境、黑名单及预算字段无损往返', async () => {
|
|
99
|
+
const p = await page()
|
|
100
|
+
p.dirty()
|
|
101
|
+
await p.button(p.render(), '保存').props.onClick()
|
|
102
|
+
assert.equal(p.button(p.render(), '保存').props.disabled, true)
|
|
103
|
+
const written = JSON.parse(p.requests.find((row) => row.method === 'POST').body)
|
|
104
|
+
assert.deepEqual(written.shells, section().shells)
|
|
105
|
+
assert.deepEqual(written.deny, section().deny)
|
|
106
|
+
assert.equal(written.timeoutMs, 120000)
|
|
107
|
+
assert.equal(written.default, 'git-bash')
|
|
108
|
+
assert.ok(!('resolved' in written))
|
|
109
|
+
assert.match(JSON.stringify(p.render()), /已保存/)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// Given 多个客户端;When 改默认 id 或删除默认项;Then 默认值始终指向现有客户端。
|
|
113
|
+
test('编辑:重命名及删除默认客户端同步更新默认值', async () => {
|
|
114
|
+
const p = await page()
|
|
115
|
+
p.field(p.render(), '客户端 1 id').props.onChange({ target: { value: 'bash-local' } })
|
|
116
|
+
assert.equal(p.field(p.render(), '客户端 1 默认').props.checked, true)
|
|
117
|
+
p.nodes(p.render()).find((node) => node.props['aria-label'] === '移除客户端 1').props.onClick()
|
|
118
|
+
assert.equal(p.field(p.render(), '客户端 1 默认').props.checked, true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('编辑:多个同类客户端可新增并保存独立 id', async () => {
|
|
122
|
+
const p = await page()
|
|
123
|
+
p.button(p.render(), '添加客户端').props.onClick()
|
|
124
|
+
p.field(p.render(), '客户端 3 id').props.onChange({ target: { value: 'bash-extra' } })
|
|
125
|
+
await p.button(p.render(), '保存').props.onClick()
|
|
126
|
+
const payload = JSON.parse(p.requests.find((row) => row.method === 'POST').body)
|
|
127
|
+
assert.deepEqual(payload.shells.map((entry) => entry.id), ['git-bash', 'pwsh', 'bash-extra'])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Given 非法配置;When 保存;Then 错误可见且不发写请求。
|
|
131
|
+
for (const [label, value, error] of [
|
|
132
|
+
['客户端 2 id', 'git-bash', '重复'],
|
|
133
|
+
['客户端 1 参数', '"unterminated', '引号'],
|
|
134
|
+
['客户端 1 环境', 'invalid', '环境'],
|
|
135
|
+
['黑名单第 1 条', '[', '正则'],
|
|
136
|
+
]) test('非法输入阻止保存: ' + label, async () => {
|
|
137
|
+
const p = await page()
|
|
138
|
+
p.field(p.render(), label).props.onChange({ target: { value } })
|
|
139
|
+
await p.button(p.render(), '保存').props.onClick()
|
|
140
|
+
assert.equal(p.requests.filter((row) => row.method === 'POST').length, 0)
|
|
141
|
+
assert.match(JSON.stringify(p.render()), new RegExp(error))
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('加载失败:显示错误及重新加载操作', async () => {
|
|
145
|
+
const p = await page({ failLoad: true })
|
|
146
|
+
assert.match(JSON.stringify(p.render()), /加载故障/)
|
|
147
|
+
assert.ok(p.button(p.render(), '重新加载'))
|
|
148
|
+
p.restoreLoad()
|
|
149
|
+
p.button(p.render(), '重新加载').props.onClick()
|
|
150
|
+
p.render()
|
|
151
|
+
await p.flush()
|
|
152
|
+
assert.equal(p.field(p.render(), '客户端 1 默认').props.checked, true)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('原规则交互:添加空行不生成空正则,逐行删除与编辑可保存', async () => {
|
|
156
|
+
const p = await page()
|
|
157
|
+
p.button(p.render(), '添加规则').props.onClick()
|
|
158
|
+
assert.equal(p.field(p.render(), '黑名单第 5 条').props.value, '')
|
|
159
|
+
p.field(p.render(), '黑名单第 1 条').props.onChange({ target: { value: '^danger' } })
|
|
160
|
+
p.field(p.render(), '删除规则 2').props.onClick()
|
|
161
|
+
await p.button(p.render(), '保存').props.onClick()
|
|
162
|
+
const payload = JSON.parse(p.requests.find((row) => row.method === 'POST').body)
|
|
163
|
+
assert.deepEqual(payload.deny, ['^danger', ' ', 'echo\nsecret'])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('原参数环境文本:引号内空格与 K=V 值可编辑,不暴露 JSON', async () => {
|
|
167
|
+
const p = await page()
|
|
168
|
+
p.field(p.render(), '客户端 1 参数').props.onChange({ target: { value: '-c "echo new words" {command}' } })
|
|
169
|
+
p.field(p.render(), '客户端 1 环境').props.onChange({ target: { value: 'NOTE=a=b c\nOTHER= two words ' } })
|
|
170
|
+
await p.button(p.render(), '保存').props.onClick()
|
|
171
|
+
const shell = JSON.parse(p.requests.find((row) => row.method === 'POST').body).shells[0]
|
|
172
|
+
assert.deepEqual(shell.args, ['-c', 'echo new words', '{command}'])
|
|
173
|
+
assert.deepEqual(shell.env, { NOTE: 'a=b c', OTHER: ' two words ' })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('编辑再还原:参数与环境原始数组和换行值仍无损', async () => {
|
|
177
|
+
const p = await page()
|
|
178
|
+
for (const label of ['客户端 1 参数', '客户端 1 环境']) {
|
|
179
|
+
const original = p.field(p.render(), label).props.value
|
|
180
|
+
p.field(p.render(), label).props.onChange({ target: { value: 'temporary' } })
|
|
181
|
+
p.field(p.render(), label).props.onChange({ target: { value: original } })
|
|
182
|
+
}
|
|
183
|
+
await p.button(p.render(), '保存').props.onClick()
|
|
184
|
+
const shell = JSON.parse(p.requests.find((row) => row.method === 'POST').body).shells[0]
|
|
185
|
+
assert.deepEqual(shell.args, section().shells[0].args)
|
|
186
|
+
assert.deepEqual(shell.env, section().shells[0].env)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('修改单个环境变量:其他含换行与反斜线的值保持不变', async () => {
|
|
190
|
+
const p = await page()
|
|
191
|
+
const original = p.field(p.render(), '客户端 1 环境').props.value
|
|
192
|
+
p.field(p.render(), '客户端 1 环境').props.onChange({ target: { value: original.replace('NOTE=a=b c', 'NOTE=changed value') } })
|
|
193
|
+
await p.button(p.render(), '保存').props.onClick()
|
|
194
|
+
const posted = p.requests.find((row) => row.method === 'POST')
|
|
195
|
+
assert.ok(posted, '编辑普通变量不应使已有多行值阻止保存')
|
|
196
|
+
assert.deepEqual(JSON.parse(posted.body).shells[0].env, { ...section().shells[0].env, NOTE: 'changed value' })
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('双引号 Windows 参数:路径反斜线不能被当成 JSON 控制字符', async () => {
|
|
200
|
+
const p = await page()
|
|
201
|
+
const path = 'C:\\Program Files\\Git\\bin\\bash.exe'
|
|
202
|
+
p.field(p.render(), '客户端 1 参数').props.onChange({ target: { value: '-c "' + path + '" {command}' } })
|
|
203
|
+
await p.button(p.render(), '保存').props.onClick()
|
|
204
|
+
const posted = p.requests.find((row) => row.method === 'POST')
|
|
205
|
+
assert.ok(posted)
|
|
206
|
+
assert.deepEqual(JSON.parse(posted.body).shells[0].args, ['-c', path, '{command}'])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('参数修改保真:只追加参数仍保留原有空项、引号、反斜线与控制字符', async () => {
|
|
210
|
+
const p = await page()
|
|
211
|
+
const original = p.field(p.render(), '客户端 1 参数').props.value
|
|
212
|
+
p.field(p.render(), '客户端 1 参数').props.onChange({ target: { value: original + ' extra' } })
|
|
213
|
+
await p.button(p.render(), '保存').props.onClick()
|
|
214
|
+
const posted = p.requests.find((row) => row.method === 'POST')
|
|
215
|
+
assert.ok(posted)
|
|
216
|
+
assert.deepEqual(JSON.parse(posted.body).shells[0].args, [...section().shells[0].args, 'extra'])
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('扫描不产生未保存改动:保存仍禁用', async () => {
|
|
220
|
+
const p = await page()
|
|
221
|
+
await p.button(p.render(), '扫描本机').props.onClick()
|
|
222
|
+
assert.equal(p.button(p.render(), '保存').props.disabled, true)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('探测:独立调用路径与扫描接口,不提交配置', async () => {
|
|
226
|
+
const p = await page()
|
|
227
|
+
p.field(p.render(), '客户端 1 路径').props.onChange({ target: { value: 'C:/Git/bash.exe' } })
|
|
228
|
+
await p.button(p.render(), '探测路径').props.onClick()
|
|
229
|
+
assert.match(JSON.stringify(p.render()), /路径存在/)
|
|
230
|
+
await p.button(p.render(), '扫描本机').props.onClick()
|
|
231
|
+
assert.match(JSON.stringify(p.render()), /C:\/Git\/bash.exe/)
|
|
232
|
+
assert.equal(p.requests.filter((row) => row.path.endsWith('/config') && row.method === 'POST').length, 0)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('提交成功但回读失败:明确已保存,不误报提交失败', async () => {
|
|
236
|
+
const p = await page({ failReadBack: true })
|
|
237
|
+
p.dirty()
|
|
238
|
+
await p.button(p.render(), '保存').props.onClick()
|
|
239
|
+
assert.equal(p.requests.filter((row) => row.method === 'POST').length, 1)
|
|
240
|
+
const text = JSON.stringify(p.render())
|
|
241
|
+
assert.match(text, /已保存.*刷新失败.*回读故障/)
|
|
242
|
+
assert.doesNotMatch(text, /保存失败/)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('保存失败:保留输入并显示服务端错误', async () => {
|
|
246
|
+
const p = await page({ failSave: true })
|
|
247
|
+
p.field(p.render(), '客户端 1 id').props.onChange({ target: { value: 'edited' } })
|
|
248
|
+
await p.button(p.render(), '保存').props.onClick()
|
|
249
|
+
assert.equal(p.field(p.render(), '客户端 1 id').props.value, 'edited')
|
|
250
|
+
assert.match(JSON.stringify(p.render()), /写入故障/)
|
|
251
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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: () => {} } },
|
|
@@ -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
|
-
|
|
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) }
|
package/test/executor.test.mjs
CHANGED
|
@@ -6,6 +6,13 @@ import assert from 'node:assert/strict'
|
|
|
6
6
|
import ShellSelectExecutor from '../src/executor.mjs'
|
|
7
7
|
import { resolveConfig, assertServiceableConfig, defaultConfig } from '../src/config.mjs'
|
|
8
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
|
+
})
|
|
15
|
+
|
|
9
16
|
// --- 桩具 ---
|
|
10
17
|
|
|
11
18
|
function stubReader(text) {
|
|
@@ -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) => ({
|
|
@@ -431,12 +443,18 @@ test('构造:boxed volatile 字段(宿主 resolve 产物)解箱生效,不回落
|
|
|
431
443
|
assert.deepEqual(listing.shells.map((entry) => entry.id), ['pwsh', 'git-bash'])
|
|
432
444
|
assert.equal(executor.entryFor(undefined).id, 'git-bash')
|
|
433
445
|
const shell = registered.tools.find((tool) => tool.name === 'shell')
|
|
434
|
-
assert.match(shell.description,
|
|
435
|
-
assert.doesNotMatch(shell.description,
|
|
446
|
+
assert.match(shell.description, /^Execute commands using shell="git-bash" by default/)
|
|
447
|
+
assert.doesNotMatch(shell.description, /^- cmd —/m)
|
|
448
|
+
assert.equal(shell.parameters.properties.shell.description, 'Override the default shell using an id listed above.')
|
|
449
|
+
assert.doesNotMatch(shell.parameters.properties.sandbox_permissions.description, /retry/)
|
|
450
|
+
const prompt = registered.promptSections.find((section) => section.name === 'tool:shell').text
|
|
451
|
+
assert.match(prompt, /investigate failures/)
|
|
452
|
+
assert.match(prompt, /after an interruption/)
|
|
453
|
+
assert.doesNotMatch(prompt, /fresh processes|default client|Pass the `shell`/)
|
|
436
454
|
})
|
|
437
455
|
|
|
438
456
|
test('refresh:boxed volatile 字段换源同样解箱生效', () => {
|
|
439
|
-
const { ctx } = stubCtx()
|
|
457
|
+
const { ctx, registered } = stubCtx()
|
|
440
458
|
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
441
459
|
executor.refresh(boxFields({
|
|
442
460
|
shells: baseConfig().shells,
|
|
@@ -446,6 +464,8 @@ test('refresh:boxed volatile 字段换源同样解箱生效', () => {
|
|
|
446
464
|
const listing = executor.listShells()
|
|
447
465
|
assert.equal(listing.default, 'git-bash')
|
|
448
466
|
assert.equal(listing.shells.length, 3)
|
|
467
|
+
assert.match(registered.tools.findLast((tool) => tool.name === 'shell').description,
|
|
468
|
+
/^Execute commands using shell="git-bash" by default/)
|
|
449
469
|
})
|
|
450
470
|
|
|
451
471
|
test('构造:混合形态(部分字段 boxed 部分普通)逐字段解箱生效', () => {
|