@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
|
@@ -1,154 +1,45 @@
|
|
|
1
|
-
// preset 接管守卫(0.1.7 预设架构):本包 patch insert 自带预设声明
|
|
2
|
-
// (preset-shell-select,standard 内联 roster − tool-pwsh 行)并覆写
|
|
3
|
-
// agent-preset-registry 的 default。漂移守卫对照已安装 dsh 的
|
|
4
|
-
// dsh-web-app/presets/standard.patch.yml——上游增删行/改表达式即红,升级后
|
|
5
|
-
// 按官方实文对表镜像;宿主缺失(无 dsh 本体的最小环境)时 skip,不制造假红。
|
|
6
|
-
|
|
7
1
|
import { test } from 'node:test'
|
|
8
2
|
import assert from 'node:assert/strict'
|
|
9
|
-
import { readFileSync
|
|
10
|
-
import
|
|
11
|
-
import { dirname, join, resolve } from 'node:path'
|
|
12
|
-
import { createRequire } from 'node:module'
|
|
13
|
-
import { execPath } from 'node:process'
|
|
14
|
-
|
|
15
|
-
const here = dirname(fileURLToPath(import.meta.url))
|
|
16
|
-
const pkgRoot = join(here, '..')
|
|
17
|
-
const patchText = readFileSync(join(pkgRoot, 'cordis.patch.yml'), 'utf8')
|
|
18
|
-
|
|
19
|
-
// ── 宿主侧 yaml 能力探测:js-yaml 与官方 entryListSchema 经 dsh 安装位解析 ──
|
|
20
|
-
|
|
21
|
-
function locateHarnessDir() {
|
|
22
|
-
// 候选锚:运行中 node 的全局安装位(nvm/全局布局:execPath 目录/node_modules)→
|
|
23
|
-
// 仓库 compat 隔离宿主(.dsh-versions/<v>/package)。缺失即返回 null(skip)。
|
|
24
|
-
const candidates = [
|
|
25
|
-
resolve(dirname(execPath), 'node_modules', '@deepseek-ai', 'dsh'),
|
|
26
|
-
]
|
|
27
|
-
const versionsDir = resolve(pkgRoot, '..', '..', '.dsh-versions')
|
|
28
|
-
try {
|
|
29
|
-
for (const version of readdirSync(versionsDir)) {
|
|
30
|
-
candidates.push(join(versionsDir, version, 'package'))
|
|
31
|
-
}
|
|
32
|
-
} catch { /* 无 .dsh-versions,忽略 */ }
|
|
33
|
-
for (const candidate of candidates) {
|
|
34
|
-
if (existsSync(join(candidate, 'package.json'))) return candidate
|
|
35
|
-
}
|
|
36
|
-
return null
|
|
37
|
-
}
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import yaml from 'js-yaml'
|
|
38
5
|
|
|
39
|
-
|
|
40
|
-
// !!js 标签本地同形 Type:tag 必须写完整 URI(tag:yaml.org,2002:js)——文档简写
|
|
41
|
-
// !!js 展开后按完整 URI 匹配,字面 '!!js' 永不命中(unknown tag,CI 假绿教训)。
|
|
42
|
-
import yamlStatic from 'js-yaml'
|
|
43
|
-
|
|
44
|
-
const LOCAL_SCHEMA = yamlStatic.JSON_SCHEMA.extend(new yamlStatic.Type('tag:yaml.org,2002:js', {
|
|
6
|
+
const schema = yaml.JSON_SCHEMA.extend(new yaml.Type('tag:yaml.org,2002:js', {
|
|
45
7
|
kind: 'scalar',
|
|
46
8
|
resolve: (data) => typeof data === 'string',
|
|
47
9
|
construct: (data) => ({ __jsExpr: data }),
|
|
48
|
-
represent: (data) => data['__jsExpr'],
|
|
49
10
|
}))
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const loadYaml = (text) => yamlStatic.load(text, { schema: entryListSchema })
|
|
63
|
-
|
|
64
|
-
// 镜像 loader 的表达式求值形态(new Function + with(ctx),全局可用)
|
|
65
|
-
const evalExpr = (expr, ctx) => new Function('ctx', `with (ctx) { return (${expr}) }`)(ctx)
|
|
66
|
-
|
|
67
|
-
// ── 结构对比:展平行树为 id → {name, disabled, isolate, config 全值} ──
|
|
68
|
-
// config 比全值而非键集:键集守不住文本漂移(plan-mode section 曾落后官方一句
|
|
69
|
-
// 而守卫全绿),镜像声明即「standard 全量 − tool-pwsh 逐项同构」,值必须对表
|
|
70
|
-
|
|
71
|
-
function flattenRows(rows, out = new Map()) {
|
|
72
|
-
for (const row of rows ?? []) {
|
|
73
|
-
if (row.id !== undefined) {
|
|
74
|
-
out.set(row.id, {
|
|
75
|
-
name: row.name,
|
|
76
|
-
disabled: row.disabled === undefined ? undefined : row.disabled.__jsExpr ?? row.disabled,
|
|
77
|
-
isolate: row.isolate === undefined ? undefined : JSON.parse(JSON.stringify(row.isolate)),
|
|
78
|
-
config: row.config === undefined ? undefined : JSON.parse(JSON.stringify(row.config)),
|
|
79
|
-
})
|
|
80
|
-
}
|
|
81
|
-
if (row.group && Array.isArray(row.config)) flattenRows(row.config, out)
|
|
82
|
-
}
|
|
83
|
-
return out
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// 本包 patch 解析:insert 的预设声明行 + 各覆写行
|
|
87
|
-
const patchRows = yamlStatic.load(patchText, { schema: LOCAL_SCHEMA })
|
|
88
|
-
const insertRows = patchRows.filter((entry) => entry.insert !== undefined).flatMap((entry) => entry.insert)
|
|
89
|
-
const ourPresetRow = insertRows.find((row) => row.id === 'preset-shell-select')
|
|
90
|
-
const ourPlugins = ourPresetRow?.config?.plugins ?? []
|
|
91
|
-
|
|
92
|
-
test('patch 形态:自带预设声明行(preset-shell-select,0.1.7 内联架构)', () => {
|
|
93
|
-
assert.ok(ourPresetRow, 'patch 缺 preset-shell-select 声明(insert 段)')
|
|
94
|
-
assert.equal(ourPresetRow.name, '@deepseek-ai/dsh-agent-preset')
|
|
95
|
-
assert.equal(ourPresetRow.config.id, 'shell-select')
|
|
96
|
-
const ids = new Set()
|
|
97
|
-
const walk = (list) => {
|
|
98
|
-
for (const row of list) {
|
|
99
|
-
if (row.id !== undefined) ids.add(row.id)
|
|
100
|
-
if (row.group && Array.isArray(row.config)) walk(row.config)
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
walk(ourPlugins)
|
|
104
|
-
assert.ok(ids.has('tool-bash'), 'POSIX bash 工具行必须保留')
|
|
105
|
-
assert.ok(!ids.has('tool-pwsh'), 'tool-pwsh 行必须移除(win32 shell 面由本包接管)')
|
|
106
|
-
assert.ok(!ids.has('persistent-pwsh'), 'persistent-pwsh 行不得进入本包预设')
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
test('patch 形态:agent-preset-registry 覆写含平台 default(patch 语义为整体替换 config)', () => {
|
|
110
|
-
const row = patchRows.find((entry) => entry.id === 'agent-preset-registry')
|
|
111
|
-
assert.ok(row, 'patch 缺 agent-preset-registry 覆写行')
|
|
112
|
-
assert.equal(row.name, undefined, 'registry 行是本包上层已 insert 的行,覆写按 id 匹配即无须 name')
|
|
113
|
-
assert.match(row.config.default.__jsExpr, /'shell-select'/)
|
|
114
|
-
assert.match(row.config.default.__jsExpr, /'standard'/)
|
|
11
|
+
const patch = yaml.load(readFileSync(new URL('../cordis.patch.yml', import.meta.url), 'utf8'), { schema })
|
|
12
|
+
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
13
|
+
const inserted = patch.flatMap((entry) => entry.insert ?? [])
|
|
14
|
+
|
|
15
|
+
// Given 插件补丁,When 应用插入行,Then 只增加宿主执行面,不声明会话预设。
|
|
16
|
+
test('补丁仅插入主行、guard 与 web,不增加会话预设', () => {
|
|
17
|
+
assert.deepEqual(inserted.map(({ id, name }) => ({ id, name })), [
|
|
18
|
+
{ id: 'shell-select', name: '@mzzsfy/dsh-shell-select' },
|
|
19
|
+
{ id: 'shell-select/guard', name: '@mzzsfy/dsh-shell-select/guard' },
|
|
20
|
+
{ id: 'shell-select/web', name: '@mzzsfy/dsh-shell-select/web' },
|
|
21
|
+
])
|
|
115
22
|
})
|
|
116
23
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
assert.
|
|
24
|
+
// Given 任意宿主默认预设,When 应用插件补丁,Then 不覆写既有组合行。
|
|
25
|
+
test('补丁不覆写宿主行或默认预设', () => {
|
|
26
|
+
assert.equal(patch.length, 1)
|
|
27
|
+
assert.deepEqual(Object.keys(patch[0]), ['insert'])
|
|
121
28
|
})
|
|
122
29
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const ours = flattenRows(ourPlugins)
|
|
132
|
-
for (const [id, row] of standard) {
|
|
133
|
-
if (id === 'tool-pwsh') {
|
|
134
|
-
assert.ok(!ours.has(id), 'standard 有 tool-pwsh 而本包预设也出现了:镜像被污染')
|
|
135
|
-
continue
|
|
136
|
-
}
|
|
137
|
-
assert.ok(ours.has(id), `standard 行 ${id} 在本包预设缺失,须对表镜像`)
|
|
138
|
-
assert.deepEqual(ours.get(id), row, `行 ${id} 与 standard 漂移,升级后按官方实文对表`)
|
|
139
|
-
}
|
|
140
|
-
for (const id of ours.keys()) {
|
|
141
|
-
assert.ok(standard.has(id), `本包预设多出 standard 没有的行 ${id}:镜像越界`)
|
|
30
|
+
// Given 不同宿主平台,When 求值禁用表达式,Then Windows 启用、POSIX 停用。
|
|
31
|
+
test('宿主行保持 Windows 平台门控', () => {
|
|
32
|
+
assert.equal(inserted.length, 3)
|
|
33
|
+
for (const row of inserted) {
|
|
34
|
+
const evaluate = new Function('process', `return (${row.disabled.__jsExpr})`)
|
|
35
|
+
assert.equal(evaluate({ platform: 'win32' }), false)
|
|
36
|
+
assert.equal(evaluate({ platform: 'linux' }), true)
|
|
37
|
+
assert.equal(evaluate({ platform: 'darwin' }), true)
|
|
142
38
|
}
|
|
143
39
|
})
|
|
144
40
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
assert.deepEqual(
|
|
150
|
-
yamlStatic.load(patchText, { schema: LOCAL_SCHEMA }),
|
|
151
|
-
loadYaml(patchText),
|
|
152
|
-
'bundle patch 双 schema 产物漂移,本地 !!js Type 须与官方 JsExpr 同构',
|
|
153
|
-
)
|
|
41
|
+
// Given 插件清单,When 对账宿主依赖,Then 删除预设依赖但保留最低宿主版本。
|
|
42
|
+
test('清单不再依赖预设模块,宿主门槛不变化', () => {
|
|
43
|
+
assert.equal(manifest.peerDependencies['@deepseek-ai/dsh-agent-preset'], undefined)
|
|
44
|
+
assert.equal(manifest.dsh.hostMin, '0.1.7-rc.1')
|
|
154
45
|
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
4
|
+
import { createScope } from '@deepseek-ai/dsh-scope'
|
|
5
|
+
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
6
|
+
import ToolRuntime, { defineTool, renderToolsSdk, renderToolsSdkPy } from '@deepseek-ai/dsh-tools'
|
|
7
|
+
import * as projection from '../src/prompt-projection.mjs'
|
|
8
|
+
|
|
9
|
+
function fixture() {
|
|
10
|
+
const ctx = new Context()
|
|
11
|
+
new SystemPrompt(ctx, {})
|
|
12
|
+
new ToolRuntime(ctx, {})
|
|
13
|
+
const preset = {}
|
|
14
|
+
const scoped = createScope(ctx, preset)
|
|
15
|
+
const register = (target, name, description) => target.tools.register(defineTool({
|
|
16
|
+
name, description, parameters: {},
|
|
17
|
+
output: { schema: { type: 'string' }, render: (value) => value },
|
|
18
|
+
execute: async () => 'PowerShell',
|
|
19
|
+
}))
|
|
20
|
+
register(ctx, 'shell', 'multi client')
|
|
21
|
+
register(ctx, 'pwsh', 'Do not call; use shell.')
|
|
22
|
+
register(scoped.ctx, 'pwsh', 'official preset description')
|
|
23
|
+
scoped.ctx.systemPrompt.section({ name: 'tool:pwsh', order: 1001, text: 'duplicate guidance' })
|
|
24
|
+
ctx.systemPrompt.section({ name: 'tool:shell', order: 1000, text: 'shell guidance' })
|
|
25
|
+
ctx.systemPrompt.context({ name: 'retained-context', order: 1, text: 'cwd' })
|
|
26
|
+
return { ctx, scoped, preset }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Given 官方预设遮蔽根描述;When 组装;Then 无pwsh展示但历史调用保持可执行。
|
|
30
|
+
test('native 投影穿过预设遮蔽且不影响调用', async () => {
|
|
31
|
+
const { ctx, preset, scoped } = fixture()
|
|
32
|
+
const agent = {}
|
|
33
|
+
const child = createScope(ctx, agent, { parent: preset })
|
|
34
|
+
const original = await ctx.systemPrompt.assemble({ scope: agent })
|
|
35
|
+
assert.equal(original.tools.find((t) => t.name === 'pwsh').description, 'official preset description')
|
|
36
|
+
projection.mountShellPromptProjection(ctx)
|
|
37
|
+
const result = await ctx.systemPrompt.assemble({ scope: agent })
|
|
38
|
+
assert.deepEqual(result.tools.map((t) => t.name), ['shell'])
|
|
39
|
+
assert.deepEqual(result.sections.filter((s) => s.name.startsWith('tool:')).map((s) => s.name), ['tool:shell'])
|
|
40
|
+
assert.deepEqual(result.contexts, original.contexts)
|
|
41
|
+
assert.deepEqual(result.variables, original.variables)
|
|
42
|
+
assert.equal(ctx.tools.get('pwsh', agent).description, 'official preset description')
|
|
43
|
+
assert.equal(await ctx.tools.get('pwsh', agent).execute({}, {}), 'PowerShell')
|
|
44
|
+
await child.dispose()
|
|
45
|
+
await scoped.dispose()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Given 监听卸载;When 再次组装;Then 原预设schema与提示段恢复。
|
|
49
|
+
test('投影卸载自然还原', async () => {
|
|
50
|
+
const { ctx, preset, scoped } = fixture()
|
|
51
|
+
const original = await ctx.systemPrompt.assemble({ scope: preset })
|
|
52
|
+
const dispose = projection.mountShellPromptProjection(ctx)
|
|
53
|
+
await ctx.systemPrompt.assemble({ scope: preset })
|
|
54
|
+
dispose()
|
|
55
|
+
assert.deepEqual(await ctx.systemPrompt.assemble({ scope: preset }), original)
|
|
56
|
+
await scoped.dispose()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// Given 子代理排除shell;When 组装;Then 不移除其唯一终端。
|
|
60
|
+
test('shell 不可见时保留 pwsh', async () => {
|
|
61
|
+
const { ctx, preset, scoped } = fixture()
|
|
62
|
+
const agent = {}
|
|
63
|
+
const child = createScope(ctx, agent, { parent: preset })
|
|
64
|
+
child.ctx.tools.restrict({ deny: ['shell'] })
|
|
65
|
+
const original = await ctx.systemPrompt.assemble({ scope: agent })
|
|
66
|
+
projection.mountShellPromptProjection(ctx)
|
|
67
|
+
assert.deepEqual(await ctx.systemPrompt.assemble({ scope: agent }), original)
|
|
68
|
+
await child.dispose()
|
|
69
|
+
await scoped.dispose()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
for (const [language, render] of [['typescript', renderToolsSdk], ['python', renderToolsSdkPy]]) {
|
|
73
|
+
for (const mode of ['ptc', 'both']) {
|
|
74
|
+
// Given PTC SDK;When 组装;Then 仅删除pwsh声明,其他工具类型不变。
|
|
75
|
+
test(`${language}/${mode} SDK 删除 pwsh 且保留其他输出类型`, async () => {
|
|
76
|
+
const { ctx, preset, scoped } = fixture()
|
|
77
|
+
const schemas = ctx.tools.schemas(preset).map((s) => ({ ...s, output: ctx.tools.get(s.name, preset).output.schema }))
|
|
78
|
+
ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
79
|
+
const result = await next()
|
|
80
|
+
return { ...result, tools: mode === 'ptc' ? [{ name: 'run_code' }] : [...result.tools, { name: 'run_code' }],
|
|
81
|
+
sections: [...result.sections, { name: 'tools:sdk', text: render(schemas), interpolate: false }] }
|
|
82
|
+
})
|
|
83
|
+
ctx.reflect.provide('ptcRuntime', { language })
|
|
84
|
+
projection.mountShellPromptProjection(ctx)
|
|
85
|
+
const result = await ctx.systemPrompt.assemble({ scope: preset })
|
|
86
|
+
assert.deepEqual(result.tools.map((t) => t.name), mode === 'ptc' ? ['run_code'] : ['shell', 'run_code'])
|
|
87
|
+
assert.equal(result.sections.find((s) => s.name === 'tools:sdk').text, render(schemas.filter((s) => s.name !== 'pwsh')))
|
|
88
|
+
assert.equal(await ctx.tools.get('pwsh', preset).execute({}, {}), 'PowerShell')
|
|
89
|
+
await scoped.dispose()
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Given 其他waterfall在next中添加pwsh;When投影;Then最终结果过滤而非入口快照。
|
|
95
|
+
test('投影过滤 next 返回的最终结果且不篡改输入', async () => {
|
|
96
|
+
const handlers = []
|
|
97
|
+
const warnings = []
|
|
98
|
+
const ctx = { on: (name, handler) => { handlers.push(handler); return () => {} }, tools: { get: () => ({}) }, logger: { warn: (msg) => warnings.push(msg) } }
|
|
99
|
+
projection.mountShellPromptProjection(ctx)
|
|
100
|
+
const input = { tools: [], sections: [] }
|
|
101
|
+
const final = { tools: [{ name: 'shell' }, { name: 'pwsh' }, { name: 'read' }], sections: [{ name: 'tool:pwsh' }, { name: 'policy' }], contexts: [], variables: {} }
|
|
102
|
+
const result = await handlers[0](input, {}, async () => final)
|
|
103
|
+
assert.deepEqual(result.tools.map((t) => t.name), ['shell', 'read'])
|
|
104
|
+
assert.deepEqual(result.sections.map((s) => s.name), ['policy'])
|
|
105
|
+
assert.equal(final.tools.length, 3)
|
|
106
|
+
assert.equal(input.tools.length, 0)
|
|
107
|
+
assert.deepEqual(warnings, [])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Given 无法识别SDK语言;When投影;Then原样返回且告警,不做半完成SDK。
|
|
111
|
+
test('SDK 投影不可用时整体不改并告警', async () => {
|
|
112
|
+
const handlers = []
|
|
113
|
+
const warnings = []
|
|
114
|
+
const ctx = { on: (name, handler) => { handlers.push(handler); return () => {} }, tools: { get: () => ({}) },
|
|
115
|
+
get: () => ({ language: 'unsupported' }), logger: { warn: (msg) => warnings.push(msg) } }
|
|
116
|
+
projection.mountShellPromptProjection(ctx)
|
|
117
|
+
const final = { tools: [{ name: 'shell' }, { name: 'pwsh' }, { name: 'run_code' }], sections: [{ name: 'tools:sdk', text: 'pwsh sdk' }] }
|
|
118
|
+
assert.equal(await handlers[0](final, {}, async () => final), final)
|
|
119
|
+
assert.equal(warnings.length, 1)
|
|
120
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mountPwshTakeover } from '../src/pwsh-takeover.mjs'
|
|
4
|
+
|
|
5
|
+
test('pwsh 接管事实仅归属实际进入执行器的并行调用', async () => {
|
|
6
|
+
// Given 两条 pwsh 调用;When 仅一条进入接管执行器;Then 仅该结果带标记,值与退出锚不变。
|
|
7
|
+
const listeners = new Map()
|
|
8
|
+
const touch = mountPwshTakeover({ on: (name, fn) => listeners.set(name, fn), effect: (fn) => fn() })
|
|
9
|
+
const dispatch = listeners.get('tools/execute')
|
|
10
|
+
const post = listeners.get('tools/post-execute')
|
|
11
|
+
const makeExec = () => ({ name: 'pwsh', token: Symbol() })
|
|
12
|
+
const taken = makeExec()
|
|
13
|
+
const untouched = makeExec()
|
|
14
|
+
const value = { exitCode: 2 }
|
|
15
|
+
const result = { isError: false, value, content: [{ type: 'text', text: 'out\n[exit code: 2]' }] }
|
|
16
|
+
await Promise.all([
|
|
17
|
+
dispatch(taken, async () => { await Promise.resolve(); touch(); return result }),
|
|
18
|
+
dispatch(untouched, async () => result),
|
|
19
|
+
])
|
|
20
|
+
const marked = await post(taken, result, async () => ({ kind: 'accept' }))
|
|
21
|
+
assert.equal(marked.kind, 'accept')
|
|
22
|
+
assert.equal(marked.content[0].text, 'out\n[exit code: 2]')
|
|
23
|
+
assert.deepEqual(marked.content[1], { type: 'shell-select-takeover', tool: 'pwsh' })
|
|
24
|
+
assert.equal(result.value, value)
|
|
25
|
+
assert.deepEqual(await post(untouched, result, async () => ({ kind: 'accept' })), { kind: 'accept' })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('普通 shell 与下游替换内容保持不变,接管事实消费一次', async () => {
|
|
29
|
+
// Given shell 与已接管 pwsh;When 下游策略替换内容;Then 不覆盖策略,调用事实无残留。
|
|
30
|
+
const listeners = new Map()
|
|
31
|
+
const touch = mountPwshTakeover({ on: (name, fn) => listeners.set(name, fn), effect: (fn) => fn() })
|
|
32
|
+
const result = { content: [{ type: 'text', text: 'out' }], isError: false }
|
|
33
|
+
const shell = { name: 'shell', token: Symbol() }
|
|
34
|
+
await listeners.get('tools/execute')(shell, async () => { touch(); return result })
|
|
35
|
+
assert.deepEqual(await listeners.get('tools/post-execute')(shell, result, async () => ({ kind: 'accept' })), { kind: 'accept' })
|
|
36
|
+
const pwsh = { name: 'pwsh', token: Symbol() }
|
|
37
|
+
await listeners.get('tools/execute')(pwsh, async () => { touch(); return result })
|
|
38
|
+
const replacement = { kind: 'accept', content: [{ type: 'text', text: 'policy replacement' }] }
|
|
39
|
+
assert.equal(await listeners.get('tools/post-execute')(pwsh, result, async () => replacement), replacement)
|
|
40
|
+
assert.deepEqual(await listeners.get('tools/post-execute')(pwsh, result, async () => ({ kind: 'accept' })), { kind: 'accept' })
|
|
41
|
+
})
|
package/test/resolve.test.mjs
CHANGED
|
@@ -25,7 +25,7 @@ const ENV_WITH_SYSTEM32_PATH = {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
test('pwsh 候选顺序:PowerShell 7 → PATH 各项 → System32 5.1', () => {
|
|
28
|
-
const candidates = candidatePaths('pwsh', ENV)
|
|
28
|
+
const candidates = candidatePaths('pwsh', ENV, 'win32')
|
|
29
29
|
assert.deepEqual(candidates, [
|
|
30
30
|
'C:\\PF\\PowerShell\\7\\pwsh.exe',
|
|
31
31
|
'C:\\one\\pwsh.exe',
|
|
@@ -36,7 +36,7 @@ test('pwsh 候选顺序:PowerShell 7 → PATH 各项 → System32 5.1', () => {
|
|
|
36
36
|
})
|
|
37
37
|
|
|
38
38
|
test('bash 候选顺序:Git 三常见位置 → msys2 真实 bash → PATH', () => {
|
|
39
|
-
const candidates = candidatePaths('bash', ENV)
|
|
39
|
+
const candidates = candidatePaths('bash', ENV, 'win32')
|
|
40
40
|
assert.deepEqual(candidates, [
|
|
41
41
|
'C:\\PF\\Git\\bin\\bash.exe',
|
|
42
42
|
'C:\\PF86\\Git\\bin\\bash.exe',
|
|
@@ -50,7 +50,7 @@ test('bash 候选顺序:Git 三常见位置 → msys2 真实 bash → PATH', ()
|
|
|
50
50
|
})
|
|
51
51
|
|
|
52
52
|
test('bash 候选排除 SystemRoot 下的 PATH 条目(WSL forwarder)', () => {
|
|
53
|
-
const candidates = candidatePaths('bash', ENV_WITH_SYSTEM32_PATH)
|
|
53
|
+
const candidates = candidatePaths('bash', ENV_WITH_SYSTEM32_PATH, 'win32')
|
|
54
54
|
assert.deepEqual(candidates, [
|
|
55
55
|
'C:\\PF\\Git\\bin\\bash.exe',
|
|
56
56
|
'C:\\PF86\\Git\\bin\\bash.exe',
|
|
@@ -63,21 +63,21 @@ test('bash 候选排除 SystemRoot 下的 PATH 条目(WSL forwarder)', () => {
|
|
|
63
63
|
|
|
64
64
|
test('bash 候选排除 SystemRoot 下的正斜杠 PATH 条目', () => {
|
|
65
65
|
const env = { ...ENV, PATH: 'C:/WINDOWS/System32;C:\\one' }
|
|
66
|
-
const candidates = candidatePaths('bash', env)
|
|
66
|
+
const candidates = candidatePaths('bash', env, 'win32')
|
|
67
67
|
assert.ok(!candidates.some((candidate) => candidate.toLowerCase().startsWith('c:\\windows')))
|
|
68
68
|
assert.ok(candidates.includes('C:\\one\\bash.exe'))
|
|
69
69
|
})
|
|
70
70
|
|
|
71
71
|
test('SystemRoot 带尾分隔符时排除规则仍命中', () => {
|
|
72
72
|
const env = { ...ENV, SystemRoot: 'C:\\WINDOWS\\', PATH: 'C:\\WINDOWS\\System32;C:\\one' }
|
|
73
|
-
const candidates = candidatePaths('bash', env)
|
|
73
|
+
const candidates = candidatePaths('bash', env, 'win32')
|
|
74
74
|
assert.ok(!candidates.some((candidate) => candidate.toLowerCase().startsWith('c:\\windows')), '尾分隔符 SystemRoot 不得让 forwarder 入候选')
|
|
75
75
|
assert.ok(candidates.includes('C:\\one\\bash.exe'))
|
|
76
76
|
})
|
|
77
77
|
|
|
78
78
|
test('LocalAppData 缺省:LAD 锚位跳过,其余候选不受影响', () => {
|
|
79
79
|
const env = { ProgramFiles: 'C:\\PF', 'ProgramFiles(x86)': 'C:\\PF86', LocalAppData: '', PATH: '' }
|
|
80
|
-
const candidates = candidatePaths('bash', env)
|
|
80
|
+
const candidates = candidatePaths('bash', env, 'win32')
|
|
81
81
|
assert.ok(!candidates.some((candidate) => candidate.startsWith('\\\\')), 'LocalAppData 为空不得产出空根拼接候选')
|
|
82
82
|
assert.ok(!candidates.some((candidate) => candidate.startsWith('undefined')))
|
|
83
83
|
assert.ok(candidates.includes('C:\\PF\\Git\\bin\\bash.exe'))
|
|
@@ -85,14 +85,14 @@ test('LocalAppData 缺省:LAD 锚位跳过,其余候选不受影响', () => {
|
|
|
85
85
|
})
|
|
86
86
|
|
|
87
87
|
test('bash 候选永不含 msys2.exe 启动器(管道下静默失败)', () => {
|
|
88
|
-
for (const candidate of candidatePaths('bash', ENV)) {
|
|
88
|
+
for (const candidate of candidatePaths('bash', ENV, 'win32')) {
|
|
89
89
|
assert.ok(!candidate.toLowerCase().includes('msys2.exe'), candidate)
|
|
90
90
|
}
|
|
91
91
|
})
|
|
92
92
|
|
|
93
93
|
test('cmd/wsl 候选:仅 System32 单点', () => {
|
|
94
|
-
assert.deepEqual(candidatePaths('cmd', ENV), ['C:\\WINDOWS\\System32\\cmd.exe'])
|
|
95
|
-
assert.deepEqual(candidatePaths('wsl', ENV), ['C:\\WINDOWS\\System32\\wsl.exe'])
|
|
94
|
+
assert.deepEqual(candidatePaths('cmd', ENV, 'win32'), ['C:\\WINDOWS\\System32\\cmd.exe'])
|
|
95
|
+
assert.deepEqual(candidatePaths('wsl', ENV, 'win32'), ['C:\\WINDOWS\\System32\\wsl.exe'])
|
|
96
96
|
})
|
|
97
97
|
|
|
98
98
|
test('显式路径原样使用,不探测', () => {
|
|
@@ -104,12 +104,12 @@ test('自动解析命中首个存在候选', () => {
|
|
|
104
104
|
const resolved = resolveEntryPath({ kind: 'bash', path: '' }, stubExists([
|
|
105
105
|
'C:\\one\\bash.exe',
|
|
106
106
|
'C:\\PF\\Git\\bin\\bash.exe',
|
|
107
|
-
]), ENV)
|
|
107
|
+
]), ENV, 'win32')
|
|
108
108
|
assert.equal(resolved, 'C:\\PF\\Git\\bin\\bash.exe')
|
|
109
109
|
})
|
|
110
110
|
|
|
111
111
|
test('无候选存在返回 undefined', () => {
|
|
112
|
-
assert.equal(resolveEntryPath({ kind: 'pwsh', path: '' }, stubExists([]), ENV), undefined)
|
|
112
|
+
assert.equal(resolveEntryPath({ kind: 'pwsh', path: '' }, stubExists([]), ENV, 'win32'), undefined)
|
|
113
113
|
})
|
|
114
114
|
|
|
115
115
|
test('detect 返回全部命中并去重', () => {
|
|
@@ -117,7 +117,7 @@ test('detect 返回全部命中并去重', () => {
|
|
|
117
117
|
'C:\\PF\\Git\\bin\\bash.exe',
|
|
118
118
|
'C:\\three\\bash.exe',
|
|
119
119
|
'C:\\WINDOWS\\System32\\cmd.exe',
|
|
120
|
-
]))
|
|
120
|
+
]), 'win32')
|
|
121
121
|
assert.deepEqual(found, [
|
|
122
122
|
{ kind: 'bash', path: 'C:\\PF\\Git\\bin\\bash.exe' },
|
|
123
123
|
{ kind: 'bash', path: 'C:\\three\\bash.exe' },
|
|
@@ -126,6 +126,36 @@ test('detect 返回全部命中并去重', () => {
|
|
|
126
126
|
})
|
|
127
127
|
|
|
128
128
|
test('detect 同路径跨 kind 不重复收录', () => {
|
|
129
|
-
const found = detectCandidates(['pwsh', 'bash'], ENV, stubExists(['C:\\one\\bash.exe', 'C:\\one\\pwsh.exe']))
|
|
129
|
+
const found = detectCandidates(['pwsh', 'bash'], ENV, stubExists(['C:\\one\\bash.exe', 'C:\\one\\pwsh.exe']), 'win32')
|
|
130
130
|
assert.equal(found.length, 2)
|
|
131
131
|
})
|
|
132
|
+
|
|
133
|
+
// POSIX 形态:冒号 PATH、裸可执行名、pwsh 固定锚点先于 PATH
|
|
134
|
+
const POSIX_ENV = { PATH: '/usr/local/bin:/opt/tools', SystemRoot: undefined }
|
|
135
|
+
|
|
136
|
+
test('POSIX pwsh 候选:固定锚点 → PATH 冒号各项(裸名)', () => {
|
|
137
|
+
const candidates = candidatePaths('pwsh', POSIX_ENV, 'linux')
|
|
138
|
+
assert.deepEqual(candidates, [
|
|
139
|
+
'/usr/bin/pwsh',
|
|
140
|
+
'/usr/local/bin/pwsh',
|
|
141
|
+
'/opt/microsoft/powershell/7/pwsh',
|
|
142
|
+
'/opt/homebrew/bin/pwsh',
|
|
143
|
+
'/usr/local/bin/pwsh',
|
|
144
|
+
'/opt/tools/pwsh',
|
|
145
|
+
])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('POSIX System32/cmd/wsl 锚位缺席', () => {
|
|
149
|
+
assert.deepEqual(candidatePaths('cmd', POSIX_ENV, 'linux'), [])
|
|
150
|
+
assert.deepEqual(candidatePaths('wsl', POSIX_ENV, 'linux'), [])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('POSIX bash 候选:PATH 冒号各项(裸名,无 win32 锚位)', () => {
|
|
154
|
+
const candidates = candidatePaths('bash', POSIX_ENV, 'linux')
|
|
155
|
+
assert.deepEqual(candidates, ['/usr/local/bin/bash', '/opt/tools/bash'])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('detect 平台参数化:同集合按平台取候选形态', () => {
|
|
159
|
+
const found = detectCandidates(['pwsh'], POSIX_ENV, stubExists(['/opt/tools/pwsh']), 'linux')
|
|
160
|
+
assert.deepEqual(found, [{ kind: 'pwsh', path: '/opt/tools/pwsh' }])
|
|
161
|
+
})
|
package/test/web-routes.test.mjs
CHANGED
|
@@ -118,14 +118,27 @@ test('webServer 缺席:路由跳过不抛(带告警,headless 合法形态)', ()
|
|
|
118
118
|
assert.match(registered.warnings.join('\n'), /webServer/)
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
-
test('webServer
|
|
121
|
+
test('webServer 在场:四条 exact 路由注册', () => {
|
|
122
122
|
const { ctx, webServer } = stubCtx()
|
|
123
123
|
webRoutes.apply(ctx)
|
|
124
124
|
const paths = webServer.state.registered.map((item) => item.path).sort()
|
|
125
|
-
assert.deepEqual(paths, ['/api/shell-select/config', '/api/shell-select/detect', '/api/shell-select/probe'])
|
|
125
|
+
assert.deepEqual(paths, ['/api/shell-select/config', '/api/shell-select/detect', '/api/shell-select/pathmap', '/api/shell-select/probe'])
|
|
126
126
|
assert.ok(webServer.state.registered.every((item) => item.kind === 'exact'))
|
|
127
127
|
})
|
|
128
128
|
|
|
129
|
+
test('GET /pathmap:返回宿主平台事实(platform/home/tmpDir)', async () => {
|
|
130
|
+
const { ctx } = stubCtx()
|
|
131
|
+
webRoutes.apply(ctx)
|
|
132
|
+
const handler = ctx.webServer.state.handlers.get('/api/shell-select/pathmap')
|
|
133
|
+
const res = fakeRes()
|
|
134
|
+
await handler(fakeReq('GET'), res)
|
|
135
|
+
assert.equal(res.state.status, 200)
|
|
136
|
+
const payload = JSON.parse(res.state.body)
|
|
137
|
+
assert.equal(payload.platform, process.platform)
|
|
138
|
+
assert.equal(typeof payload.home, 'string')
|
|
139
|
+
assert.equal(typeof payload.tmpDir, 'string')
|
|
140
|
+
})
|
|
141
|
+
|
|
129
142
|
test('GET /config:返回 describe 值 + resolved 投影(路径已解析)', async () => {
|
|
130
143
|
const { ctx } = stubCtx()
|
|
131
144
|
webRoutes.apply(ctx)
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
// KINDS 漂移守卫:client.js 工具形态清单与 host config.mjs 同源(竞品
|
|
2
|
-
// bash-terminal-ts 的 drift 守卫同构:加形态忘改文案即测试失败)。
|
|
3
|
-
// BDD 场景见 docs/feat-shell-select-optim/plan.md S14。
|
|
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 { KINDS } from '../src/config.mjs'
|
|
11
|
-
|
|
12
|
-
const here = dirname(fileURLToPath(import.meta.url))
|
|
13
|
-
const clientSource = readFileSync(join(here, '..', 'src', 'client.js'), 'utf8')
|
|
14
|
-
|
|
15
|
-
test('S14 client KINDS 字面与 host KINDS 同源', () => {
|
|
16
|
-
const match = clientSource.match(/const KINDS = \[([^\]]*)\]/)
|
|
17
|
-
assert.ok(match, 'client.js 缺少 KINDS 声明')
|
|
18
|
-
const declared = match[1].split(',').map((item) => item.trim().replace(/^'|'$/g, '')).filter((item) => item.length > 0)
|
|
19
|
-
assert.deepEqual(declared, [...KINDS])
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
test('S14b client KIND_LABELS 覆盖全部形态', () => {
|
|
23
|
-
for (const kind of KINDS) {
|
|
24
|
-
const pattern = new RegExp(kind + ":")
|
|
25
|
-
assert.match(clientSource, pattern, 'KIND_LABELS 缺少形态 ' + kind)
|
|
26
|
-
}
|
|
27
|
-
})
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
// 开关样式守卫 BDD:裸 checkbox 直接呈现违反仓库 client 规约(DEVELOPMENT/plugin-client-conventions.md),
|
|
2
|
-
// 静态断言锁死 sls-switch 结构(参照 mce-switch/tn-switch/cb-switch 同款守卫)。
|
|
3
|
-
import { readFileSync } from 'node:fs'
|
|
4
|
-
import { dirname, join } from 'node:path'
|
|
5
|
-
import { fileURLToPath } from 'node:url'
|
|
6
|
-
import test from 'node:test'
|
|
7
|
-
import assert from 'node:assert/strict'
|
|
8
|
-
|
|
9
|
-
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
10
|
-
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
11
|
-
|
|
12
|
-
test('switch 隐藏规则以 input[type="checkbox"] 精确匹配', () => {
|
|
13
|
-
assert.match(source, /\.sls-switch input\[type="checkbox"\] \{ position:absolute/)
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
test('switch 状态选择器禁止裸 input 锚定(防误伤同 label 的文本输入)', () => {
|
|
17
|
-
const bare = source.match(/\.sls-switch input:(?!\[type)[a-z-]+/g)
|
|
18
|
-
assert.equal(bare, null, `裸 input 状态选择器: ${bare}`)
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
test('checkbox 仅允许出现在 switchToggle 工厂内,禁止裸 checkbox 直出', () => {
|
|
22
|
-
const occurrences = [...source.matchAll(/type: 'checkbox'/g)].map((match) => match.index)
|
|
23
|
-
assert.equal(occurrences.length, 1, `checkbox 字面量出现 ${occurrences.length} 次`)
|
|
24
|
-
const factoryStart = source.indexOf('function switchToggle')
|
|
25
|
-
const factoryEnd = source.indexOf('}', source.indexOf('__thumb', factoryStart))
|
|
26
|
-
assert.ok(occurrences[0] > factoryStart && occurrences[0] < factoryEnd, 'checkbox 字面量不在 switchToggle 工厂内')
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
test('switchToggle 产出顺序为 input 在前 track 在后', () => {
|
|
30
|
-
const factory = source.slice(source.indexOf('function switchToggle'))
|
|
31
|
-
assert.ok(factory.indexOf("h('input'") < factory.indexOf('sls-switch__track'), '工厂内 input 必须先于 track')
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
test('checked/focus-visible 两态均以 checkbox 锚定', () => {
|
|
35
|
-
assert.match(source, /\.sls-switch input\[type="checkbox"\]:checked \+/)
|
|
36
|
-
assert.match(source, /\.sls-switch input\[type="checkbox"\]:focus-visible \+/)
|
|
37
|
-
})
|