@mzzsfy/dsh-shell-select 0.4.1 → 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.
- package/README.md +13 -11
- package/cordis.patch.yml +3 -189
- package/package.json +10 -4
- package/src/api.mjs +7 -1
- package/src/client.js +132 -416
- 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 +36 -10
- 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/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 +12 -0
- package/test/official-contract.test.mjs +208 -0
- package/test/path-guidance.test.mjs +28 -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
|
@@ -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) => ({
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// 官方 ctx.shell 契约钉死 pwsh + 工具层六场景矩阵(前台/后台 × pwsh/git-bash):
|
|
2
|
+
// BDD:官方 dsh-pwsh-local 契约即 PowerShell 专用,execute/run/start 落默认
|
|
3
|
+
// 客户端会随配置漂移炸方言(官方 pwsh 工具调用在 git-bash 里执行 PowerShell)。
|
|
4
|
+
// 后台分支 owner 必须是 agent id(resolveOwner 以 id 查 registry,传对象报
|
|
5
|
+
// "[object Object] has no live agent");deny 探测不得真实 spawn(命令双跑)。
|
|
6
|
+
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import ShellSelectExecutor from '../src/executor.mjs'
|
|
10
|
+
import { registerShellTool } from '../src/tool.mjs'
|
|
11
|
+
|
|
12
|
+
function stubReader(text) {
|
|
13
|
+
return { readFrom: () => ({ text, lossy: false, nextOffset: text.length }) }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stubSpawn(stdoutText = '', stderrText = '', exitCode = 0) {
|
|
17
|
+
const calls = []
|
|
18
|
+
const spawn = (spec) => {
|
|
19
|
+
calls.push(spec)
|
|
20
|
+
return {
|
|
21
|
+
collected: { stdout: stubReader(stdoutText), stderr: stubReader(stderrText) },
|
|
22
|
+
done: Promise.resolve({ exitCode, signal: null }),
|
|
23
|
+
terminate: () => true,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { spawn, calls }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SHELLS = [
|
|
30
|
+
{ id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: process.execPath },
|
|
31
|
+
{ id: 'git-bash', name: 'Git Bash', kind: 'bash', path: process.execPath },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
function buildExecutor({ defaultId = 'git-bash', deny = [] } = {}) {
|
|
35
|
+
const stub = stubSpawn('out', '', 0)
|
|
36
|
+
const registered = { tools: [] }
|
|
37
|
+
const jobs = { starts: [] }
|
|
38
|
+
const ctx = {
|
|
39
|
+
reflect: { provide: () => {} },
|
|
40
|
+
on: () => () => {},
|
|
41
|
+
logger: { warn: () => {} },
|
|
42
|
+
get(service) {
|
|
43
|
+
if (service === 'webServer') return { register: () => {} }
|
|
44
|
+
if (service === 'jobs') return jobsApi()
|
|
45
|
+
return undefined
|
|
46
|
+
},
|
|
47
|
+
inject(services, fn) {
|
|
48
|
+
fn({ settings: undefined, effect: (effectFn) => effectFn() })
|
|
49
|
+
},
|
|
50
|
+
subprocess: stub,
|
|
51
|
+
sandbox: { confine: (argv) => ({ argv: ['WRAPPED', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }) },
|
|
52
|
+
sandboxPolicy: { defaultMode: 'danger-full-access', resolve: () => ({ mode: 'danger-full-access', roots: [] }) },
|
|
53
|
+
tools: { register: (definition) => { registered.tools.push(definition); return () => registered.tools.pop() } },
|
|
54
|
+
systemPrompt: { section: () => {}, getSectionOrder: (key) => key },
|
|
55
|
+
shellEnv: { collect: () => ({}) },
|
|
56
|
+
effect: (fn) => fn(),
|
|
57
|
+
}
|
|
58
|
+
function jobsApi() {
|
|
59
|
+
return {
|
|
60
|
+
start(spec) {
|
|
61
|
+
jobs.starts.push(spec)
|
|
62
|
+
const outcome = spec.run()
|
|
63
|
+
return { id: `job-${jobs.starts.length}`, done: outcome.done, read: () => outcome.readOutput() }
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const executor = new ShellSelectExecutor(ctx, { shells: SHELLS, default: defaultId, deny })
|
|
68
|
+
return { executor, ctx, stub, registered, jobs }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const execFor = (id) => ({ signal: new AbortController().signal, agent: { id, session: { header: { cwd: process.cwd() } } } })
|
|
72
|
+
const EXEC = execFor('agent-0')
|
|
73
|
+
|
|
74
|
+
test('execute 契约钉死 pwsh:default=git-bash 时仍走 pwsh argv', async () => {
|
|
75
|
+
const { executor, stub } = buildExecutor({ defaultId: 'git-bash' })
|
|
76
|
+
const handle = await executor.execute(executor.resolve({ command: 'Get-Date', workdir: process.cwd() }))
|
|
77
|
+
await handle.result()
|
|
78
|
+
assert.match(stub.calls[0].argv.at(-1), /Get-Date$/)
|
|
79
|
+
assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('start 契约钉死 pwsh:default=git-bash 时仍走 pwsh argv', () => {
|
|
83
|
+
const { executor, stub } = buildExecutor({ defaultId: 'git-bash' })
|
|
84
|
+
const proc = executor.start(executor.resolve({ command: 'Get-Date', workdir: process.cwd(), onExpiry: 'none' }))
|
|
85
|
+
return proc.done.then(() => {
|
|
86
|
+
assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('配置删除 pwsh 条目:契约现场探测仍走 pwsh,不落默认客户端', async () => {
|
|
91
|
+
const { executor, stub } = buildExecutor({ defaultId: 'git-bash' })
|
|
92
|
+
executor.refresh({ shells: SHELLS.filter((entry) => entry.id !== 'pwsh'), default: 'git-bash' })
|
|
93
|
+
assert.throws(() => executor.entryFor('pwsh'), /pwsh/)
|
|
94
|
+
const entry = executor.contractEntry()
|
|
95
|
+
assert.equal(entry.kind, 'pwsh')
|
|
96
|
+
const handle = await executor.execute(executor.resolve({ command: 'Get-Date', workdir: process.cwd() }))
|
|
97
|
+
await handle.result()
|
|
98
|
+
assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// --- 工具层六场景矩阵 ---
|
|
102
|
+
|
|
103
|
+
test('矩阵:shell 前台 pwsh(git-bash 默认)', async () => {
|
|
104
|
+
const { executor, ctx, stub, registered } = buildExecutor({ defaultId: 'git-bash' })
|
|
105
|
+
registerShellTool(ctx, { executor })
|
|
106
|
+
const shellTool = registered.tools.find((tool) => tool.name === 'shell')
|
|
107
|
+
const value = await shellTool.execute({ command: 'Write-Output hi', description: 'probe', shell: 'pwsh', workdir: process.cwd() }, EXEC)
|
|
108
|
+
assert.equal(value.kind, 'foreground')
|
|
109
|
+
assert.equal(value.shell, 'pwsh')
|
|
110
|
+
assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('矩阵:shell 前台 git-bash(默认)', async () => {
|
|
114
|
+
const { executor, ctx, stub, registered } = buildExecutor({ defaultId: 'git-bash' })
|
|
115
|
+
registerShellTool(ctx, { executor })
|
|
116
|
+
const shellTool = registered.tools.find((tool) => tool.name === 'shell')
|
|
117
|
+
const value = await shellTool.execute({ command: 'echo hi', description: 'probe', workdir: process.cwd() }, EXEC)
|
|
118
|
+
assert.equal(value.kind, 'foreground')
|
|
119
|
+
assert.equal(value.shell, 'git-bash')
|
|
120
|
+
assert.deepEqual(stub.calls[0].argv.slice(1, 3), ['-c', 'echo hi'])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('矩阵:pwsh 工具前台(钉死 pwsh)', async () => {
|
|
124
|
+
const { executor, ctx, stub, registered } = buildExecutor({ defaultId: 'git-bash' })
|
|
125
|
+
registerShellTool(ctx, { executor })
|
|
126
|
+
const pwshTool = registered.tools.find((tool) => tool.name === 'pwsh')
|
|
127
|
+
const value = await pwshTool.execute({ command: 'Get-Date', description: 'probe', workdir: process.cwd() }, EXEC)
|
|
128
|
+
assert.equal(value.shell, 'pwsh')
|
|
129
|
+
assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
async function backgroundCase({ shellArg, defaultId }) {
|
|
133
|
+
const { executor, ctx, stub, registered, jobs } = buildExecutor({ defaultId })
|
|
134
|
+
registerShellTool(ctx, { executor })
|
|
135
|
+
const shellTool = registered.tools.find((tool) => tool.name === 'shell')
|
|
136
|
+
const args = { command: 'echo hi', description: 'probe', run_in_background: true, workdir: process.cwd() }
|
|
137
|
+
if (shellArg !== undefined) args.shell = shellArg
|
|
138
|
+
const value = await shellTool.execute(args, { signal: new AbortController().signal, agent: execFor('agent-1').agent })
|
|
139
|
+
assert.equal(value.kind, 'background')
|
|
140
|
+
assert.equal(jobs.starts.length, 1)
|
|
141
|
+
assert.equal(jobs.starts[0].owner, 'agent-1', 'owner 必须是 agent id 而非对象')
|
|
142
|
+
const expectedKind = shellArg === 'pwsh' || (shellArg === undefined && defaultId === 'pwsh') ? 'pwsh' : 'bash'
|
|
143
|
+
assert.equal(executor.entryFor(shellArg).kind, expectedKind)
|
|
144
|
+
return { stub, jobs }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
test('矩阵:后台 default(git-bash)', async () => {
|
|
148
|
+
const { stub, jobs } = await backgroundCase({ defaultId: 'git-bash' })
|
|
149
|
+
await jobs.starts[0].run // run() 已在 start 内同步执行
|
|
150
|
+
await Promise.resolve()
|
|
151
|
+
assert.equal(stub.calls.length, 1, 'deny 探测不得真实 spawn,整个后台任务只 spawn 一次')
|
|
152
|
+
assert.deepEqual(stub.calls[0].argv.slice(1, 3), ['-c', 'echo hi'])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('矩阵:后台 shell=pwsh', async () => {
|
|
156
|
+
const { stub } = await backgroundCase({ shellArg: 'pwsh', defaultId: 'git-bash' })
|
|
157
|
+
await Promise.resolve()
|
|
158
|
+
assert.equal(stub.calls.length, 1)
|
|
159
|
+
assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('矩阵:后台 default=pwsh(默认即 pwsh)', async () => {
|
|
163
|
+
const { stub } = await backgroundCase({ defaultId: 'pwsh' })
|
|
164
|
+
await Promise.resolve()
|
|
165
|
+
assert.equal(stub.calls.length, 1)
|
|
166
|
+
assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('矩阵:后台 pwsh 工具(钉死 pwsh)', async () => {
|
|
170
|
+
const { executor, ctx, stub, registered, jobs } = buildExecutor({ defaultId: 'git-bash' })
|
|
171
|
+
registerShellTool(ctx, { executor })
|
|
172
|
+
const pwshTool = registered.tools.find((tool) => tool.name === 'pwsh')
|
|
173
|
+
const value = await pwshTool.execute(
|
|
174
|
+
{ command: 'Get-Date', description: 'probe', run_in_background: true, workdir: process.cwd() },
|
|
175
|
+
{ signal: new AbortController().signal, agent: execFor('agent-2').agent },
|
|
176
|
+
)
|
|
177
|
+
assert.equal(value.kind, 'background')
|
|
178
|
+
assert.equal(jobs.starts[0].owner, 'agent-2')
|
|
179
|
+
await Promise.resolve()
|
|
180
|
+
assert.equal(stub.calls.length, 1)
|
|
181
|
+
assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('本包同名 pwsh 工具结果携带接管事实,而 shell=pwsh 只携带客户端事实', async () => {
|
|
185
|
+
// Given本包两种PowerShell入口;When执行并渲染;Then仅直接pwsh产生独立接管事实。
|
|
186
|
+
const { ctx, registered } = buildExecutor()
|
|
187
|
+
for (const name of ['pwsh', 'shell']) {
|
|
188
|
+
const tool = registered.tools.find(tool => tool.name === name)
|
|
189
|
+
const args = { command: 'Get-Date', description: 'probe', ...(name === 'shell' ? { shell: 'pwsh' } : {}) }
|
|
190
|
+
const value = await tool.execute(args, EXEC)
|
|
191
|
+
const content = tool.output.render(args, value)
|
|
192
|
+
if (name === 'pwsh') assert.deepEqual(content.at(-1), { type: 'shell-select-takeover', tool: 'pwsh' })
|
|
193
|
+
else assert.equal(content.some(part => part.type === 'shell-select-takeover'), false)
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('矩阵:后台 deny 命中,启动前拒绝且零 spawn', async () => {
|
|
198
|
+
const { executor, ctx, stub, registered, jobs } = buildExecutor({ defaultId: 'git-bash', deny: ['forbidden'] })
|
|
199
|
+
registerShellTool(ctx, { executor })
|
|
200
|
+
const shellTool = registered.tools.find((tool) => tool.name === 'shell')
|
|
201
|
+
const value = await shellTool.execute(
|
|
202
|
+
{ command: 'echo forbidden thing', description: 'probe', run_in_background: true, workdir: process.cwd() },
|
|
203
|
+
{ signal: new AbortController().signal, agent: execFor('agent-3').agent },
|
|
204
|
+
)
|
|
205
|
+
assert.equal(value.kind, 'foreground', 'deny 命中回退前台拒绝形态')
|
|
206
|
+
assert.equal(jobs.starts.length, 0)
|
|
207
|
+
assert.equal(stub.calls.length, 0)
|
|
208
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// bash 路径措辞测试:shellDescription 在 bash 形可用客户端在场时追加路径引导。
|
|
2
|
+
// BDD 场景见 docs/feat-shell-select-pathrescue/plan.md A 节。
|
|
3
|
+
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { shellDescription } from '../src/tool.mjs'
|
|
7
|
+
|
|
8
|
+
const FACES = { backgroundEnabled: true, escalationModes: [] }
|
|
9
|
+
const entry = (id, kind, available) => ({ id, kind, available })
|
|
10
|
+
|
|
11
|
+
test('A1 含可用 bash 条目:描述含 Windows-style 路径措辞', () => {
|
|
12
|
+
const executor = { listShells: () => ({ default: 'pwsh', shells: [entry('pwsh', 'pwsh', true), entry('git-bash', 'bash', true)] }) }
|
|
13
|
+
const text = shellDescription({ executor, ...FACES })
|
|
14
|
+
assert.match(text, /Windows-style paths/)
|
|
15
|
+
assert.match(text, /`workdir`/)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('A2 仅 pwsh/cmd:不含措辞', () => {
|
|
19
|
+
const executor = { listShells: () => ({ default: 'pwsh', shells: [entry('pwsh', 'pwsh', true), entry('cmd', 'cmd', true)] }) }
|
|
20
|
+
const text = shellDescription({ executor, ...FACES })
|
|
21
|
+
assert.doesNotMatch(text, /Windows-style paths/)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('A3 bash 条目不可执行:不含措辞(可执行性事实优先)', () => {
|
|
25
|
+
const executor = { listShells: () => ({ default: 'pwsh', shells: [entry('pwsh', 'pwsh', true), entry('git-bash', 'bash', false)] }) }
|
|
26
|
+
const text = shellDescription({ executor, ...FACES })
|
|
27
|
+
assert.doesNotMatch(text, /Windows-style paths/)
|
|
28
|
+
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// 侧栏路径救援测试:函数映射、地址语法与实际注入生命周期。
|
|
2
|
+
// BDD 场景见 docs/feat-shell-select-pathrescue/plan.md B 节。
|
|
3
|
+
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
11
|
+
const source = readFileSync(join(here, '..', 'src', 'client.js'), 'utf8')
|
|
12
|
+
|
|
13
|
+
function extractLogic(name, deps = {}) {
|
|
14
|
+
const pattern = new RegExp('// LOGIC-BEGIN ' + name + '\\n([\\s\\S]*?)\\n\\s*// LOGIC-END ' + name)
|
|
15
|
+
const match = source.match(pattern)
|
|
16
|
+
if (!match) throw new Error('client.js 缺少 LOGIC 段: ' + name)
|
|
17
|
+
const keys = Object.keys(deps)
|
|
18
|
+
return new Function(...keys, 'return (' + match[1].trim() + ')')(...keys.map((key) => deps[key]))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FACTS = { platform: 'win32', home: 'C:/Users/me', tmpDir: 'C:/Users/me/AppData/Local/Temp' }
|
|
22
|
+
|
|
23
|
+
function mountClient({ facts, hasEffect = true, laterWrapper = false }) {
|
|
24
|
+
let definition
|
|
25
|
+
let activeFacts = facts
|
|
26
|
+
const registrations = []
|
|
27
|
+
const disposers = []
|
|
28
|
+
const controller = {
|
|
29
|
+
calls: [],
|
|
30
|
+
openResource(address, options) { this.calls.push({ method: 'openResource', address, options }); return 'opened' },
|
|
31
|
+
openResourceIn(sessionId, address, options) { this.calls.push({ method: 'openResourceIn', sessionId, address, options }); return 'opened' },
|
|
32
|
+
}
|
|
33
|
+
const original = { openResource: controller.openResource, openResourceIn: controller.openResourceIn }
|
|
34
|
+
const fetcher = () => activeFacts === null
|
|
35
|
+
? Promise.reject(new Error('route not ready'))
|
|
36
|
+
: Promise.resolve({ ok: true, json: () => Promise.resolve(activeFacts) })
|
|
37
|
+
new Function('window', 'document', 'navigator', 'fetch', source)(
|
|
38
|
+
{ __ModuleLoader__: { load: (value) => { definition = value } } },
|
|
39
|
+
{ createElement: () => ({ setAttribute() {}, remove() {} }), head: { appendChild() {} } },
|
|
40
|
+
{}, fetcher,
|
|
41
|
+
)
|
|
42
|
+
const client = definition.factory((name) => name === 'react'
|
|
43
|
+
? { createElement: () => null, useState: () => [false, () => {}], useEffect() {} }
|
|
44
|
+
: {})
|
|
45
|
+
const ctx = {
|
|
46
|
+
effect: (body) => body(),
|
|
47
|
+
slots: { inject: (_hole, callback) => callback(), register: (meta) => registrations.push(meta) },
|
|
48
|
+
inject: (names, callback) => {
|
|
49
|
+
if (!names.includes('sidebarRight')) return
|
|
50
|
+
callback({
|
|
51
|
+
sidebarRight: controller,
|
|
52
|
+
...(hasEffect ? { effect: (body) => disposers.push(body()) } : {}),
|
|
53
|
+
})
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
client.apply(ctx)
|
|
57
|
+
if (laterWrapper) {
|
|
58
|
+
const wrapped = controller.openResource
|
|
59
|
+
controller.openResource = function later(address, options) { return wrapped.call(this, address, options) }
|
|
60
|
+
}
|
|
61
|
+
return { controller, original, disposers, setFacts: (value) => { activeFacts = value } }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test('B5 盘根翻译:/c/x → C:/x,/c → C:/', () => {
|
|
65
|
+
const translate = extractLogic('msysToHostPath')
|
|
66
|
+
assert.equal(translate('/c/Users/x', FACTS), 'C:/Users/x')
|
|
67
|
+
assert.equal(translate('/c', FACTS), 'C:/')
|
|
68
|
+
assert.equal(translate('/C/Users/x', FACTS), 'C:/Users/x')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('B5 home 翻译:~ 与 ~/x', () => {
|
|
72
|
+
const translate = extractLogic('msysToHostPath')
|
|
73
|
+
assert.equal(translate('~', FACTS), 'C:/Users/me')
|
|
74
|
+
assert.equal(translate('~/a/b.txt', FACTS), 'C:/Users/me/a/b.txt')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('B5 tmp 翻译:/tmp 与 /tmp/x', () => {
|
|
78
|
+
const translate = extractLogic('msysToHostPath')
|
|
79
|
+
assert.equal(translate('/tmp', FACTS), 'C:/Users/me/AppData/Local/Temp')
|
|
80
|
+
assert.equal(translate('/tmp/x.log', FACTS), 'C:/Users/me/AppData/Local/Temp/x.log')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('B5 非翻译形态原样:Windows 路径/相对路径/未知 POSIX 段', () => {
|
|
84
|
+
const translate = extractLogic('msysToHostPath')
|
|
85
|
+
assert.equal(translate('C:/repo/a.txt', FACTS), 'C:/repo/a.txt')
|
|
86
|
+
assert.equal(translate('src/index.js', FACTS), 'src/index.js')
|
|
87
|
+
assert.equal(translate('/usr/bin/bash', FACTS), '/usr/bin/bash')
|
|
88
|
+
assert.equal(translate(42, FACTS), 42)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('B6 file 地址翻译:session/absolute、空格与后缀', () => {
|
|
92
|
+
const translate = extractLogic('msysToHostPath')
|
|
93
|
+
const rescue = extractLogic('rescueFileAddress')
|
|
94
|
+
assert.equal(rescue('dsh-resource://file/session/s1//c/Users/x.txt', translate, FACTS), 'dsh-resource://file/session/s1/C:/Users/x.txt')
|
|
95
|
+
assert.equal(rescue('dsh-resource://file/absolute/c/Users/y%20x.md', translate, FACTS), 'dsh-resource://file/absolute/C:/Users/y%20x.md')
|
|
96
|
+
assert.ok(rescue('dsh-resource://file/absolute/c/Users/y.md?line=3', translate, FACTS).endsWith('?line=3'))
|
|
97
|
+
const unchanged = 'dsh-resource://file/absolute/C%3A/Users/z.txt'
|
|
98
|
+
assert.equal(rescue(unchanged, translate, FACTS), unchanged)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('B6 非 file、UNC、坏转义地址原样', () => {
|
|
102
|
+
const translate = extractLogic('msysToHostPath')
|
|
103
|
+
const rescue = extractLogic('rescueFileAddress')
|
|
104
|
+
for (const address of [
|
|
105
|
+
'dsh-resource://other/abc', 'not an address',
|
|
106
|
+
'dsh-resource://file/absolute//server/share/x',
|
|
107
|
+
'dsh-resource://file/absolute/%zz/x',
|
|
108
|
+
]) assert.equal(rescue(address, translate, FACTS), address)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('B6 编码 sessionId 翻译后只编码一次', () => {
|
|
112
|
+
const translate = extractLogic('msysToHostPath')
|
|
113
|
+
const rescue = extractLogic('rescueFileAddress')
|
|
114
|
+
const address = 'dsh-resource://file/session/s%201//c/Users/x.txt'
|
|
115
|
+
assert.equal(rescue(address, translate, FACTS), 'dsh-resource://file/session/s%201/C:/Users/x.txt')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('B6 地址翻译函数是纯函数,平台门由运行时安装包装负责', () => {
|
|
119
|
+
const translate = extractLogic('msysToHostPath')
|
|
120
|
+
assert.equal(translate('/c/x', null), 'C:/x')
|
|
121
|
+
const rescue = extractLogic('rescueFileAddress')
|
|
122
|
+
assert.equal(rescue('dsh-resource://file/absolute/c/x', translate, FACTS), 'dsh-resource://file/absolute/C:/x')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('B7 可观察的 sidebar 包装透传两个入口并在 fiber dispose 后恢复', async () => {
|
|
126
|
+
const mounted = mountClient({ facts: FACTS })
|
|
127
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
128
|
+
const { controller, original, disposers } = mounted
|
|
129
|
+
assert.equal(controller.openResource('dsh-resource://file/absolute/c/x', { tab: true }), 'opened')
|
|
130
|
+
assert.deepEqual(controller.calls.at(-1), { method: 'openResource', address: 'dsh-resource://file/absolute/C:/x', options: { tab: true } })
|
|
131
|
+
assert.equal(controller.openResourceIn('s2', 'dsh-resource://file/session/s1//c/x', { line: 2 }), 'opened')
|
|
132
|
+
assert.equal(controller.calls.at(-1).address, 'dsh-resource://file/session/s1/C:/x')
|
|
133
|
+
assert.equal(controller.calls.at(-1).sessionId, 's2')
|
|
134
|
+
disposers[0]()
|
|
135
|
+
assert.equal(controller.openResource, original.openResource)
|
|
136
|
+
assert.equal(controller.openResourceIn, original.openResourceIn)
|
|
137
|
+
controller.openResource('dsh-resource://file/absolute/c/x')
|
|
138
|
+
assert.equal(controller.calls.at(-1).address, 'dsh-resource://file/absolute/c/x')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('B7 缺少 effect 清理能力时不改写侧栏方法', () => {
|
|
142
|
+
const mounted = mountClient({ facts: FACTS, hasEffect: false })
|
|
143
|
+
assert.equal(mounted.controller.openResource, mounted.original.openResource)
|
|
144
|
+
assert.equal(mounted.controller.openResourceIn, mounted.original.openResourceIn)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('B7 后续插件包装仍保留,卸载只移除本插件包装', async () => {
|
|
148
|
+
const mounted = mountClient({ facts: FACTS, laterWrapper: true })
|
|
149
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
150
|
+
const laterWrapper = mounted.controller.openResource
|
|
151
|
+
mounted.disposers[0]()
|
|
152
|
+
assert.equal(mounted.controller.openResource, laterWrapper)
|
|
153
|
+
mounted.controller.openResource('dsh-resource://file/absolute/c/x')
|
|
154
|
+
assert.equal(mounted.controller.calls.at(-1).address, 'dsh-resource://file/absolute/c/x')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('B7 pathmap 请求失败可在后续调用重试', async () => {
|
|
158
|
+
const mounted = mountClient({ facts: null })
|
|
159
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
160
|
+
mounted.setFacts(FACTS)
|
|
161
|
+
mounted.controller.openResource('dsh-resource://file/absolute/c/x')
|
|
162
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
163
|
+
mounted.controller.calls.length = 0
|
|
164
|
+
mounted.controller.openResource('dsh-resource://file/absolute/c/x')
|
|
165
|
+
assert.equal(mounted.controller.calls[0].address, 'dsh-resource://file/absolute/C:/x')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('B7 非 Windows pathmap 不改写原地址', async () => {
|
|
169
|
+
const mounted = mountClient({ facts: { ...FACTS, platform: 'linux' } })
|
|
170
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
171
|
+
mounted.controller.openResource('/c/x')
|
|
172
|
+
assert.equal(mounted.controller.calls[0].address, '/c/x')
|
|
173
|
+
})
|