dsh-api-runner 0.1.0 → 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.
- package/README.md +58 -14
- package/client/client.js +1824 -1604
- package/package.json +71 -77
- package/src/java-runner.js +3 -3
- package/src/main.js +31 -2
- package/src/ops.js +218 -0
- package/src/restart.js +43 -12
- package/test/client.mjs +162 -109
- package/test/plugin.mjs +34 -0
- package/test/smoke.mjs +85 -0
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
|
@@ -436,6 +436,91 @@ await ok('describe 展示完整地址(含路径)并列出占位符', () => {
|
|
|
436
436
|
assert.deepEqual(echo.params.sort(), ['fixed', 'id']);
|
|
437
437
|
});
|
|
438
438
|
|
|
439
|
+
console.log('java 一键启动:');
|
|
440
|
+
await ok('没有保存的服务时给出明确提示', async () => {
|
|
441
|
+
const result = await service.startAllSavedJava(projectDir, []);
|
|
442
|
+
assert.equal(result.ok, false);
|
|
443
|
+
assert.equal(result.code, 'NOTHING_SAVED');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
await ok('一键启动逐个记账:成功/跳过/失败都进 items,不中断', async () => {
|
|
447
|
+
// 造两个配方:一个能起来(假的 java 命令必然失败),一个 id 不存在。
|
|
448
|
+
// 这里不真的拉 Java 进程,只验证编排逻辑:失败被记进 items,循环继续。
|
|
449
|
+
const store = service.projectStore;
|
|
450
|
+
store.saveJava(projectDir, {
|
|
451
|
+
id: 'demo-a',
|
|
452
|
+
name: 'demo-a',
|
|
453
|
+
mainClass: 'com.example.DemoAApplication',
|
|
454
|
+
classPath: '/tmp/none/target/classes',
|
|
455
|
+
javaExe: 'definitely-not-a-real-java-binary',
|
|
456
|
+
cwd: projectDir,
|
|
457
|
+
});
|
|
458
|
+
store.saveJava(projectDir, {
|
|
459
|
+
id: 'demo-b',
|
|
460
|
+
name: 'demo-b',
|
|
461
|
+
mainClass: 'com.example.DemoBApplication',
|
|
462
|
+
classPath: '/tmp/none/target/classes',
|
|
463
|
+
javaExe: 'definitely-not-a-real-java-binary',
|
|
464
|
+
cwd: projectDir,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const result = await service.startAllSavedJava(projectDir, ['demo-a', 'ghost']);
|
|
468
|
+
assert.equal(result.ok, false, '有失败项时整体 ok 为 false');
|
|
469
|
+
assert.equal(result.code, 'PARTIAL');
|
|
470
|
+
assert.equal(result.total, 2);
|
|
471
|
+
// ghost 不存在 → NOT_SAVED;demo-a 拉不起来 → 各自有失败原因
|
|
472
|
+
const ghost = result.items.find((item) => item.id === 'ghost');
|
|
473
|
+
assert.equal(ghost.code, 'NOT_SAVED');
|
|
474
|
+
const a = result.items.find((item) => item.id === 'demo-a');
|
|
475
|
+
assert.equal(a.ok, false, '假 java 路径不可能启动成功');
|
|
476
|
+
assert.ok(a.code || a.message, '失败项必须带原因');
|
|
477
|
+
// 关键:第一个失败没有阻止第二个被处理
|
|
478
|
+
assert.equal(result.items.length, 2, '失败不应中断整批');
|
|
479
|
+
assert.match(result.message, /成功 \d+ 个,跳过 \d+ 个,失败 \d+ 个/);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
await ok('一键启动跳过已在运行的服务', async () => {
|
|
483
|
+
// 直接往托管记录里塞一条 alive 的假记录(用当前进程的 pid,保证 isAlive 为 true)
|
|
484
|
+
service.javaRunner._put(projectDir, {
|
|
485
|
+
id: 'demo-b',
|
|
486
|
+
pid: process.pid,
|
|
487
|
+
mainClass: 'com.example.DemoBApplication',
|
|
488
|
+
logPath: path.join(homeDir, 'java-logs', 'demo-b.log'),
|
|
489
|
+
startedAt: new Date().toISOString(),
|
|
490
|
+
});
|
|
491
|
+
const result = await service.startAllSavedJava(projectDir, ['demo-b']);
|
|
492
|
+
const b = result.items.find((item) => item.id === 'demo-b');
|
|
493
|
+
assert.equal(b.skipped, true, JSON.stringify(b));
|
|
494
|
+
assert.equal(result.skipped, 1);
|
|
495
|
+
assert.equal(result.failed, 0);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
await ok('后台批次立刻返回 id,轮询能看到进度与结束状态', async () => {
|
|
499
|
+
const started = service.startSavedJavaBatch(projectDir, ['ghost']);
|
|
500
|
+
assert.equal(started.ok, true);
|
|
501
|
+
assert.ok(started.batchId, '必须立刻返回批次 id');
|
|
502
|
+
assert.equal(started.total, 1);
|
|
503
|
+
|
|
504
|
+
// 轮询到结束(失败也算结束)
|
|
505
|
+
let status;
|
|
506
|
+
for (let i = 0; i < 50; i += 1) {
|
|
507
|
+
status = service.startBatchStatus(started.batchId);
|
|
508
|
+
if (status.finished) break;
|
|
509
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
510
|
+
}
|
|
511
|
+
assert.equal(status.ok, true);
|
|
512
|
+
assert.equal(status.finished, true, JSON.stringify(status));
|
|
513
|
+
assert.equal(status.total, 1);
|
|
514
|
+
assert.ok(status.result, '结束后必须带上 result');
|
|
515
|
+
assert.equal(status.items.length, 1, '每个服务都要有一条进度记录');
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
await ok('查不存在的批次给出 NO_BATCH', () => {
|
|
519
|
+
const status = service.startBatchStatus('batch-does-not-exist');
|
|
520
|
+
assert.equal(status.ok, false);
|
|
521
|
+
assert.equal(status.code, 'NO_BATCH');
|
|
522
|
+
});
|
|
523
|
+
|
|
439
524
|
service.dispose();
|
|
440
525
|
server.close();
|
|
441
526
|
fs.rmSync(tmp, { recursive: true, force: true });
|