dsh-api-runner 0.1.0 → 0.2.1
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 +71 -15
- package/client/client.js +1840 -1604
- package/package.json +71 -77
- package/src/java-runner.js +3 -3
- package/src/main.js +31 -2
- package/src/ops.js +1268 -1039
- package/src/project-store.js +350 -334
- package/src/restart.js +43 -12
- package/test/client.mjs +162 -109
- package/test/plugin.mjs +34 -0
- package/test/smoke.mjs +199 -0
package/src/restart.js
CHANGED
|
@@ -437,14 +437,8 @@ export function spawnService({ javaExe, argv, cwd, logPath }) {
|
|
|
437
437
|
} catch {
|
|
438
438
|
out = 'ignore';
|
|
439
439
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
cwd: cwd || undefined,
|
|
443
|
-
detached: true,
|
|
444
|
-
stdio: ['ignore', out, out],
|
|
445
|
-
windowsHide: true,
|
|
446
|
-
});
|
|
447
|
-
child.unref();
|
|
440
|
+
|
|
441
|
+
const closeOut = () => {
|
|
448
442
|
if (typeof out === 'number') {
|
|
449
443
|
try {
|
|
450
444
|
fs.closeSync(out);
|
|
@@ -452,10 +446,47 @@ export function spawnService({ javaExe, argv, cwd, logPath }) {
|
|
|
452
446
|
/* 已交给子进程 */
|
|
453
447
|
}
|
|
454
448
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* 返回 Promise:等到 'spawn' 或 'error' 再决定成败。
|
|
453
|
+
*
|
|
454
|
+
* 为什么不能直接返回 `{ok:true}`:可执行文件不存在时 spawn **不抛异常**,
|
|
455
|
+
* 而是异步发 'error';没有监听器时 Node 把它当未捕获异常,会把 DSH 整个进程崩掉。
|
|
456
|
+
* 一键启动要连续拉起多个服务,其中一个 javaExe 配错(换机器、JDK 路径变了)
|
|
457
|
+
* 就足以让宿主挂掉,所以这里必须挂监听器,并且如实报告失败原因。
|
|
458
|
+
*/
|
|
459
|
+
return new Promise((resolve) => {
|
|
460
|
+
let child;
|
|
461
|
+
try {
|
|
462
|
+
child = spawn(exe, argv, {
|
|
463
|
+
cwd: cwd || undefined,
|
|
464
|
+
detached: true,
|
|
465
|
+
stdio: ['ignore', out, out],
|
|
466
|
+
windowsHide: true,
|
|
467
|
+
});
|
|
468
|
+
} catch (error) {
|
|
469
|
+
closeOut();
|
|
470
|
+
resolve({ ok: false, message: `拉起进程失败:${error?.message || error}`, logPath: logPath || '' });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let settled = false;
|
|
475
|
+
const settle = (value) => {
|
|
476
|
+
if (settled) return;
|
|
477
|
+
settled = true;
|
|
478
|
+
closeOut();
|
|
479
|
+
resolve(value);
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
child.once('error', (error) => {
|
|
483
|
+
settle({ ok: false, message: `拉起进程失败:${error?.message || error}`, logPath: logPath || '' });
|
|
484
|
+
});
|
|
485
|
+
child.once('spawn', () => {
|
|
486
|
+
child.unref();
|
|
487
|
+
settle({ ok: true, pid: child.pid, logPath: logPath || '' });
|
|
488
|
+
});
|
|
489
|
+
});
|
|
459
490
|
}
|
|
460
491
|
|
|
461
492
|
/** 等端口开始 LISTENING(用 TCP 连接探测,不依赖 netstat) */
|
package/test/client.mjs
CHANGED
|
@@ -1,109 +1,162 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* client/client.js 的形态测试。
|
|
3
|
-
*
|
|
4
|
-
* 客户端 bundle 是「经典脚本」:加载时立刻调用 window.__ModuleLoader__.load(...),
|
|
5
|
-
* 由 DSH 前端注入的 loader 提供 require
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
assert.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
1
|
+
/**
|
|
2
|
+
* client/client.js 的形态测试。
|
|
3
|
+
*
|
|
4
|
+
* 客户端 bundle 是「经典脚本」:加载时立刻调用 window.__ModuleLoader__.load(...),
|
|
5
|
+
* 由 DSH 前端注入的 loader 提供 require。这里用最小桩模拟这套环境,校验:
|
|
6
|
+
* ① 注册的 id 与包名一致;
|
|
7
|
+
* ② 导出 inject/apply,且 inject 里**没有**第三方侧边栏(本插件是独立的);
|
|
8
|
+
* ③ apply 只往宿主自带的 slot 席位注册(panellist / main / 右栏页签 / 设置页);
|
|
9
|
+
* ④ 面板组件在缺少 scope 时也能渲染不崩;
|
|
10
|
+
* ⑤ 宿主没提供 slots 时安静退出,不抛异常。
|
|
11
|
+
*/
|
|
12
|
+
import assert from 'node:assert';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const source = fs.readFileSync(path.join(root, 'client', 'client.js'), 'utf8');
|
|
19
|
+
|
|
20
|
+
let recorded;
|
|
21
|
+
/** 极简 React 桩:只够执行 createElement 与 hooks */
|
|
22
|
+
const React = {
|
|
23
|
+
createElement: (type, props, ...children) => ({ type, props: { ...(props || {}), children: children.length > 1 ? children : children[0] } }),
|
|
24
|
+
Fragment: 'Fragment',
|
|
25
|
+
useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
|
|
26
|
+
useEffect: () => {},
|
|
27
|
+
useMemo: (factory) => factory(),
|
|
28
|
+
useCallback: (fn) => fn,
|
|
29
|
+
useRef: (initial) => ({ current: initial }),
|
|
30
|
+
};
|
|
31
|
+
const requireStub = (name) => {
|
|
32
|
+
if (name === 'react') return React;
|
|
33
|
+
throw new Error(`客户端 bundle 请求了未桩的模块:${name}`);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const windowStub = {
|
|
37
|
+
__ModuleLoader__: {
|
|
38
|
+
load: (entry) => {
|
|
39
|
+
recorded = entry;
|
|
40
|
+
recorded.exports = entry.factory(requireStub);
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
confirm: () => true,
|
|
44
|
+
document: {
|
|
45
|
+
getElementById: () => null,
|
|
46
|
+
createElement: () => ({ set textContent(v) {}, id: '' }),
|
|
47
|
+
head: { appendChild: () => {} },
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
// bundle 里会读 document / window,通过函数参数作用域注入
|
|
51
|
+
const run = new Function('window', 'document', 'setInterval', 'clearInterval', 'fetch', source);
|
|
52
|
+
run(windowStub, windowStub.document, () => 0, () => {}, () => { throw new Error('测试里不应真的发请求'); });
|
|
53
|
+
|
|
54
|
+
assert.ok(recorded, '应当调用 window.__ModuleLoader__.load');
|
|
55
|
+
assert.equal(recorded.id, 'dsh-api-runner', 'bundle id 必须与包名一致');
|
|
56
|
+
assert.deepEqual(recorded.exports.inject, ['slots'], 'client half 只硬依赖 slots,不依赖任何第三方侧边栏');
|
|
57
|
+
assert.equal(typeof recorded.exports.apply, 'function');
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 造一个记录注册的 slots 服务桩。
|
|
61
|
+
* @param {{withInject?: boolean}} options - 是否提供 inject(宿主一定有,缺省时走直注册分支)
|
|
62
|
+
*/
|
|
63
|
+
function makeSlots(options = {}) {
|
|
64
|
+
const registrations = [];
|
|
65
|
+
const injected = [];
|
|
66
|
+
const slots = {
|
|
67
|
+
register: (descriptor, render) => {
|
|
68
|
+
registrations.push({ descriptor, render });
|
|
69
|
+
return () => {};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
if (options.withInject !== false) {
|
|
73
|
+
slots.inject = (slot, callback) => {
|
|
74
|
+
injected.push(slot);
|
|
75
|
+
callback();
|
|
76
|
+
return () => {};
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { slots, registrations, injected };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// apply:只提供 slots 的 ctx,断言注册落在宿主自带的席位上
|
|
83
|
+
const first = makeSlots();
|
|
84
|
+
const tabTypes = [];
|
|
85
|
+
const effects = [];
|
|
86
|
+
const ctx = {
|
|
87
|
+
get: (name) => {
|
|
88
|
+
if (name === 'slots') return first.slots;
|
|
89
|
+
if (name === 'sidebarRightTabs') {
|
|
90
|
+
return {
|
|
91
|
+
register: (definition) => {
|
|
92
|
+
tabTypes.push(definition);
|
|
93
|
+
return () => {};
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
},
|
|
99
|
+
effect: (callback, label) => {
|
|
100
|
+
effects.push(label);
|
|
101
|
+
return callback();
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
recorded.exports.apply(ctx);
|
|
105
|
+
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
first.injected,
|
|
108
|
+
['sidebar.panellist', 'main', 'sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'settings.section'],
|
|
109
|
+
'每个席位都要通过 slots.inject 等待声明就绪',
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const byName = (name) => first.registrations.filter((item) => item.descriptor.name === name);
|
|
113
|
+
const panellist = byName('sidebar.panellist');
|
|
114
|
+
assert.equal(panellist.length, 1, '应注册一个左栏图标');
|
|
115
|
+
assert.equal(panellist[0].descriptor.id, 'dsh-api-runner');
|
|
116
|
+
assert.equal(panellist[0].descriptor.label(), '接口调试');
|
|
117
|
+
|
|
118
|
+
const main = byName('main');
|
|
119
|
+
assert.equal(main.length, 1, '应注册一个中栏整页');
|
|
120
|
+
assert.equal(main[0].descriptor.key, 'dsh-api-runner', 'main 的 key 必须与 panellist 的 id 一致,点图标才切得过去');
|
|
121
|
+
|
|
122
|
+
assert.equal(byName('sidebar.right.pane.tab').length, 1, '应注册一个右侧栏页签正文');
|
|
123
|
+
assert.equal(byName('sidebar.right.pane.tab')[0].descriptor.key, 'dsh-api-runner');
|
|
124
|
+
assert.equal(byName('sidebar.right.pane.tab.title').length, 1, '应注册一个右侧栏页签标题');
|
|
125
|
+
|
|
126
|
+
const sections = byName('settings.section');
|
|
127
|
+
assert.equal(sections.length, 1, '应注册一个设置页');
|
|
128
|
+
assert.equal(sections[0].descriptor.id, 'dsh-api-runner');
|
|
129
|
+
assert.equal(sections[0].descriptor.label(), '接口调试');
|
|
130
|
+
|
|
131
|
+
assert.equal(tabTypes.length, 1, '应向 sidebarRightTabs 注册一个页签类型');
|
|
132
|
+
assert.equal(tabTypes[0].id, 'dsh-api-runner');
|
|
133
|
+
assert.equal(tabTypes[0].kind, 'dsh-api-runner');
|
|
134
|
+
assert.equal(tabTypes[0].title(), '接口调试');
|
|
135
|
+
|
|
136
|
+
// 设置页能渲染
|
|
137
|
+
const page = sections[0].render();
|
|
138
|
+
assert.ok(page, '设置页应返回元素');
|
|
139
|
+
assert.equal(typeof page.type, 'function');
|
|
140
|
+
|
|
141
|
+
// 面板组件:没有 scope 也不能崩(全局页没有会话时就是这条路径)
|
|
142
|
+
const panel = main[0].render({});
|
|
143
|
+
assert.ok(panel, '面板组件应返回元素');
|
|
144
|
+
assert.equal(typeof panel.type, 'function');
|
|
145
|
+
|
|
146
|
+
// 右侧栏页签:没有 useTabInfo 时按可见渲染,也不崩
|
|
147
|
+
const rightPanel = byName('sidebar.right.pane.tab')[0].render({ sessionId: 'session-1' });
|
|
148
|
+
assert.ok(rightPanel, '右侧栏页签正文应返回元素');
|
|
149
|
+
assert.equal(typeof rightPanel.type, 'function');
|
|
150
|
+
|
|
151
|
+
// 宿主没提供 slots 时 apply 要安全退出,不能抛
|
|
152
|
+
recorded.exports.apply({ effect: () => {} });
|
|
153
|
+
|
|
154
|
+
// slots 没有 inject 时退回直接注册(旧版宿主)
|
|
155
|
+
const second = makeSlots({ withInject: false });
|
|
156
|
+
recorded.exports.apply({
|
|
157
|
+
get: (name) => (name === 'slots' ? second.slots : undefined),
|
|
158
|
+
effect: (callback) => callback(),
|
|
159
|
+
});
|
|
160
|
+
assert.equal(second.registrations.length, 5, '没有 inject 时也要把五个席位都注册上');
|
|
161
|
+
|
|
162
|
+
console.log('PASSED: client bundle shape');
|
package/test/plugin.mjs
CHANGED
|
@@ -396,12 +396,46 @@ await ok('java/save、java/unsave、java/start-saved 都能给出可读结果',
|
|
|
396
396
|
assert.equal(unsavePayload.ok, true, unsave.body);
|
|
397
397
|
assert.ok(Array.isArray(unsavePayload.saved));
|
|
398
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
|
+
|
|
399
431
|
await ok('工具 action 里有 login 与 java', () => {
|
|
400
432
|
const tool = registered.tools[0];
|
|
401
433
|
const actions = tool.parameters.properties.action.enum;
|
|
402
434
|
assert.ok(actions.includes('login'));
|
|
403
435
|
assert.ok(actions.includes('java'));
|
|
404
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 参数');
|
|
405
439
|
});
|
|
406
440
|
|
|
407
441
|
// ---- 清理 ----
|
package/test/smoke.mjs
CHANGED
|
@@ -289,6 +289,120 @@ await ok('面板接口的 tokenPath 会自动写回配置,第二次登录不
|
|
|
289
289
|
service.deleteApiUi(projectDir, 'ui-login');
|
|
290
290
|
});
|
|
291
291
|
|
|
292
|
+
console.log('面板回显与数据保留:');
|
|
293
|
+
await ok('示例参数保存后能回显(不再永远空)', async () => {
|
|
294
|
+
service.saveApiUi(projectDir, {
|
|
295
|
+
id: 'ui-sample',
|
|
296
|
+
name: '回显示例参数',
|
|
297
|
+
method: 'GET',
|
|
298
|
+
url: `${base}/echo`,
|
|
299
|
+
sampleParams: { matterCd: 'SX-202609-0012', page: 1 },
|
|
300
|
+
});
|
|
301
|
+
const state = await service.uiState(projectDir);
|
|
302
|
+
const api = state.apis.find((item) => item.id === 'ui-sample');
|
|
303
|
+
assert.ok(api, '接口应出现在 uiState 里');
|
|
304
|
+
assert.notEqual(api.sampleText, '', 'sampleText 不能为空,否则编辑框回显不出来');
|
|
305
|
+
const parsed = JSON.parse(api.sampleText);
|
|
306
|
+
assert.equal(parsed.matterCd, 'SX-202609-0012');
|
|
307
|
+
assert.equal(parsed.page, 1);
|
|
308
|
+
service.deleteApiUi(projectDir, 'ui-sample');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
await ok('YAML 里的 query 不会被面板保存抹掉', async () => {
|
|
312
|
+
// 先写进面板(模拟 YAML 已有该接口),再在面板里只改说明后保存
|
|
313
|
+
service.saveApiUi(projectDir, {
|
|
314
|
+
id: 'ui-query',
|
|
315
|
+
name: '带 query 的接口',
|
|
316
|
+
method: 'GET',
|
|
317
|
+
url: `${base}/system/library/matter/billList`,
|
|
318
|
+
query: { matterCd: '{matterCd}' },
|
|
319
|
+
});
|
|
320
|
+
const before = await service.uiState(projectDir);
|
|
321
|
+
const b = before.apis.find((item) => item.id === 'ui-query');
|
|
322
|
+
assert.ok(b.queryText && b.queryText.includes('matterCd'), 'uiState 应返回 queryText 供面板回显');
|
|
323
|
+
|
|
324
|
+
// 面板只改说明,不带 query 字段(真实表单就是这样提交的)
|
|
325
|
+
service.saveApiUi(projectDir, {
|
|
326
|
+
id: 'ui-query',
|
|
327
|
+
name: '带 query 的接口',
|
|
328
|
+
method: 'GET',
|
|
329
|
+
url: `${base}/system/library/matter/billList`,
|
|
330
|
+
description: '只改了说明',
|
|
331
|
+
});
|
|
332
|
+
const stored = service.projectStore.project(projectDir).apis.find((item) => item.id === 'ui-query');
|
|
333
|
+
assert.ok(stored.query && stored.query.matterCd === '{matterCd}', 'query 必须被保留,不能被整体覆盖丢掉');
|
|
334
|
+
assert.equal(stored.description, '只改了说明');
|
|
335
|
+
|
|
336
|
+
// 运行期确实用上了 query
|
|
337
|
+
const config = service.config(projectDir);
|
|
338
|
+
const runtime = config.config.apis.find((item) => item.id === 'ui-query');
|
|
339
|
+
assert.equal(runtime.query.matterCd, '{matterCd}');
|
|
340
|
+
service.deleteApiUi(projectDir, 'ui-query');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
await ok('显式传 query 时以新值为准(不是只增不减)', async () => {
|
|
344
|
+
service.saveApiUi(projectDir, {
|
|
345
|
+
id: 'ui-query2',
|
|
346
|
+
name: 'q2',
|
|
347
|
+
method: 'GET',
|
|
348
|
+
url: `${base}/echo`,
|
|
349
|
+
query: { a: '1', b: '2' },
|
|
350
|
+
});
|
|
351
|
+
service.saveApiUi(projectDir, {
|
|
352
|
+
id: 'ui-query2',
|
|
353
|
+
name: 'q2',
|
|
354
|
+
method: 'GET',
|
|
355
|
+
url: `${base}/echo`,
|
|
356
|
+
query: { a: '9' },
|
|
357
|
+
});
|
|
358
|
+
const stored = service.projectStore.project(projectDir).apis.find((item) => item.id === 'ui-query2');
|
|
359
|
+
assert.deepEqual(stored.query, { a: '9' }, '显式提交的 query 应整体替换,不被旧值污染');
|
|
360
|
+
service.deleteApiUi(projectDir, 'ui-query2');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
await ok('面板传空 query 能真正清空(不被旧值粘回来)', async () => {
|
|
364
|
+
service.saveApiUi(projectDir, {
|
|
365
|
+
id: 'ui-query3',
|
|
366
|
+
name: 'q3',
|
|
367
|
+
method: 'GET',
|
|
368
|
+
url: `${base}/echo`,
|
|
369
|
+
query: { a: '1' },
|
|
370
|
+
});
|
|
371
|
+
// 面板把输入框清空后提交的是 {},必须覆盖成空
|
|
372
|
+
service.saveApiUi(projectDir, {
|
|
373
|
+
id: 'ui-query3',
|
|
374
|
+
name: 'q3',
|
|
375
|
+
method: 'GET',
|
|
376
|
+
url: `${base}/echo`,
|
|
377
|
+
query: {},
|
|
378
|
+
});
|
|
379
|
+
const stored = service.projectStore.project(projectDir).apis.find((item) => item.id === 'ui-query3');
|
|
380
|
+
assert.deepEqual(stored.query, {}, '清空 query 必须生效,否则用户没法去掉参数');
|
|
381
|
+
service.deleteApiUi(projectDir, 'ui-query3');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await ok('只改说明时示例参数也要保留(不能变成空)', async () => {
|
|
385
|
+
service.saveApiUi(projectDir, {
|
|
386
|
+
id: 'ui-sample2',
|
|
387
|
+
name: 's2',
|
|
388
|
+
method: 'GET',
|
|
389
|
+
url: `${base}/echo`,
|
|
390
|
+
sampleParams: { matterCd: 'SX-1' },
|
|
391
|
+
});
|
|
392
|
+
service.saveApiUi(projectDir, {
|
|
393
|
+
id: 'ui-sample2',
|
|
394
|
+
name: 's2',
|
|
395
|
+
method: 'GET',
|
|
396
|
+
url: `${base}/echo`,
|
|
397
|
+
description: '只改说明',
|
|
398
|
+
});
|
|
399
|
+
const state = await service.uiState(projectDir);
|
|
400
|
+
const api = state.apis.find((item) => item.id === 'ui-sample2');
|
|
401
|
+
assert.notEqual(api.sampleText, '', '只改说明不应把示例参数弄丢');
|
|
402
|
+
assert.equal(JSON.parse(api.sampleText).matterCd, 'SX-1');
|
|
403
|
+
service.deleteApiUi(projectDir, 'ui-sample2');
|
|
404
|
+
});
|
|
405
|
+
|
|
292
406
|
console.log('面板按项目分类:');
|
|
293
407
|
const otherProjectDir = path.join(tmp, 'another-project');
|
|
294
408
|
fs.mkdirSync(otherProjectDir, { recursive: true });
|
|
@@ -436,6 +550,91 @@ await ok('describe 展示完整地址(含路径)并列出占位符', () => {
|
|
|
436
550
|
assert.deepEqual(echo.params.sort(), ['fixed', 'id']);
|
|
437
551
|
});
|
|
438
552
|
|
|
553
|
+
console.log('java 一键启动:');
|
|
554
|
+
await ok('没有保存的服务时给出明确提示', async () => {
|
|
555
|
+
const result = await service.startAllSavedJava(projectDir, []);
|
|
556
|
+
assert.equal(result.ok, false);
|
|
557
|
+
assert.equal(result.code, 'NOTHING_SAVED');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
await ok('一键启动逐个记账:成功/跳过/失败都进 items,不中断', async () => {
|
|
561
|
+
// 造两个配方:一个能起来(假的 java 命令必然失败),一个 id 不存在。
|
|
562
|
+
// 这里不真的拉 Java 进程,只验证编排逻辑:失败被记进 items,循环继续。
|
|
563
|
+
const store = service.projectStore;
|
|
564
|
+
store.saveJava(projectDir, {
|
|
565
|
+
id: 'demo-a',
|
|
566
|
+
name: 'demo-a',
|
|
567
|
+
mainClass: 'com.example.DemoAApplication',
|
|
568
|
+
classPath: '/tmp/none/target/classes',
|
|
569
|
+
javaExe: 'definitely-not-a-real-java-binary',
|
|
570
|
+
cwd: projectDir,
|
|
571
|
+
});
|
|
572
|
+
store.saveJava(projectDir, {
|
|
573
|
+
id: 'demo-b',
|
|
574
|
+
name: 'demo-b',
|
|
575
|
+
mainClass: 'com.example.DemoBApplication',
|
|
576
|
+
classPath: '/tmp/none/target/classes',
|
|
577
|
+
javaExe: 'definitely-not-a-real-java-binary',
|
|
578
|
+
cwd: projectDir,
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const result = await service.startAllSavedJava(projectDir, ['demo-a', 'ghost']);
|
|
582
|
+
assert.equal(result.ok, false, '有失败项时整体 ok 为 false');
|
|
583
|
+
assert.equal(result.code, 'PARTIAL');
|
|
584
|
+
assert.equal(result.total, 2);
|
|
585
|
+
// ghost 不存在 → NOT_SAVED;demo-a 拉不起来 → 各自有失败原因
|
|
586
|
+
const ghost = result.items.find((item) => item.id === 'ghost');
|
|
587
|
+
assert.equal(ghost.code, 'NOT_SAVED');
|
|
588
|
+
const a = result.items.find((item) => item.id === 'demo-a');
|
|
589
|
+
assert.equal(a.ok, false, '假 java 路径不可能启动成功');
|
|
590
|
+
assert.ok(a.code || a.message, '失败项必须带原因');
|
|
591
|
+
// 关键:第一个失败没有阻止第二个被处理
|
|
592
|
+
assert.equal(result.items.length, 2, '失败不应中断整批');
|
|
593
|
+
assert.match(result.message, /成功 \d+ 个,跳过 \d+ 个,失败 \d+ 个/);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
await ok('一键启动跳过已在运行的服务', async () => {
|
|
597
|
+
// 直接往托管记录里塞一条 alive 的假记录(用当前进程的 pid,保证 isAlive 为 true)
|
|
598
|
+
service.javaRunner._put(projectDir, {
|
|
599
|
+
id: 'demo-b',
|
|
600
|
+
pid: process.pid,
|
|
601
|
+
mainClass: 'com.example.DemoBApplication',
|
|
602
|
+
logPath: path.join(homeDir, 'java-logs', 'demo-b.log'),
|
|
603
|
+
startedAt: new Date().toISOString(),
|
|
604
|
+
});
|
|
605
|
+
const result = await service.startAllSavedJava(projectDir, ['demo-b']);
|
|
606
|
+
const b = result.items.find((item) => item.id === 'demo-b');
|
|
607
|
+
assert.equal(b.skipped, true, JSON.stringify(b));
|
|
608
|
+
assert.equal(result.skipped, 1);
|
|
609
|
+
assert.equal(result.failed, 0);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
await ok('后台批次立刻返回 id,轮询能看到进度与结束状态', async () => {
|
|
613
|
+
const started = service.startSavedJavaBatch(projectDir, ['ghost']);
|
|
614
|
+
assert.equal(started.ok, true);
|
|
615
|
+
assert.ok(started.batchId, '必须立刻返回批次 id');
|
|
616
|
+
assert.equal(started.total, 1);
|
|
617
|
+
|
|
618
|
+
// 轮询到结束(失败也算结束)
|
|
619
|
+
let status;
|
|
620
|
+
for (let i = 0; i < 50; i += 1) {
|
|
621
|
+
status = service.startBatchStatus(started.batchId);
|
|
622
|
+
if (status.finished) break;
|
|
623
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
624
|
+
}
|
|
625
|
+
assert.equal(status.ok, true);
|
|
626
|
+
assert.equal(status.finished, true, JSON.stringify(status));
|
|
627
|
+
assert.equal(status.total, 1);
|
|
628
|
+
assert.ok(status.result, '结束后必须带上 result');
|
|
629
|
+
assert.equal(status.items.length, 1, '每个服务都要有一条进度记录');
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
await ok('查不存在的批次给出 NO_BATCH', () => {
|
|
633
|
+
const status = service.startBatchStatus('batch-does-not-exist');
|
|
634
|
+
assert.equal(status.ok, false);
|
|
635
|
+
assert.equal(status.code, 'NO_BATCH');
|
|
636
|
+
});
|
|
637
|
+
|
|
439
638
|
service.dispose();
|
|
440
639
|
server.close();
|
|
441
640
|
fs.rmSync(tmp, { recursive: true, force: true });
|