dsh-api-runner 0.0.0-stage → 0.2.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,467 @@
1
+ /**
2
+ * 插件入口形态自测:用桩 ctx 模拟 cordis 宿主,验证 apply() 注册了工具、HTTP 路由、
3
+ * 提示段与清理回调,并走一遍工具 execute(含会话 cwd 解析与错误兜底)。
4
+ * 这一层不依赖 DSH 运行时,只验证契约形状。
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import fs from 'node:fs';
8
+ import http from 'node:http';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+
13
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
+ const load = (rel) => import(pathToFileURL(path.join(root, rel)).href);
15
+ const { normalizeProjectKey } = await load('src/util.js');
16
+
17
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'api-runner-plugin-'));
18
+ const homeDir = path.join(tmp, 'home');
19
+ const projectDir = path.join(tmp, 'project');
20
+ fs.mkdirSync(homeDir, { recursive: true });
21
+ fs.mkdirSync(projectDir, { recursive: true });
22
+ fs.writeFileSync(path.join(projectDir, 'app.log'), 'hello\nworld\n');
23
+
24
+ const server = http.createServer((req, res) => {
25
+ res.setHeader('Content-Type', 'application/json');
26
+ res.end(JSON.stringify({ ok: true, path: req.url }));
27
+ });
28
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
29
+ const base = `http://127.0.0.1:${server.address().port}`;
30
+
31
+ fs.writeFileSync(
32
+ path.join(homeDir, 'api-runner.yml'),
33
+ `apis:
34
+ - id: ping
35
+ method: GET
36
+ url: ${base}/ping
37
+ logs:
38
+ - id: app-log
39
+ path: ./app.log
40
+ `,
41
+ 'utf8',
42
+ );
43
+
44
+ process.env.DSH_HOME = homeDir;
45
+
46
+ const mod = await load('src/main.js');
47
+ let passed = 0;
48
+ async function ok(name, fn) {
49
+ try {
50
+ await fn();
51
+ passed += 1;
52
+ console.log(` ✓ ${name}`);
53
+ } catch (error) {
54
+ console.error(` ✗ ${name}\n ${error?.stack || error}`);
55
+ process.exitCode = 1;
56
+ }
57
+ }
58
+
59
+ // ---- 桩 ctx ----
60
+ const registered = { tools: [], routes: [], sections: [], effects: [] };
61
+ const ctx = {
62
+ logger: { info: () => {}, error: () => {} },
63
+ config: {},
64
+ sessions: { get: (id) => (id === 's1' ? { header: { cwd: projectDir } } : undefined) },
65
+ connection: { requestRejection: () => undefined },
66
+ webServer: {
67
+ register: (route) => {
68
+ registered.routes.push(route);
69
+ return () => {};
70
+ },
71
+ },
72
+ systemPrompt: {
73
+ section: (section) => {
74
+ registered.sections.push(section);
75
+ return () => {};
76
+ },
77
+ },
78
+ tools: {
79
+ register: (definition) => {
80
+ registered.tools.push(definition);
81
+ return () => {};
82
+ },
83
+ },
84
+ effect: (factory, id) => {
85
+ registered.effects.push({ id, dispose: factory() });
86
+ return () => {};
87
+ },
88
+ get: (name) => (name === 'systemPrompt' ? ctx.systemPrompt : undefined),
89
+ };
90
+
91
+ console.log('plugin shape:');
92
+ await ok('导出 inject 且没有 default export', () => {
93
+ assert.deepEqual(mod.inject, ['webServer', 'sessions', 'tools', 'connection', 'systemPrompt']);
94
+ assert.equal(mod.default, undefined);
95
+ assert.equal(typeof mod.apply, 'function');
96
+ });
97
+ await ok('apply 注册 1 个工具 / 1 条路由 / 1 个提示段 / 3 个 effect', () => {
98
+ mod.apply(ctx);
99
+ assert.equal(registered.tools.length, 1);
100
+ assert.equal(registered.routes.length, 1);
101
+ assert.equal(registered.sections.length, 1);
102
+ assert.equal(registered.effects.length, 3);
103
+ });
104
+ await ok('工具定义符合 ToolDefinition 契约', () => {
105
+ const tool = registered.tools[0];
106
+ assert.equal(tool.name, 'ApiRunner');
107
+ assert.equal(typeof tool.timeoutMs, 'number');
108
+ assert.equal(tool.parameters.type, 'object');
109
+ assert.ok(tool.parameters.properties.action.enum.includes('verify'));
110
+ assert.equal(typeof tool.output.render, 'function');
111
+ assert.equal(typeof tool.execute, 'function');
112
+ });
113
+ await ok('路由是 prefix /dsh-api-runner/api', () => {
114
+ const route = registered.routes[0];
115
+ assert.equal(route.kind, 'prefix');
116
+ assert.equal(route.path, '/dsh-api-runner/api');
117
+ });
118
+ await ok('提示段返回配置摘要且无配置时为空串', () => {
119
+ const section = registered.sections[0];
120
+ const text = section.text({});
121
+ assert.match(text, /ping/);
122
+ assert.match(text, /app-log/);
123
+ });
124
+
125
+ console.log('tool execute:');
126
+ const tool = registered.tools[0];
127
+ await ok('describe 通过会话 cwd 读到项目配置', async () => {
128
+ const result = await tool.execute({ action: 'describe' }, { agent: { session: { id: 's1' } } });
129
+ assert.equal(result.isError, false);
130
+ const payload = JSON.parse(result.text);
131
+ assert.equal(payload.apis[0].id, 'ping');
132
+ });
133
+ await ok('call 成功返回 isError=false', async () => {
134
+ const result = await tool.execute({ action: 'call', api_id: 'ping' }, { agent: { session: { id: 's1' } } });
135
+ assert.equal(result.isError, false, result.text);
136
+ assert.match(result.text, /"ok": true/);
137
+ });
138
+ await ok('readLog 默认取第一个日志', async () => {
139
+ const result = await tool.execute({ action: 'log', reset: true }, { agent: { session: { id: 's1' } } });
140
+ assert.equal(result.isError, false, result.text);
141
+ assert.match(result.text, /hello/);
142
+ });
143
+ await ok('未知 action 返回 isError=true 且不抛异常', async () => {
144
+ const result = await tool.execute({ action: 'nope' }, { agent: { session: { id: 's1' } } });
145
+ assert.equal(result.isError, true);
146
+ assert.match(result.text, /BAD_ACTION/);
147
+ });
148
+ await ok('缺参数时返回明确错误而不是崩溃', async () => {
149
+ const result = await tool.execute({ action: 'call' }, { agent: { session: { id: 's1' } } });
150
+ assert.equal(result.isError, true);
151
+ assert.match(result.text, /MISSING_ARGUMENT/);
152
+ });
153
+ await ok('无会话时不会崩(退化为空 cwd)', async () => {
154
+ const result = await tool.execute({ action: 'describe' }, { agent: {} });
155
+ assert.equal(result.isError, false);
156
+ });
157
+
158
+ console.log('http route:');
159
+ await ok('GET /config 返回配置摘要', async () => {
160
+ const route = registered.routes[0];
161
+ const req = { url: '/dsh-api-runner/api/config', method: 'GET', headers: {} };
162
+ const res = createMockResponse();
163
+ await route.handler(req, res);
164
+ const payload = JSON.parse(res.body);
165
+ assert.equal(payload.ok, true);
166
+ assert.equal(payload.apis[0].id, 'ping');
167
+ });
168
+ await ok('请求被 connection 拒绝时返回 401 JSON', async () => {
169
+ const route = registered.routes[0];
170
+ const ctx2 = { ...ctx, connection: { requestRejection: () => 401 } };
171
+ // 重新 apply 到新桩上以拿到打了拒绝逻辑的 handler
172
+ const local = { tools: [], routes: [], sections: [], effects: [] };
173
+ mod.apply({
174
+ ...ctx2,
175
+ webServer: { register: (r) => (local.routes.push(r), () => {}) },
176
+ systemPrompt: { section: () => () => {} },
177
+ tools: { register: () => () => {} },
178
+ effect: (factory) => (factory(), () => {}),
179
+ });
180
+ const res = createMockResponse();
181
+ await local.routes[0].handler({ url: '/dsh-api-runner/api/config', method: 'GET', headers: {} }, res);
182
+ assert.equal(res.statusCode, 401);
183
+ assert.equal(JSON.parse(res.body).code, 'UNAUTHORIZED');
184
+ });
185
+ await ok('未知路由返回 404', async () => {
186
+ const route = registered.routes[0];
187
+ const res = createMockResponse();
188
+ await route.handler({ url: '/dsh-api-runner/api/nope', method: 'GET', headers: {} }, res);
189
+ assert.equal(res.statusCode, 404);
190
+ });
191
+
192
+ console.log('panel api:');
193
+ await ok('GET /ui 返回面板数据(含来源与接口)', async () => {
194
+ const route = registered.routes[0];
195
+ const res = createMockResponse();
196
+ await route.handler({ url: `/dsh-api-runner/api/ui?cwd=${encodeURIComponent(projectDir)}`, method: 'GET', headers: {} }, res);
197
+ const payload = JSON.parse(res.body);
198
+ assert.equal(payload.ok, true);
199
+ assert.equal(payload.apis[0].id, 'ping');
200
+ assert.equal(payload.apis[0].source, 'file');
201
+ });
202
+ await ok('POST /ui/api 保存面板接口后能在 /ui 里看到(source=ui)', async () => {
203
+ const route = registered.routes[0];
204
+ const res2 = createMockResponse();
205
+ const body = JSON.stringify({
206
+ cwd: projectDir,
207
+ api: { id: 'panel-api', method: 'GET', url: '/panel', baseUrl: base, description: '面板加的' },
208
+ });
209
+ const req = {
210
+ url: '/dsh-api-runner/api/ui/api',
211
+ method: 'POST',
212
+ headers: {},
213
+ async *[Symbol.asyncIterator]() {
214
+ yield Buffer.from(body, 'utf8');
215
+ },
216
+ };
217
+ await route.handler(req, res2);
218
+ const saved = JSON.parse(res2.body);
219
+ assert.equal(saved.ok, true, res2.body);
220
+
221
+ const listRes = createMockResponse();
222
+ await route.handler({ url: `/dsh-api-runner/api/ui?cwd=${encodeURIComponent(projectDir)}`, method: 'GET', headers: {} }, listRes);
223
+ const list = JSON.parse(listRes.body);
224
+ const created = list.apis.find((item) => item.id === 'panel-api');
225
+ assert.ok(created, '保存后应出现在接口列表');
226
+ assert.equal(created.source, 'ui');
227
+ assert.equal(created.url, `${base}/panel`, '应展示完整地址');
228
+ });
229
+ await ok('GET /ui?project=<key> 切到别的项目(优先于会话 cwd)', async () => {
230
+ const route = registered.routes[0];
231
+ const otherDir = path.join(tmp, 'another');
232
+ fs.mkdirSync(otherDir, { recursive: true });
233
+ // 先在另一个项目里存一个接口
234
+ const saveBody = JSON.stringify({
235
+ cwd: otherDir,
236
+ api: { id: 'from-other', method: 'GET', url: '/other', baseUrl: base },
237
+ });
238
+ const saveReq = {
239
+ url: '/dsh-api-runner/api/ui/api',
240
+ method: 'POST',
241
+ headers: {},
242
+ async *[Symbol.asyncIterator]() { yield Buffer.from(saveBody, 'utf8'); },
243
+ };
244
+ const saveRes = createMockResponse();
245
+ await route.handler(saveReq, saveRes);
246
+ assert.equal(JSON.parse(saveRes.body).ok, true, saveRes.body);
247
+
248
+ const otherKey = normalizeProjectKey(otherDir);
249
+ const res = createMockResponse();
250
+ // 带会话 id(cwd=projectDir)同时也带 project:必须以后者为准,否则页签点了没反应
251
+ await route.handler({
252
+ url: `/dsh-api-runner/api/ui?project=${encodeURIComponent(otherKey)}`,
253
+ method: 'GET',
254
+ headers: { 'x-dsh-session-id': 's1' },
255
+ }, res);
256
+ const payload = JSON.parse(res.body);
257
+ assert.equal(payload.ok, true);
258
+ assert.equal(payload.projectKey, otherKey);
259
+ assert.equal(payload.cwdProjectKey, normalizeProjectKey(projectDir));
260
+ assert.ok(payload.apis.some((api) => api.id === 'from-other'), JSON.stringify(payload.apis.map((api) => api.id)));
261
+
262
+ // 清单里会话工作区排第一
263
+ assert.equal(payload.projects[0].key, payload.cwdProjectKey);
264
+ assert.ok(payload.projects.some((item) => item.key === otherKey));
265
+
266
+ // 不在 store 里、也不是绝对路径的 project 应被忽略,退回会话 cwd
267
+ const bad = createMockResponse();
268
+ await route.handler({
269
+ url: '/dsh-api-runner/api/ui?project=nonsense',
270
+ method: 'GET',
271
+ headers: { 'x-dsh-session-id': 's1' },
272
+ }, bad);
273
+ assert.equal(JSON.parse(bad.body).projectKey, normalizeProjectKey(projectDir));
274
+ });
275
+
276
+ await ok('GET /java 返回进程列表结构', async () => {
277
+ const route = registered.routes[0];
278
+ const res = createMockResponse();
279
+ await route.handler({ url: `/dsh-api-runner/api/java?cwd=${encodeURIComponent(projectDir)}`, method: 'GET', headers: {} }, res);
280
+ const payload = JSON.parse(res.body);
281
+ assert.equal(typeof payload.ok, 'boolean');
282
+ assert.ok(Array.isArray(payload.processes));
283
+ // profiles 必须是数组,前端会直接 .map;曾因返回字符串导致崩溃
284
+ for (const item of payload.processes) assert.ok(Array.isArray(item.profiles), `pid ${item.pid} 的 profiles 应为数组`);
285
+ // 每个端口都带角色,前端靠它区分 HTTP 业务端口与 Sentinel 规则端口
286
+ for (const item of payload.processes) {
287
+ const list = Array.isArray(item.portList) ? item.portList : [];
288
+ // portList 是「除调试端口外的监听端口」,每个都带角色
289
+ const raw = (Array.isArray(item.ports) ? item.ports : []).filter((port) => port !== item.debugPort);
290
+ assert.equal(list.length, raw.length, `pid ${item.pid} 的 portList 应覆盖所有非调试端口`);
291
+ for (const port of list) {
292
+ assert.ok(['http', 'sentinel', 'debug', 'unknown'].includes(port.role), `端口 ${port.port} 的 role 不合法`);
293
+ assert.equal(typeof port.label, 'string');
294
+ }
295
+ assert.equal(typeof item.ignored, 'boolean');
296
+ }
297
+ assert.ok(Array.isArray(payload.ignored), '应返回已忽略清单');
298
+ });
299
+ await ok('忽略/取消忽略只在配置里记一笔,不动进程', async () => {
300
+ const route = registered.routes[0];
301
+ const item = { mainClass: 'com.example.DemoApplication', name: 'Demo', kind: 'spring' };
302
+ const post = (path, payload) => ({
303
+ url: `${path}?cwd=${encodeURIComponent(projectDir)}`,
304
+ method: 'POST',
305
+ headers: {},
306
+ async *[Symbol.asyncIterator]() {
307
+ yield Buffer.from(JSON.stringify(payload), 'utf8');
308
+ },
309
+ });
310
+
311
+ const ignored = createMockResponse();
312
+ await route.handler(post('/dsh-api-runner/api/java/ignore', { item }), ignored);
313
+ assert.equal(JSON.parse(ignored.body).ok, true, ignored.body);
314
+
315
+ const listRes = createMockResponse();
316
+ await route.handler({ url: `/dsh-api-runner/api/java?cwd=${encodeURIComponent(projectDir)}`, method: 'GET', headers: {} }, listRes);
317
+ const list = JSON.parse(listRes.body);
318
+ assert.ok(list.ignored.some((entry) => entry.mainClass === item.mainClass), '被忽略的服务应出现在 ignored 里');
319
+
320
+ const unignored = createMockResponse();
321
+ await route.handler(post('/dsh-api-runner/api/java/unignore', { item }), unignored);
322
+ assert.equal(JSON.parse(unignored.body).ok, true, unignored.body);
323
+
324
+ const after = createMockResponse();
325
+ await route.handler({ url: `/dsh-api-runner/api/java?cwd=${encodeURIComponent(projectDir)}`, method: 'GET', headers: {} }, after);
326
+ assert.equal(JSON.parse(after.body).ignored.some((entry) => entry.mainClass === item.mainClass), false);
327
+ });
328
+ await ok('GET /java/detail 返回日志与可选来源', async () => {
329
+ const route = registered.routes[0];
330
+ const res = createMockResponse();
331
+ await route.handler({ url: `/dsh-api-runner/api/java/detail?cwd=${encodeURIComponent(projectDir)}&id=999999`, method: 'GET', headers: {} }, res);
332
+ const payload = JSON.parse(res.body);
333
+ // 不存在的 pid 要给出可读错误,而不是抛异常
334
+ assert.equal(payload.ok, false);
335
+ assert.equal(typeof payload.message, 'string');
336
+ });
337
+ await ok('GET/POST /settings 能读能写,并拒绝相对路径的数据目录', async () => {
338
+ const route = registered.routes[0];
339
+ const read = createMockResponse();
340
+ await route.handler({ url: '/dsh-api-runner/api/settings', method: 'GET', headers: {} }, read);
341
+ const before = JSON.parse(read.body);
342
+ assert.equal(before.ok, true, read.body);
343
+ assert.equal(typeof before.effectiveDataDir, 'string');
344
+ assert.equal(before.dataDir, '');
345
+
346
+ const post = (payload) => ({
347
+ url: '/dsh-api-runner/api/settings',
348
+ method: 'POST',
349
+ headers: {},
350
+ async *[Symbol.asyncIterator]() {
351
+ yield Buffer.from(JSON.stringify(payload), 'utf8');
352
+ },
353
+ });
354
+
355
+ const bad = createMockResponse();
356
+ await route.handler(post({ dataDir: 'relative/dir' }), bad);
357
+ const badPayload = JSON.parse(bad.body);
358
+ assert.equal(badPayload.ok, false, '相对路径应被拒绝');
359
+ assert.match(badPayload.message, /绝对路径/);
360
+
361
+ const good = createMockResponse();
362
+ await route.handler(post({ workspace: projectDir }), good);
363
+ const goodPayload = JSON.parse(good.body);
364
+ assert.equal(goodPayload.ok, true, good.body);
365
+ assert.equal(goodPayload.workspace, projectDir);
366
+ });
367
+ await ok('java/save、java/unsave、java/start-saved 都能给出可读结果', async () => {
368
+ const route = registered.routes[0];
369
+ const post = (path, payload) => ({
370
+ url: `${path}?cwd=${encodeURIComponent(projectDir)}`,
371
+ method: 'POST',
372
+ headers: {},
373
+ async *[Symbol.asyncIterator]() {
374
+ yield Buffer.from(JSON.stringify(payload), 'utf8');
375
+ },
376
+ });
377
+
378
+ // 保存一个不存在的服务:必须报错,不能抛异常
379
+ const save = createMockResponse();
380
+ await route.handler(post('/dsh-api-runner/api/java/save', { id: 'not-running' }), save);
381
+ const savePayload = JSON.parse(save.body);
382
+ assert.equal(savePayload.ok, false, save.body);
383
+ assert.equal(typeof savePayload.message, 'string');
384
+
385
+ // 没保存过的服务启动:给出 NOT_SAVED
386
+ const start = createMockResponse();
387
+ await route.handler(post('/dsh-api-runner/api/java/start-saved', { id: 'not-saved' }), start);
388
+ const startPayload = JSON.parse(start.body);
389
+ assert.equal(startPayload.ok, false, start.body);
390
+ assert.equal(startPayload.code, 'NOT_SAVED');
391
+
392
+ // 删除一个不存在的配方:不报错(幂等),返回 saved 列表
393
+ const unsave = createMockResponse();
394
+ await route.handler(post('/dsh-api-runner/api/java/unsave', { id: 'not-saved' }), unsave);
395
+ const unsavePayload = JSON.parse(unsave.body);
396
+ assert.equal(unsavePayload.ok, true, unsave.body);
397
+ assert.ok(Array.isArray(unsavePayload.saved));
398
+ });
399
+ await ok('java/start-all 与状态轮询路由都已挂载', async () => {
400
+ const route = registered.routes[0];
401
+ const post = (path, payload) => ({
402
+ url: `${path}?cwd=${encodeURIComponent(projectDir)}`,
403
+ method: 'POST',
404
+ headers: {},
405
+ async *[Symbol.asyncIterator]() {
406
+ yield Buffer.from(JSON.stringify(payload), 'utf8');
407
+ },
408
+ });
409
+ const get = (path) => ({
410
+ url: `${path}?cwd=${encodeURIComponent(projectDir)}`,
411
+ method: 'GET',
412
+ headers: {},
413
+ async *[Symbol.asyncIterator]() {},
414
+ });
415
+
416
+ // 一个配方都没保存:一键启动要说清楚,不能 404 也不能抛
417
+ const empty = createMockResponse();
418
+ await route.handler(post('/dsh-api-runner/api/java/start-all', {}), empty);
419
+ const emptyPayload = JSON.parse(empty.body);
420
+ assert.equal(emptyPayload.ok, false, empty.body);
421
+ assert.equal(emptyPayload.code, 'NOTHING_SAVED');
422
+
423
+ // 状态路由:查不存在的批次给 NO_BATCH(证明路由存在且不崩)
424
+ const status = createMockResponse();
425
+ await route.handler(get('/dsh-api-runner/api/java/start-all/status?batch=nope'), status);
426
+ const statusPayload = JSON.parse(status.body);
427
+ assert.equal(statusPayload.ok, false, status.body);
428
+ assert.equal(statusPayload.code, 'NO_BATCH');
429
+ });
430
+
431
+ await ok('工具 action 里有 login 与 java', () => {
432
+ const tool = registered.tools[0];
433
+ const actions = tool.parameters.properties.action.enum;
434
+ assert.ok(actions.includes('login'));
435
+ assert.ok(actions.includes('java'));
436
+ assert.ok(tool.parameters.properties.token_from, '应有 token_from 参数');
437
+ assert.ok(tool.parameters.properties.java_action.enum.includes('start_all'), 'java_action 应有 start_all');
438
+ assert.ok(tool.parameters.properties.java_ids, '应有 java_ids 参数');
439
+ });
440
+
441
+ // ---- 清理 ----
442
+ for (const effect of registered.effects) effect.dispose?.();
443
+ server.close();
444
+ fs.rmSync(tmp, { recursive: true, force: true });
445
+
446
+ console.log(`\n${process.exitCode ? 'FAILED' : 'PASSED'}: ${passed} checks`);
447
+
448
+ function createMockResponse() {
449
+ const res = {
450
+ statusCode: 200,
451
+ headers: {},
452
+ body: '',
453
+ headersSent: false,
454
+ setHeader(key, value) {
455
+ this.headers[key.toLowerCase()] = value;
456
+ },
457
+ end(text) {
458
+ this.body = text;
459
+ this.headersSent = true;
460
+ },
461
+ status(code) {
462
+ this.statusCode = code;
463
+ return this;
464
+ },
465
+ };
466
+ return res;
467
+ }