@mzzsfy/dsh-shell-select 0.3.1 → 0.4.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,267 @@
1
+ // web 路由行:声明式 inject 门控 + settings 面驱动的 faces(0.1.7 形态)。
2
+ // BDD 场景:webServer 缺席不激活路由;GET 读 describe 值;POST 归一校验后 replace;
3
+ // ns 缺失读兜底出厂默认;写路径合并既有字段(单改 default 不丢 shells)。
4
+
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import * as webRoutes from '../src/web-routes.mjs'
8
+ import { SECTION_NS, buildFaces } from '../src/web-routes.mjs'
9
+ import { bindShellRefreshBridge } from '../src/executor.mjs'
10
+
11
+ const NODE_EXE = process.execPath
12
+
13
+ function describedSection(overrides = {}) {
14
+ return {
15
+ ns: SECTION_NS,
16
+ value: {
17
+ shells: [
18
+ { id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: NODE_EXE, args: [], login: false, distro: '', env: {} },
19
+ { id: 'git-bash', name: 'Git Bash', kind: 'bash', path: NODE_EXE, args: [], login: false, distro: '', env: {} },
20
+ ],
21
+ default: 'pwsh',
22
+ deny: [],
23
+ timeoutMs: 12e4,
24
+ },
25
+ ...overrides,
26
+ }
27
+ }
28
+
29
+ function stubSettings(descriptors) {
30
+ const calls = { replace: [], describe: 0 }
31
+ return {
32
+ calls,
33
+ describe: () => {
34
+ calls.describe += 1
35
+ return descriptors
36
+ },
37
+ replace: async (ns, section) => {
38
+ calls.replace.push({ ns, section })
39
+ },
40
+ }
41
+ }
42
+
43
+ function stubWebServer() {
44
+ const state = { registered: [], handlers: new Map() }
45
+ return {
46
+ state,
47
+ register(route) {
48
+ state.registered.push({ kind: route.kind, path: route.path })
49
+ state.handlers.set(route.path, route.handler)
50
+ return () => {}
51
+ },
52
+ }
53
+ }
54
+
55
+ function stubCtx({ settings = stubSettings([describedSection()]), webServer = stubWebServer() } = {}) {
56
+ const registered = { warnings: [] }
57
+ const services = { webServer, settings }
58
+ const ctx = {
59
+ logger: { warn: (msg) => registered.warnings.push(msg) },
60
+ webServer,
61
+ get(service) {
62
+ return services[service]
63
+ },
64
+ effect(fn) {
65
+ fn()
66
+ return () => {}
67
+ },
68
+ }
69
+ return {
70
+ ctx, registered, settings, webServer,
71
+ /** 测试中覆盖服务可用性(缺席 = delete services.webServer) */
72
+ services,
73
+ }
74
+ }
75
+
76
+ function fakeReq(method, body) {
77
+ const text = body === undefined ? '' : JSON.stringify(body)
78
+ return {
79
+ method,
80
+ headers: { origin: 'http://127.0.0.1:9191', host: '127.0.0.1:9191' },
81
+ on(event, cb) {
82
+ if (event === 'data') setImmediate(() => cb(Buffer.from(text)))
83
+ if (event === 'end') setImmediate(cb)
84
+ },
85
+ destroy: () => {},
86
+ }
87
+ }
88
+
89
+ function fakeRes() {
90
+ const state = { status: 0, body: '' }
91
+ return {
92
+ state,
93
+ writeHead(status) {
94
+ state.status = status
95
+ },
96
+ end(body) {
97
+ state.body = body
98
+ },
99
+ }
100
+ }
101
+
102
+ async function callConfig(ctx, method, body) {
103
+ const handler = ctx.webServer.state.handlers.get('/api/shell-select/config')
104
+ const res = fakeRes()
105
+ await handler(fakeReq(method, body), res)
106
+ return res.state
107
+ }
108
+
109
+ test('行声明:name/inject 形态(webServer+settings 点分门控)', () => {
110
+ assert.equal(webRoutes.name, 'shell-select/web')
111
+ assert.deepEqual(webRoutes.inject, ['webServer', 'settings'])
112
+ })
113
+
114
+ test('webServer 缺席:路由跳过不抛(带告警,headless 合法形态)', () => {
115
+ const { ctx, registered, services } = stubCtx()
116
+ delete services.webServer
117
+ webRoutes.apply(ctx)
118
+ assert.match(registered.warnings.join('\n'), /webServer/)
119
+ })
120
+
121
+ test('webServer 在场:三条 exact 路由注册', () => {
122
+ const { ctx, webServer } = stubCtx()
123
+ webRoutes.apply(ctx)
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'])
126
+ assert.ok(webServer.state.registered.every((item) => item.kind === 'exact'))
127
+ })
128
+
129
+ test('GET /config:返回 describe 值 + resolved 投影(路径已解析)', async () => {
130
+ const { ctx } = stubCtx()
131
+ webRoutes.apply(ctx)
132
+ const state = await callConfig(ctx, 'GET')
133
+ const payload = JSON.parse(state.body)
134
+ assert.equal(payload.default, 'pwsh')
135
+ assert.equal(payload.timeoutMs, 12e4)
136
+ assert.equal(payload.resolved.shells[0].available, true)
137
+ assert.equal(payload.resolved.shells[0].path, NODE_EXE)
138
+ })
139
+
140
+ test('GET /config:ns 缺失兜底出厂默认并告警(不崩)', async () => {
141
+ const { ctx, registered } = stubCtx({ settings: stubSettings([]) })
142
+ webRoutes.apply(ctx)
143
+ const state = await callConfig(ctx, 'GET')
144
+ const payload = JSON.parse(state.body)
145
+ assert.equal(state.status, 200)
146
+ assert.equal(payload.default, 'pwsh')
147
+ assert.ok(payload.shells.length > 0)
148
+ assert.match(registered.warnings.join('\n'), /shell-select/)
149
+ })
150
+
151
+ test('POST /config:合法清单 replace 到 SECTION_NS,响应带归一 resolved', async () => {
152
+ const { ctx, settings } = stubCtx()
153
+ webRoutes.apply(ctx)
154
+ const state = await callConfig(ctx, 'POST', {
155
+ shells: [{ id: 'git-bash', name: 'Git Bash', kind: 'bash', path: NODE_EXE, args: [], login: false, distro: '', env: {} }],
156
+ default: 'git-bash',
157
+ deny: ['^format\\s'],
158
+ })
159
+ assert.equal(state.status, 200)
160
+ const payload = JSON.parse(state.body)
161
+ assert.equal(payload.ok, true)
162
+ assert.equal(payload.resolved.default, 'git-bash')
163
+ assert.equal(settings.calls.replace.length, 1)
164
+ assert.equal(settings.calls.replace[0].ns, SECTION_NS)
165
+ assert.equal(settings.calls.replace[0].section.deny.length, 1)
166
+ })
167
+
168
+ test('POST /config:写路径合并既有字段(单改 default 保留 timeoutMs)', async () => {
169
+ const { ctx, settings } = stubCtx()
170
+ webRoutes.apply(ctx)
171
+ await callConfig(ctx, 'POST', {
172
+ shells: [{ id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: NODE_EXE }],
173
+ default: 'pwsh',
174
+ deny: [],
175
+ })
176
+ const written = settings.calls.replace[0].section
177
+ assert.equal(written.timeoutMs, 12e4)
178
+ })
179
+
180
+ test('POST /config:落盘后经 refresh 桥刷新执行面(运行中删除客户端即时生效)', async () => {
181
+ const described = describedSection()
182
+ described.value.shells.push({ id: 'cmd', name: 'CMD', kind: 'cmd', path: NODE_EXE, args: [], login: false, distro: '', env: {} })
183
+ const refreshes = []
184
+ const unbind = bindShellRefreshBridge((next) => refreshes.push(next))
185
+ try {
186
+ const { ctx, settings } = stubCtx({ settings: stubSettings([described]) })
187
+ webRoutes.apply(ctx)
188
+ const remaining = described.value.shells.filter((entry) => entry.id !== 'cmd')
189
+ const state = await callConfig(ctx, 'POST', {
190
+ shells: remaining.map(({ id, name, kind, path }) => ({ id, name, kind, path })),
191
+ default: 'git-bash',
192
+ deny: [],
193
+ })
194
+ assert.equal(state.status, 200)
195
+ assert.equal(settings.calls.replace.length, 1)
196
+ assert.equal(refreshes.length, 1)
197
+ assert.deepEqual(refreshes[0].shells.map((entry) => entry.id), ['pwsh', 'git-bash'])
198
+ } finally {
199
+ unbind()
200
+ }
201
+ })
202
+
203
+ test('POST /config:桥未绑定(未接管态)请求静默丢弃,写路径不炸', async () => {
204
+ const { ctx, settings } = stubCtx()
205
+ webRoutes.apply(ctx)
206
+ const state = await callConfig(ctx, 'POST', {
207
+ shells: [{ id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: NODE_EXE }],
208
+ default: 'pwsh',
209
+ deny: [],
210
+ })
211
+ assert.equal(state.status, 200)
212
+ assert.equal(settings.calls.replace.length, 1)
213
+ })
214
+
215
+ test('POST /config:非法负载(空清单/坏 default)400 不写', async () => {
216
+ const { ctx, settings } = stubCtx()
217
+ webRoutes.apply(ctx)
218
+ const bad1 = await callConfig(ctx, 'POST', { shells: [], default: 'pwsh', deny: [] })
219
+ assert.equal(bad1.status, 400)
220
+ const bad2 = await callConfig(ctx, 'POST', {
221
+ shells: [{ id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: NODE_EXE }],
222
+ default: 'nope',
223
+ deny: [],
224
+ })
225
+ assert.equal(bad2.status, 400)
226
+ assert.equal(settings.calls.replace.length, 0)
227
+ })
228
+
229
+ test('POST /config:非对象负载 400', async () => {
230
+ const { ctx } = stubCtx()
231
+ webRoutes.apply(ctx)
232
+ const state = await callConfig(ctx, 'POST', ['nope'])
233
+ assert.equal(state.status, 400)
234
+ })
235
+
236
+ test('删除条目全链:POST 移除 cmd → 落盘无 cmd → 执行面拒绝已删 id(出厂默认不复活)', async () => {
237
+ // 用户报告:设置页删除 cmd 后"还能用"。写路径契约:shells 整体替换,
238
+ // schema resolve 的出厂默认(含 cmd)只兜底缺节/坏形态,不得回灌合法清单。
239
+ const described = describedSection()
240
+ described.value.shells.push({ id: 'cmd', name: 'CMD', kind: 'cmd', path: NODE_EXE, args: [], login: false, distro: '', env: {} })
241
+ const { ctx, settings } = stubCtx({ settings: stubSettings([described]) })
242
+ webRoutes.apply(ctx)
243
+ const remaining = described.value.shells.filter((entry) => entry.id !== 'cmd')
244
+ const state = await callConfig(ctx, 'POST', {
245
+ shells: remaining.map(({ id, name, kind, path }) => ({ id, name, kind, path })),
246
+ default: 'git-bash',
247
+ deny: [],
248
+ })
249
+ assert.equal(state.status, 200)
250
+ const payload = JSON.parse(state.body)
251
+ assert.deepEqual(payload.resolved.shells.map((entry) => entry.id), ['pwsh', 'git-bash'])
252
+ const written = settings.calls.replace[0].section
253
+ assert.deepEqual(written.shells.map((entry) => entry.id), ['pwsh', 'git-bash'])
254
+ // 落盘形态再喂回执行面(resolve→requireEntry):已删 id 拒绝,不出厂默认回灌
255
+ const { resolveConfig } = await import('../src/config.mjs')
256
+ const resolved = resolveConfig(written)
257
+ assert.deepEqual(resolved.shells.map((entry) => entry.id), ['pwsh', 'git-bash'])
258
+ const { requireEntry } = await import('../src/config.mjs')
259
+ assert.throws(() => requireEntry(resolved.shells, 'cmd', resolved.default), /cmd/)
260
+ })
261
+
262
+ test('buildFaces:probe 真实路径布尔,detect 返回候选数组', () => {
263
+ const settings = stubSettings([describedSection()])
264
+ const faces = buildFaces({ logger: { warn: () => {} } }, settings)
265
+ assert.equal(faces.probe(NODE_EXE), true)
266
+ assert.ok(Array.isArray(faces.detect(['cmd'])))
267
+ })