@mzzsfy/dsh-shell-select 0.1.0

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,363 @@
1
+ // executor 集成(桩 ctx):BDD 场景见 docs/progress/shell-select-plan.md「executor」。
2
+ // 桩 subprocess 用立即可收的假输出;桩 tools/systemPrompt/settings/webServer 记录注册。
3
+
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import ShellSelectExecutor, { assertServiceableConfig } from '../src/executor.mjs'
7
+ import { Config } from '../src/config.mjs'
8
+
9
+ // --- 桩具 ---
10
+
11
+ function stubReader(text) {
12
+ return { readFrom: () => ({ text, lossy: false, nextOffset: text.length }) }
13
+ }
14
+
15
+ function stubSpawn(stdoutText = '', stderrText = '', exitCode = 0) {
16
+ const calls = []
17
+ const spawn = (spec) => {
18
+ calls.push(spec)
19
+ return {
20
+ collected: {
21
+ stdout: stubReader(stdoutText),
22
+ stderr: stubReader(stderrText),
23
+ },
24
+ done: Promise.resolve({ exitCode, signal: null }),
25
+ terminate: () => true,
26
+ }
27
+ }
28
+ return { spawn, calls }
29
+ }
30
+
31
+ function stubCtx({ withSettings = true, sandboxMode = 'danger-full-access', spawn = stubSpawn() } = {}) {
32
+ const registered = { tools: [], sections: [], promptSections: [], routes: [] }
33
+ const settingsImpl = {
34
+ installSection: (ctx, ns, schema, base, hooks) => {
35
+ registered.sections.push({ ns, schema, base, hooks })
36
+ settingsImpl._source = () => Config(base ?? {})
37
+ },
38
+ update: (ns, patch) => {
39
+ settingsImpl._patch = { ns, patch }
40
+ registered.sections[0].hooks.onChange()
41
+ },
42
+ }
43
+ const disposers = []
44
+ const ctx = {
45
+ // cordis Service 基类构造期要求 reflect.provide(登记服务实例),桩空转
46
+ reflect: { provide: () => {} },
47
+ logger: { warn: (msg) => registered.warnings?.push(msg) },
48
+ get(service) {
49
+ if (service === 'webServer') {
50
+ return { register: (item) => registered.routes.push(item.path) }
51
+ }
52
+ return undefined
53
+ },
54
+ subprocess: spawn,
55
+ sandbox: {
56
+ confine: (argv, policy) => ({
57
+ argv: ['WRAPPED', ...argv],
58
+ enforcement: 'full',
59
+ denialSignatures: ['file access denied'],
60
+ runnerFailureRules: [{ fatalSignatures: ['runner died'] }],
61
+ _policy: policy,
62
+ }),
63
+ },
64
+ sandboxPolicy: {
65
+ defaultMode: sandboxMode,
66
+ resolve: () => ({ mode: sandboxMode, roots: [] }),
67
+ },
68
+ tools: {
69
+ register: (definition) => {
70
+ registered.tools.push(definition)
71
+ return () => {
72
+ registered.tools.pop()
73
+ }
74
+ },
75
+ },
76
+ systemPrompt: {
77
+ section: (section) => registered.promptSections.push(section),
78
+ getSectionOrder: (key) => `order:${key}`,
79
+ },
80
+ shellEnv: { collect: () => ({ DSH_TEST: '1' }) },
81
+ settings: withSettings ? settingsImpl : undefined,
82
+ effect: (fn) => {
83
+ const disposer = fn()
84
+ disposers.push(disposer)
85
+ return disposer
86
+ },
87
+ }
88
+ return { ctx, registered, disposers }
89
+ }
90
+
91
+ function build() {
92
+ const { ctx, registered } = stubCtx()
93
+ const executor = new ShellSelectExecutor(ctx, {})
94
+ return { executor, registered }
95
+ }
96
+
97
+ // --- 断言 ---
98
+
99
+ test('构造:注册 shell 工具、systemPrompt 段、shell-select 设置节、三条路由(config 双方法单注册)', () => {
100
+ const { registered } = build()
101
+ assert.equal(registered.tools.length, 1)
102
+ assert.equal(registered.tools[0].name, 'shell')
103
+ assert.match(registered.tools[0].description, /git-bash/)
104
+ assert.equal(registered.promptSections.length, 1)
105
+ assert.equal(registered.sections[0].ns, 'shell-select')
106
+ // exact 路由 path 为唯一键:config/probe/detect 三条,GET/POST 分流在 handler 内
107
+ const routePaths = registered.routes.filter((path) => path.startsWith('/api/shell-select/'))
108
+ assert.equal(routePaths.length, 3)
109
+ assert.equal(new Set(routePaths).size, 3)
110
+ })
111
+
112
+ test('settings 缺席:降级不 installSection,工具照常注册', () => {
113
+ const { ctx, registered } = stubCtx({ withSettings: false })
114
+ new ShellSelectExecutor(ctx, {})
115
+ assert.equal(registered.sections.length, 0)
116
+ assert.equal(registered.tools.length, 1)
117
+ })
118
+
119
+ test('listShells:出厂条目自动解析为真实路径(pwsh/cmd 必在本机)', () => {
120
+ const { executor } = build()
121
+ const listing = executor.listShells()
122
+ assert.equal(listing.default, 'pwsh')
123
+ const pwsh = listing.shells.find((entry) => entry.id === 'pwsh')
124
+ assert.equal(pwsh.available, true)
125
+ assert.match(pwsh.path, /pwsh\.exe$|powershell\.exe$/)
126
+ })
127
+
128
+ test('entryFor:缺省落 default;未知 id 报可用清单;坏路径条目报配置指引', () => {
129
+ const { executor } = build()
130
+ assert.equal(executor.entryFor(undefined).id, 'pwsh')
131
+ assert.throws(() => executor.entryFor('nope'), /nope/)
132
+ const ctx = stubCtx()
133
+ const broken = new ShellSelectExecutor(ctx.ctx, {
134
+ shells: [{ id: 'gone', name: 'G', kind: 'bash', path: 'Z:\\missing\\bash.exe' }],
135
+ default: 'gone',
136
+ })
137
+ assert.throws(() => broken.entryFor(undefined), /gone.*explicit path|explicit path.*gone/s)
138
+ })
139
+
140
+ test('resolve:预算字段生效,策略缺省落部署策略', () => {
141
+ const { executor } = build()
142
+ const spec = executor.resolve({ command: 'x', timeoutMs: 9999999 })
143
+ assert.equal(spec.timeoutMs, 600000)
144
+ assert.equal(spec.sandboxPolicy.mode, 'danger-full-access')
145
+ assert.equal(spec.workdir, process.cwd())
146
+ })
147
+
148
+ test('runFor danger 直跑:argv 按条目组装,不经 confine,stdout/stderr 回传', async () => {
149
+ const spawn = stubSpawn('out-text', 'err-text')
150
+ const { ctx } = stubCtx({ spawn })
151
+ const executor = new ShellSelectExecutor(ctx, {})
152
+ const entry = executor.entryFor('git-bash')
153
+ const result = await executor.runFor(entry, executor.resolve({ command: 'ls -la', workdir: process.cwd() }))
154
+ assert.equal(spawn.calls.length, 1)
155
+ assert.deepEqual(spawn.calls[0].argv, [entry.path, '-c', 'ls -la'])
156
+ assert.equal(result.stdout.text, 'out-text')
157
+ assert.equal(result.stderr.text, 'err-text')
158
+ assert.deepEqual(result.sandbox, { mode: 'danger-full-access', denied: false })
159
+ })
160
+
161
+ test('deny 拦截:runFor 命中 deny 抛 DenyError 不 spawn', async () => {
162
+ const spawn = stubSpawn('ok', '')
163
+ const { ctx } = stubCtx({ spawn })
164
+ const executor = new ShellSelectExecutor(ctx, { deny: ['format '] })
165
+ const entry = executor.entryFor('git-bash')
166
+ await assert.rejects(
167
+ () => executor.runFor(entry, executor.resolve({ command: 'format c: /q', workdir: process.cwd() })),
168
+ (error) => error.code === 'SHELL_COMMAND_BLOCKED',
169
+ )
170
+ await executor.runFor(entry, executor.resolve({ command: 'git status', workdir: process.cwd() }))
171
+ assert.equal(spawn.calls.length, 1)
172
+ })
173
+
174
+ test('deny 拦截:startFor 同样拒绝(后台入口)', () => {
175
+ const spawn = stubSpawn('', '', 0)
176
+ const { ctx } = stubCtx({ spawn })
177
+ const executor = new ShellSelectExecutor(ctx, { deny: ['shutdown'] })
178
+ const entry = executor.entryFor('cmd')
179
+ assert.throws(
180
+ () => executor.startFor(entry, executor.resolve({ command: 'shutdown /r', workdir: process.cwd() })),
181
+ (error) => error.code === 'SHELL_COMMAND_BLOCKED',
182
+ )
183
+ assert.equal(spawn.calls.length, 0)
184
+ })
185
+
186
+ test('runFor 受限模式:经 confine 包装 argv,结果分类', async () => {
187
+ const spawn = stubSpawn('', 'some file access denied happened', 1)
188
+ const { ctx } = stubCtx({ spawn, sandboxMode: 'read-only' })
189
+ const executor = new ShellSelectExecutor(ctx, {})
190
+ const entry = executor.entryFor('cmd')
191
+ const result = await executor.runFor(entry, executor.resolve({ command: 'dir', workdir: process.cwd() }))
192
+ assert.equal(spawn.calls[0].argv[0], 'WRAPPED')
193
+ assert.deepEqual(spawn.calls[0].argv.slice(1, 6), [entry.path, '/d', '/s', '/c', 'dir'])
194
+ assert.equal(result.sandbox.denied, true)
195
+ assert.equal(result.sandbox.mode, 'read-only')
196
+ assert.equal(result.sandbox.enforcement, 'full')
197
+ })
198
+
199
+ test('runFor pwsh 条目:非交互形 + 编码前缀进 argv', async () => {
200
+ const spawn = stubSpawn()
201
+ const { ctx } = stubCtx({ spawn })
202
+ const executor = new ShellSelectExecutor(ctx, {})
203
+ const entry = executor.entryFor('pwsh')
204
+ await executor.runFor(entry, executor.resolve({ command: 'Get-Date', workdir: process.cwd() }))
205
+ const argv = spawn.calls[0].argv
206
+ assert.equal(argv[0], entry.path)
207
+ assert.deepEqual(argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
208
+ assert.match(argv[5], /;Get-Date$|; Get-Date$|Get-Date$/)
209
+ assert.match(argv[5], /OutputEncoding/)
210
+ })
211
+
212
+ test('settings.update 触发 onChange:工具重注册且描述更新', () => {
213
+ const { executor, registered } = stubCtxAndBuild()
214
+ const before = registered.tools.length
215
+ assert.ok(before >= 1)
216
+ registered.sections[0].hooks.onChange()
217
+ assert.ok(registered.tools.length >= 1)
218
+ })
219
+
220
+ test('updateConfig:await settings.update 后读数即新值(setSource 接线)', async () => {
221
+ const { ctx, registered } = stubCtx()
222
+ // 桩 settings:installSection 后 setSource 接 scope;replace wholesale 后 setSource 到新值
223
+ const scope = { value: Config({}) }
224
+ registered.sectionsHook = null
225
+ ctx.settings.installSection = (ctx2, ns, schema, base, hooks) => {
226
+ registered.sections.push({ ns, schema, base, hooks })
227
+ scope.value = Config(base ?? {})
228
+ hooks.setSource(() => scope.value)
229
+ registered.sectionsHook = hooks
230
+ }
231
+ ctx.settings.replace = async (ns, section) => {
232
+ scope.value = Config(section)
233
+ registered.sectionsHook.onChange()
234
+ }
235
+ const executor = new ShellSelectExecutor(ctx, {})
236
+ assert.equal(executor.config.default, 'pwsh')
237
+ await executor.updateConfig({ default: 'git-bash' })
238
+ assert.equal(executor.config.default, 'git-bash')
239
+ assert.equal(executor.listShells().default, 'git-bash')
240
+ })
241
+
242
+ test('updateConfig:部分补丁归并当前节后 replace(单改 default 不丢 shells)', async () => {
243
+ const { ctx, registered } = stubCtx()
244
+ const written = []
245
+ ctx.settings.replace = async (ns, section) => {
246
+ written.push(section)
247
+ }
248
+ const executor = new ShellSelectExecutor(ctx, {})
249
+ await executor.updateConfig({ default: 'git-bash' })
250
+ assert.equal(written[0].shells.length, 3)
251
+ assert.equal(written[0].default, 'git-bash')
252
+ })
253
+
254
+ test('api POST config:await updateConfig,resolved 即新清单(async 修复)', async () => {
255
+ const { ctx, registered } = stubCtx()
256
+ ctx.get = (service) => {
257
+ if (service === 'webServer') {
258
+ const routes = []
259
+ ctx.__routes = routes
260
+ return { register: (item) => routes.push(item) }
261
+ }
262
+ return undefined
263
+ }
264
+ ctx.settings.replace = async (ns, section) => {
265
+ registered.sections[0].hooks.setSource(() => section)
266
+ registered.sections[0].hooks.onChange()
267
+ }
268
+ const executor = new ShellSelectExecutor(ctx, {})
269
+ const handler = ctx.__routes.find((r) => r.path === '/api/shell-select/config').handler
270
+ let captured = null
271
+ const fakeRes = {
272
+ writeHead: () => {},
273
+ end: (body) => {
274
+ captured = JSON.parse(body)
275
+ },
276
+ }
277
+ const body = JSON.stringify({ default: 'git-bash', shells: executor.config.shells })
278
+ const fakeReq = {
279
+ method: 'POST',
280
+ headers: { origin: 'http://127.0.0.1:9191', host: '127.0.0.1:9191' },
281
+ on(event, cb) {
282
+ if (event === 'data') setImmediate(() => cb(Buffer.from(body)))
283
+ if (event === 'end') setImmediate(cb)
284
+ },
285
+ destroy: () => {},
286
+ }
287
+ await handler(fakeReq, fakeRes)
288
+ assert.equal(captured.ok, true)
289
+ assert.equal(captured.resolved.default, 'git-bash')
290
+ assert.equal(captured.resolved.shells.length, 3)
291
+ })
292
+
293
+ test('updateConfig:入参 path 反斜杠归一(防 yaml 无引号落盘字面翻倍)', async () => {
294
+ const { ctx, registered } = stubCtx()
295
+ const written = []
296
+ ctx.settings.replace = async (ns, section) => {
297
+ written.push(section)
298
+ }
299
+ const executor = new ShellSelectExecutor(ctx, {})
300
+ await executor.updateConfig({
301
+ shells: [{ id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: 'C:\\\\WINDOWS\\\\System32\\\\powershell.exe' }],
302
+ })
303
+ assert.equal(written[0].shells[0].path, 'C:\\WINDOWS\\System32\\powershell.exe')
304
+ })
305
+
306
+ test('entryFor:双反斜杠污染路径被归一后仍可用', () => {
307
+ const { executor } = stubCtxAndBuild()
308
+ const ctx2 = stubCtx()
309
+ const polluted = new ShellSelectExecutor(ctx2.ctx, {
310
+ shells: [{ id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: 'C:\\\\WINDOWS\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\powershell.exe' }],
311
+ default: 'pwsh',
312
+ })
313
+ const entry = polluted.entryFor(undefined)
314
+ assert.equal(entry.path, 'C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe')
315
+ })
316
+
317
+ test('entryFor:login 布尔透传,argv 产出 -lc(配置→执行全链)', async () => {
318
+ const ctx2 = stubCtx()
319
+ const executor = new ShellSelectExecutor(ctx2.ctx, {
320
+ shells: [
321
+ { id: 'm', name: 'MSYS2', kind: 'bash', path: 'C:\\Program Files\\Git\\bin\\bash.exe', login: true },
322
+ { id: 'g', name: 'Git Bash', kind: 'bash', path: 'C:\\Program Files\\Git\\bin\\bash.exe' },
323
+ ],
324
+ default: 'm',
325
+ })
326
+ const loginEntry = executor.entryFor('m')
327
+ assert.equal(loginEntry.login, true)
328
+ assert.deepEqual(executor.argvFor(loginEntry, { command: 'uname -a' }).slice(0, 2), ['C:\\Program Files\\Git\\bin\\bash.exe', '-lc'])
329
+ const plainEntry = executor.entryFor('g')
330
+ assert.equal(plainEntry.login, false)
331
+ assert.deepEqual(executor.argvFor(plainEntry, { command: 'uname -a' }).slice(0, 2), ['C:\\Program Files\\Git\\bin\\bash.exe', '-c'])
332
+ })
333
+
334
+ function stubCtxAndBuild() {
335
+ const { ctx, registered } = stubCtx()
336
+ const executor = new ShellSelectExecutor(ctx, {})
337
+ return { executor, registered }
338
+ }
339
+
340
+ test('startFor danger:返回进程句柄,readOutput 增量与 done 定局', async () => {
341
+ const spawn = stubSpawn('bg-out', '')
342
+ const { ctx } = stubCtx({ spawn })
343
+ const executor = new ShellSelectExecutor(ctx, {})
344
+ const proc = executor.startFor(executor.entryFor('pwsh'), executor.resolve({ command: 'sleep 1', workdir: process.cwd() }))
345
+ assert.equal(proc.status, 'running')
346
+ const first = proc.readOutput()
347
+ assert.match(first.delta, /bg-out/)
348
+ await proc.done
349
+ assert.equal(proc.status, 'completed')
350
+ assert.equal(proc.exitCode, 0)
351
+ })
352
+
353
+ test('assertServiceableConfig:空 shells/default 不在列/重复 id 全拒', () => {
354
+ assert.throws(() => assertServiceableConfig(Config({ shells: [], default: 'x' })), /shells/)
355
+ assert.throws(() => assertServiceableConfig(Config({ shells: [{ id: 'a', name: 'A', kind: 'bash', path: '' }], default: 'b' })), /default/)
356
+ assert.throws(() => assertServiceableConfig(Config({
357
+ shells: [
358
+ { id: 'a', name: 'A', kind: 'bash', path: '' },
359
+ { id: 'a', name: 'A2', kind: 'cmd', path: '' },
360
+ ],
361
+ default: 'a',
362
+ })), /duplicate/)
363
+ })
@@ -0,0 +1,157 @@
1
+ // guard 入口集成(桩 ctx):安装 → 死态代挂两个官方行 → 复活卸载 → 官方包缺失停用。
2
+
3
+ import { test } from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { installGuard } from '../src/guard.js'
6
+ import { MAIN_ROW_ID, OFFICIAL_ROW_IDS } from '../src/guard-state.mjs'
7
+ import { shellSelectApplyState } from '../src/apply-state.mjs'
8
+
9
+ function row({ id, disabled = false, running = false }) {
10
+ return {
11
+ options: { id },
12
+ get disabled() {
13
+ return disabled
14
+ },
15
+ fiber: running ? { uid: 'f' } : undefined,
16
+ }
17
+ }
18
+
19
+ function stubCtx({ rows, importOfficial }) {
20
+ const log = []
21
+ const mounts = []
22
+ const listeners = new Map()
23
+ const ctx = {
24
+ logger: {
25
+ warn: (msg) => log.push(['warn', msg]),
26
+ error: (msg) => log.push(['error', msg]),
27
+ info: (msg) => log.push(['info', msg]),
28
+ },
29
+ loader: {
30
+ resolve(id) {
31
+ const found = rows.find((candidate) => candidate.options.id === id)
32
+ if (found) return found
33
+ throw new Error('not found')
34
+ },
35
+ },
36
+ on(event, handler) {
37
+ listeners.set(event, handler)
38
+ },
39
+ plugin(module, config) {
40
+ mounts.push({ moduleName: module.__name, config })
41
+ return Promise.resolve({
42
+ dispose: () => {
43
+ mounts.length = 0
44
+ return Promise.resolve()
45
+ },
46
+ })
47
+ },
48
+ }
49
+ return { ctx, log, mounts, listeners }
50
+ }
51
+
52
+ const OFFICIAL_MODULE = (name) => ({ __name: name, name, apply() {} })
53
+
54
+ function makeImport() {
55
+ return async (packageName) => OFFICIAL_MODULE(packageName)
56
+ }
57
+
58
+ function deadWorld() {
59
+ return [
60
+ row({ id: MAIN_ROW_ID, disabled: true }),
61
+ row({ id: 'tool-pwsh', disabled: true }),
62
+ row({ id: 'pwsh-sandbox', disabled: true }),
63
+ ]
64
+ }
65
+
66
+ test('死态安装:代挂两个官方行(pwsh-sandbox 配置读空文档)', async () => {
67
+ const { ctx, mounts, log } = stubCtx({ rows: deadWorld(), importOfficial: makeImport() })
68
+ await installGuard(ctx, {
69
+ importOfficial: makeImport(),
70
+ delay: async () => {},
71
+ sweepIntervalMs: 10 * 3600 * 1000,
72
+ })
73
+ assert.equal(mounts.length, 2)
74
+ assert.deepEqual(mounts.map((mount) => mount.moduleName), ['@deepseek-ai/dsh-tool-pwsh', '@deepseek-ai/dsh-pwsh-sandbox'])
75
+ assert.deepEqual(mounts[0].config, {})
76
+ assert.deepEqual(mounts[1].config, {})
77
+ assert.ok(log.some(([level, msg]) => level === 'warn' && msg.includes('代挂官方')))
78
+ })
79
+
80
+ test('非死态安装:不代挂', async () => {
81
+ const rows = [
82
+ row({ id: MAIN_ROW_ID }),
83
+ row({ id: 'tool-pwsh', disabled: true }),
84
+ row({ id: 'pwsh-sandbox', disabled: true }),
85
+ ]
86
+ const { ctx, mounts } = stubCtx({ rows })
87
+ await installGuard(ctx, { importOfficial: makeImport(), delay: async () => {}, sweepIntervalMs: 3600 * 1000 })
88
+ assert.equal(mounts.length, 0)
89
+ })
90
+
91
+ test('官方包加载失败:告警并停用自愈,不抛错不拖垮树', async () => {
92
+ const { ctx, mounts, log } = stubCtx({ rows: deadWorld() })
93
+ await installGuard(ctx, {
94
+ importOfficial: async () => {
95
+ throw new Error('module not found')
96
+ },
97
+ delay: async () => {},
98
+ })
99
+ assert.equal(mounts.length, 0)
100
+ assert.ok(log.some(([level, msg]) => level === 'warn' && msg.includes('停用')))
101
+ })
102
+
103
+ test('复活边沿:受控行即将 init 时卸代挂再放行', async () => {
104
+ const rows = deadWorld()
105
+ const { ctx, mounts, listeners } = stubCtx({ rows })
106
+ await installGuard(ctx, { importOfficial: makeImport(), delay: async () => {}, sweepIntervalMs: 3600 * 1000 })
107
+ assert.equal(mounts.length, 2)
108
+ const patchContext = listeners.get('loader/patch-context')
109
+ assert.ok(typeof patchContext === 'function')
110
+ // 复活行:主行 fiber 空(即将 init)→ guard 卸代挂后放行
111
+ await patchContext(row({ id: MAIN_ROW_ID }), () => 'passed')
112
+ assert.equal(mounts.length, 0)
113
+ })
114
+
115
+ test('运行主行使复活行跳过让位逻辑(事件快速返回)', async () => {
116
+ const rows = deadWorld()
117
+ const { ctx, mounts, listeners } = stubCtx({ rows })
118
+ await installGuard(ctx, { importOfficial: makeImport(), delay: async () => {}, sweepIntervalMs: 3600 * 1000 })
119
+ const patchContext = listeners.get('loader/patch-context')
120
+ const runningEntry = row({ id: MAIN_ROW_ID, running: true })
121
+ await patchContext(runningEntry, () => 'passed')
122
+ assert.equal(mounts.length, 2)
123
+ })
124
+
125
+ test('apply 旗标:guard 引用的旗标模块可用', () => {
126
+ assert.equal(shellSelectApplyState(), 'pending')
127
+ })
128
+
129
+ test('模块归一:apply 型模块直挂,default 类模块挂类(tool-pwsh/pwsh-sandbox 真实形态)', async () => {
130
+ const applyShaped = { __name: '@deepseek-ai/dsh-tool-pwsh', name: 'tool-pwsh', apply() {}, Config: {}, inject: ['tools'] }
131
+ const classShaped = { __name: '@deepseek-ai/dsh-pwsh-sandbox', default: class SandboxPwshExecutor {} }
132
+ const modules = { '@deepseek-ai/dsh-tool-pwsh': applyShaped, '@deepseek-ai/dsh-pwsh-sandbox': classShaped }
133
+ const mounted = []
134
+ const { ctx } = stubCtx({ rows: deadWorld() })
135
+ ctx.plugin = (plugin, config) => {
136
+ mounted.push({ plugin, config })
137
+ return Promise.resolve({ dispose: () => Promise.resolve() })
138
+ }
139
+ await installGuard(ctx, {
140
+ importOfficial: async (packageName) => modules[packageName],
141
+ delay: async () => {},
142
+ sweepIntervalMs: 3600 * 1000,
143
+ })
144
+ assert.equal(mounted.length, 2)
145
+ assert.equal(mounted[0].plugin, applyShaped)
146
+ assert.equal(mounted[1].plugin, classShaped.default)
147
+ })
148
+
149
+ test('模块归一:两形态皆无的官方包按预载失败处理,停用自愈', async () => {
150
+ const { ctx, mounts, log } = stubCtx({ rows: deadWorld() })
151
+ await installGuard(ctx, {
152
+ importOfficial: async () => ({ __name: 'shapeless' }),
153
+ delay: async () => {},
154
+ })
155
+ assert.equal(mounts.length, 0)
156
+ assert.ok(log.some(([level, msg]) => level === 'warn' && msg.includes('停用')))
157
+ })
@@ -0,0 +1,79 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import { installGuard } from '../src/guard.js'
4
+
5
+ const MAIN_ROW_ID = 'shell-select'
6
+ const OFFICIAL_ROW_IDS = ['tool-pwsh', 'pwsh-sandbox']
7
+ const OFFICIAL_PACKAGES = {
8
+ 'tool-pwsh': '@deepseek-ai/dsh-tool-pwsh',
9
+ 'pwsh-sandbox': '@deepseek-ai/dsh-pwsh-sandbox',
10
+ }
11
+
12
+ const OFFICIAL_MODULE = (name) => ({ __name: name, name, apply() {} })
13
+
14
+ function stubLoader(rows) {
15
+ const entries = rows.map(([id, options]) => ({
16
+ id,
17
+ options,
18
+ get fiber() { return options?.running ? { uid: 'x' } : undefined },
19
+ }))
20
+ return { entries: () => entries }
21
+ }
22
+
23
+ function stubCtx({ loader, importOfficial }) {
24
+ const logs = []
25
+ return {
26
+ ctx: {
27
+ logger: { warn: (m) => logs.push(m), error: (m) => logs.push(m), info: () => {} },
28
+ loader,
29
+ get: () => undefined,
30
+ on: () => () => {},
31
+ effect: () => () => {},
32
+ },
33
+ logs,
34
+ importOfficial,
35
+ }
36
+ }
37
+
38
+ test('预载瞬时失败退避重试后成功', async () => {
39
+ let attempts = 0
40
+ const importOfficial = async (name) => {
41
+ attempts++
42
+ if (attempts <= 2) throw new Error('import 竞争超时')
43
+ return OFFICIAL_MODULE(name)
44
+ }
45
+ const loader = stubLoader([
46
+ [MAIN_ROW_ID, { id: MAIN_ROW_ID, name: '@mzzsfy/dsh-shell-select', disabled: true }],
47
+ ['tool-pwsh', { id: 'tool-pwsh', name: OFFICIAL_PACKAGES['tool-pwsh'], disabled: true }],
48
+ ['pwsh-sandbox', { id: 'pwsh-sandbox', name: OFFICIAL_PACKAGES['pwsh-sandbox'], disabled: true }],
49
+ ])
50
+ const { ctx } = stubCtx({ loader, importOfficial })
51
+ const delays = []
52
+ const result = await installGuard(ctx, {
53
+ importOfficial,
54
+ delay: async (ms) => { delays.push(ms) },
55
+ timeouts: { load: 100, mount: 100 },
56
+ sweepIntervalMs: 60 * 1000,
57
+ })
58
+ assert.equal(result, undefined)
59
+ assert.ok(attempts >= 3, `至少 3 次尝试,实际 ${attempts}`)
60
+ assert.ok(delays.length >= 2, '存在重试退避')
61
+ })
62
+
63
+ test('预载重试耗尽仍失败则停用自愈', async () => {
64
+ const importOfficial = async () => { throw new Error('持续不可用') }
65
+ const loader = stubLoader([
66
+ [MAIN_ROW_ID, { id: MAIN_ROW_ID, name: '@mzzsfy/dsh-shell-select', disabled: true }],
67
+ ['tool-pwsh', { id: 'tool-pwsh', name: OFFICIAL_PACKAGES['tool-pwsh'], disabled: true }],
68
+ ['pwsh-sandbox', { id: 'pwsh-sandbox', name: OFFICIAL_PACKAGES['pwsh-sandbox'], disabled: true }],
69
+ ])
70
+ const { ctx, logs } = stubCtx({ loader, importOfficial })
71
+ const result = await installGuard(ctx, {
72
+ importOfficial,
73
+ delay: async () => {},
74
+ timeouts: { load: 50, mount: 50 },
75
+ sweepIntervalMs: 60 * 1000,
76
+ })
77
+ assert.equal(result, undefined)
78
+ assert.ok(logs.some((m) => m.includes('死态自愈停用')))
79
+ })