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.
package/test/smoke.mjs ADDED
@@ -0,0 +1,528 @@
1
+ /**
2
+ * 本地自测脚本(不依赖 cordis,直接跑 node test/smoke.mjs)。
3
+ * 覆盖:配置解析、模板展开、接口调用、日志增量、服务托管(用 node 起一个临时 HTTP 服务)。
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import http from 'node:http';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
+
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+ const root = path.resolve(here, '..');
14
+ const load = (rel) => import(pathToFileURL(path.join(root, rel)).href);
15
+
16
+ const { expandDollar, expandBraces, truncateText, normalizeProjectKey, isPidAlive, redactHeaders } = await load('src/util.js');
17
+ const { callApi } = await load('src/api.js');
18
+ const { LogReader } = await load('src/log.js');
19
+ const { ApiRunnerService } = await load('src/ops.js');
20
+ const { ProjectStore } = await load('src/project-store.js');
21
+ const { SettingsStore } = await load('src/settings.js');
22
+
23
+ let passed = 0;
24
+ function ok(name, fn) {
25
+ return Promise.resolve()
26
+ .then(fn)
27
+ .then(() => {
28
+ passed += 1;
29
+ console.log(` ✓ ${name}`);
30
+ })
31
+ .catch((error) => {
32
+ console.error(` ✗ ${name}\n ${error?.stack || error}`);
33
+ process.exitCode = 1;
34
+ });
35
+ }
36
+
37
+ // ---------- 临时工作区 ----------
38
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'api-runner-test-'));
39
+ const homeDir = path.join(tmp, 'home');
40
+ const projectDir = path.join(tmp, 'project');
41
+ fs.mkdirSync(homeDir, { recursive: true });
42
+ fs.mkdirSync(projectDir, { recursive: true });
43
+
44
+ // ---------- 起一个临时 HTTP 服务,作为"后端" ----------
45
+ const server = http.createServer((req, res) => {
46
+ const url = new URL(req.url, 'http://127.0.0.1');
47
+ if (url.pathname === '/echo') {
48
+ let body = '';
49
+ req.on('data', (chunk) => (body += chunk));
50
+ req.on('end', () => {
51
+ res.setHeader('Content-Type', 'application/json');
52
+ res.end(JSON.stringify({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), headers: req.headers, body }));
53
+ });
54
+ return;
55
+ }
56
+ if (url.pathname === '/users/7') {
57
+ res.setHeader('Content-Type', 'application/json');
58
+ res.end(JSON.stringify({ id: 7, name: '张三' }));
59
+ return;
60
+ }
61
+ if (url.pathname === '/health') {
62
+ res.statusCode = 200;
63
+ res.end('UP');
64
+ return;
65
+ }
66
+ // 模拟真实后端:token 藏在 data.access_token 里,而不是顶层 token
67
+ if (url.pathname === '/login') {
68
+ res.setHeader('Content-Type', 'application/json');
69
+ res.end(JSON.stringify({ code: 200, msg: null, data: { access_token: 'tok-abc-123456', expires_in: 720 } }));
70
+ return;
71
+ }
72
+ if (url.pathname === '/me') {
73
+ res.setHeader('Content-Type', 'application/json');
74
+ res.end(JSON.stringify({ ok: true, authorization: req.headers.authorization || null }));
75
+ return;
76
+ }
77
+ res.statusCode = 404;
78
+ res.end('not found');
79
+ });
80
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
81
+ const port = server.address().port;
82
+ const base = `http://127.0.0.1:${port}`;
83
+
84
+ // ---------- 写配置 ----------
85
+ const logPath = path.join(projectDir, 'app.log');
86
+ fs.writeFileSync(logPath, 'old line 1\nold line 2\n');
87
+
88
+ fs.writeFileSync(
89
+ path.join(homeDir, 'api-runner.yml'),
90
+ `defaults:
91
+ timeoutMs: 5000
92
+ env:
93
+ API_HOST: ${base}
94
+ apis:
95
+ - id: echo
96
+ name: 回显
97
+ method: POST
98
+ url: \${API_HOST}/echo
99
+ headers:
100
+ Content-Type: application/json
101
+ Authorization: "Bearer secret-token-123"
102
+ query:
103
+ fixed: "1"
104
+ body:
105
+ userId: "{id}"
106
+ - id: user
107
+ method: GET
108
+ url: \${API_HOST}/users/{id}
109
+ - id: plain
110
+ method: POST
111
+ url: \${API_HOST}/echo
112
+ body:
113
+ hello: world
114
+ - id: health
115
+ method: GET
116
+ url: \${API_HOST}/health
117
+ - id: login
118
+ method: POST
119
+ url: \${API_HOST}/login
120
+ body:
121
+ username: "{username}"
122
+ password: "{password}"
123
+ login:
124
+ tokenPath: token
125
+ header: Authorization
126
+ prefix: "Bearer "
127
+ - id: me
128
+ method: GET
129
+ url: \${API_HOST}/me
130
+ services:
131
+ - id: fake
132
+ mode: managed
133
+ cwd: ${projectDir.replace(/\\/g, '/')}
134
+ command: node
135
+ args: ["-e", "require('http').createServer((q,s)=>s.end('ok')).listen(${port})"]
136
+ startupTimeoutMs: 8000
137
+ health:
138
+ url: ${base}/health
139
+ expectStatus: 200
140
+ intervalMs: 300
141
+ logs:
142
+ - id: app-log
143
+ path: ./app.log
144
+ `,
145
+ 'utf8',
146
+ );
147
+
148
+ // ---------- 测试 ----------
149
+ console.log('util:');
150
+ await ok('expandDollar 用 scope 优先,其次 env', () => {
151
+ process.env.TEST_DOLLAR = 'from-env';
152
+ assert.equal(expandDollar('${NOPE}-${TEST_DOLLAR}', { NOPE: 'scope' }), 'scope-from-env');
153
+ assert.equal(expandDollar('${MISSING_VAR_XYZ}'), '${MISSING_VAR_XYZ}');
154
+ delete process.env.TEST_DOLLAR;
155
+ });
156
+ await ok('expandBraces 只替换已知键', () => {
157
+ assert.equal(expandBraces('/u/{id}/{other}', { id: 5 }), '/u/5/{other}');
158
+ });
159
+ await ok('truncateText 按字节截断并带提示', () => {
160
+ const text = 'a'.repeat(100) + '\n' + 'b'.repeat(100);
161
+ const out = truncateText(text, 50);
162
+ assert.ok(out.length < text.length);
163
+ assert.match(out, /已截断/);
164
+ });
165
+ await ok('normalizeProjectKey 统一盘符与斜杠', () => {
166
+ assert.equal(normalizeProjectKey('C:\\Code\\X\\'), 'c:/Code/X');
167
+ });
168
+ await ok('isPidAlive 对自身 pid 返回 true,对不存在返回 false', () => {
169
+ assert.equal(isPidAlive(process.pid), true);
170
+ assert.equal(isPidAlive(999_999), false);
171
+ });
172
+ await ok('redactHeaders 覆盖自定义敏感头与普通头', () => {
173
+ const out = redactHeaders({
174
+ Authorization: 'Bearer secret-token-123',
175
+ 'X-Token': 'demo-secret-123456',
176
+ 'X-Api-Key': 'abcdef1234567890',
177
+ 'Content-Type': 'application/json',
178
+ });
179
+ assert.match(out.Authorization, /^Bear\*\*\*-123$/);
180
+ assert.match(out['X-Token'], /^demo\*\*\*3456$/);
181
+ assert.match(out['X-Api-Key'], /^abcd\*\*\*7890$/);
182
+ assert.equal(out['Content-Type'], 'application/json');
183
+ });
184
+
185
+ console.log('config:');
186
+ const service = new ApiRunnerService({
187
+ homeDir,
188
+ projectStore: new ProjectStore({ homeDir }),
189
+ settingsStore: new SettingsStore({ homeDir }),
190
+ });
191
+ await ok('读取全局配置并解析出 api/service/log', () => {
192
+ const { config, warnings } = service.config(projectDir);
193
+ assert.deepEqual(warnings, []);
194
+ assert.equal(config.apis.length, 6);
195
+ assert.equal(config.apis[0].url, `${base}/echo`.replace(base, base)); // 已展开 ${API_HOST}
196
+ assert.equal(config.apis[0].timeoutMs, 5000);
197
+ assert.equal(config.services[0].mode, 'managed');
198
+ assert.equal(config.logs[0].path, path.join(projectDir, 'app.log'));
199
+ });
200
+ await ok('findApi 支持 id 与 name', () => {
201
+ const { config } = service.config(projectDir);
202
+ assert.equal(service.configStore.findApi(config, 'echo').id, 'echo');
203
+ assert.equal(service.configStore.findApi(config, '回显').id, 'echo');
204
+ });
205
+
206
+ console.log('api:');
207
+ await ok('call 展开路径占位并拼 query,body 模板生效', async () => {
208
+ const result = await service.call(projectDir, 'echo', { id: 'u-1', extra: 'x' });
209
+ assert.equal(result.ok, true, result.error);
210
+ const json = JSON.parse(result.json ? JSON.stringify(result.json) : '{}');
211
+ assert.equal(json.body, JSON.stringify({ userId: 'u-1' }));
212
+ assert.equal(json.query.fixed, '1');
213
+ assert.equal(json.query.extra, 'x');
214
+ assert.equal(json.headers.authorization, 'Bearer secret-token-123');
215
+ // JSON body 必须自带 application/json,否则 Spring 端会报
216
+ // "Content type 'text/plain;charset=UTF-8' not supported"
217
+ assert.match(json.headers['content-type'], /^application\/json/);
218
+ });
219
+ await ok('未声明 Content-Type 的 JSON body 自动补 application/json', async () => {
220
+ const result = await service.call(projectDir, 'plain', {});
221
+ assert.equal(result.ok, true, result.error);
222
+ const json = JSON.parse(result.body);
223
+ assert.match(json.headers['content-type'], /^application\/json/);
224
+ assert.equal(json.body, JSON.stringify({ hello: 'world' }));
225
+ });
226
+ await ok('call 返回里请求头已脱敏', async () => {
227
+ const result = await service.call(projectDir, 'echo', { id: 1 });
228
+ assert.match(result.request.headers.Authorization, /^Bear\*\*\*-123$/);
229
+ });
230
+ await ok('URL 路径占位替换', async () => {
231
+ const result = await service.call(projectDir, 'user', { id: 7 });
232
+ assert.equal(result.ok, true, result.error);
233
+ assert.equal(result.json.name, '张三');
234
+ });
235
+ await ok('404 返回 ok:false 且保留响应体', async () => {
236
+ const result = await service.call(projectDir, 'nope', {});
237
+ assert.equal(result.ok, false);
238
+ assert.equal(result.code, 'API_NOT_FOUND');
239
+ });
240
+ await ok('超时返回结构化错误', async () => {
241
+ const { config } = service.config(projectDir);
242
+ const api = { ...service.configStore.findApi(config, 'health'), url: `${base}/health`, timeoutMs: 1 };
243
+ // 直接把 timeoutMs 压到 1ms,绝大多数情况下会超时;若没超时也不算失败
244
+ const result = await callApi(api, {}, { timeoutMs: 1 });
245
+ assert.ok(result.ok || /超时|timeout/i.test(result.error || ''), JSON.stringify(result));
246
+ });
247
+
248
+ console.log('login:');
249
+ await ok('tokenPath 配错但响应里只有一个候选时自动探测并保存', async () => {
250
+ const result = await service.login(projectDir, 'login', { username: 'alice', password: 'p@ss' });
251
+ assert.equal(result.ok, true, JSON.stringify(result));
252
+ assert.equal(result.tokenPath, 'data.access_token');
253
+ assert.match(result.masked, /\*\*\*/);
254
+ assert.match(result.message, /自动改用/);
255
+ });
256
+ await ok('保存的 token 会被后续接口自动注入', async () => {
257
+ const result = await service.call(projectDir, 'me', {});
258
+ assert.equal(result.ok, true, result.error);
259
+ assert.equal(result.json.authorization, 'Bearer tok-abc-123456');
260
+ assert.equal(result.token.from, 'login');
261
+ assert.match(result.request.headers.Authorization, /^Bear\*\*\*3456$/);
262
+ });
263
+ await ok('YAML 接口不写回配置文件(只对面板接口写回),再次登录仍提示', async () => {
264
+ const result = await service.login(projectDir, 'login', {});
265
+ assert.equal(result.ok, true, JSON.stringify(result));
266
+ // 接口来自 YAML 文件,插件不擅自改用户的配置文件,所以每次都提示一次
267
+ assert.match(result.message, /自动改用/);
268
+ const raw = fs.readFileSync(path.join(homeDir, 'api-runner.yml'), 'utf8');
269
+ assert.match(raw, /tokenPath: token/, 'YAML 文件不应被改写');
270
+ });
271
+
272
+ await ok('面板接口的 tokenPath 会自动写回配置,第二次登录不再提示', async () => {
273
+ service.saveApiUi(projectDir, {
274
+ id: 'ui-login',
275
+ name: '面板登录',
276
+ method: 'POST',
277
+ url: `${base}/login`,
278
+ body: { username: '{username}', password: '{password}' },
279
+ login: { tokenPath: 'token', header: 'Authorization', prefix: 'Bearer ' },
280
+ });
281
+ const first = await service.login(projectDir, 'ui-login', {});
282
+ assert.equal(first.ok, true, JSON.stringify(first));
283
+ assert.match(first.message, /自动改用/);
284
+ const stored = service.projectStore.project(projectDir).apis.find((item) => item.id === 'ui-login');
285
+ assert.equal(stored.login.tokenPath, 'data.access_token');
286
+ const second = await service.login(projectDir, 'ui-login', {});
287
+ assert.equal(second.ok, true, JSON.stringify(second));
288
+ assert.doesNotMatch(second.message, /自动改用/);
289
+ service.deleteApiUi(projectDir, 'ui-login');
290
+ });
291
+
292
+ console.log('面板按项目分类:');
293
+ const otherProjectDir = path.join(tmp, 'another-project');
294
+ fs.mkdirSync(otherProjectDir, { recursive: true });
295
+ await ok('项目页签列出所有已知项目,会话工作区排第一', async () => {
296
+ service.saveApiUi(otherProjectDir, { id: 'other-api', name: '别处的接口', method: 'GET', url: `${base}/users/{id}` });
297
+ const state = await service.uiState(projectDir);
298
+ assert.equal(state.ok, true);
299
+ assert.equal(state.projectKey, service.projectStore.keyOf(projectDir));
300
+ assert.equal(state.cwdProjectKey, service.projectStore.keyOf(projectDir));
301
+ const keys = state.projects.map((item) => item.key);
302
+ assert.equal(keys[0], state.projectKey, `当前项目排第一: ${JSON.stringify(keys)}`);
303
+ // 本项目也在清单里(页签要能标出来),不是「排除自己」
304
+ assert.ok(keys.includes(state.projectKey));
305
+ const other = state.projects.find((item) => item.key === service.projectStore.keyOf(otherProjectDir));
306
+ assert.ok(other, `别的项目要在清单里: ${JSON.stringify(keys)}`);
307
+ assert.equal(other.apiCount, 1);
308
+ // 清单只给摘要,不带接口正文与 token
309
+ assert.equal(Object.prototype.hasOwnProperty.call(other, 'apis'), false);
310
+ assert.equal(JSON.stringify(state.projects).includes('masked'), false);
311
+ });
312
+
313
+ await ok('没有接口的项目也出现在清单里(否则切不过去)', async () => {
314
+ const emptyDir = path.join(tmp, 'empty-project');
315
+ fs.mkdirSync(emptyDir, { recursive: true });
316
+ service.projectStore.update(emptyDir, () => {});
317
+ const state = await service.uiState(projectDir);
318
+ const hit = state.projects.find((item) => item.key === service.projectStore.keyOf(emptyDir));
319
+ assert.ok(hit, JSON.stringify(state.projects.map((item) => item.key)));
320
+ assert.equal(hit.apiCount, 0);
321
+ });
322
+
323
+ await ok('设置里的默认工作区也算已知项目', async () => {
324
+ const workspaceDir = path.join(tmp, 'workspace-project');
325
+ fs.mkdirSync(workspaceDir, { recursive: true });
326
+ service.saveSettings({ workspace: workspaceDir });
327
+ const state = await service.uiState(projectDir);
328
+ const hit = state.projects.find((item) => item.key === service.projectStore.keyOf(workspaceDir));
329
+ assert.ok(hit, JSON.stringify(state.projects.map((item) => item.key)));
330
+ assert.ok(hit.origins.includes('settings'));
331
+ service.saveSettings({ workspace: '' });
332
+ });
333
+
334
+ await ok('选中别的项目就切到那个项目:接口、projectKey 都换', async () => {
335
+ const otherKey = service.projectStore.keyOf(otherProjectDir);
336
+ const state = await service.uiState(projectDir, { project: otherKey });
337
+ assert.equal(state.ok, true);
338
+ assert.equal(state.projectKey, otherKey);
339
+ // 会话工作区的 key 仍然给出来,前端才能把那一档标成「当前工作区」
340
+ assert.equal(state.cwdProjectKey, service.projectStore.keyOf(projectDir));
341
+ // 全局 YAML 里的接口在每个项目都可见;「面板里配的」只应有那个项目自己的
342
+ const panelIds = state.apis.filter((api) => api.source === 'ui').map((api) => api.id);
343
+ assert.deepEqual(panelIds, ['other-api']);
344
+ const other = state.apis.find((api) => api.id === 'other-api');
345
+ assert.deepEqual(other.params, ['id']);
346
+ });
347
+
348
+ await ok('切项目后保存的接口写进那个项目的桶,不污染当前项目', async () => {
349
+ const otherKey = service.projectStore.keyOf(otherProjectDir);
350
+ service.saveApiUi(otherProjectDir, { id: 'other-api-2', name: '第二个', method: 'POST', url: '/x' });
351
+ const other = service.projectStore.project(otherProjectDir);
352
+ assert.deepEqual(other.apis.map((api) => api.id).sort(), ['other-api', 'other-api-2']);
353
+ const here = service.projectStore.project(projectDir);
354
+ assert.equal(here.apis.some((api) => String(api.id).startsWith('other-api')), false);
355
+ assert.equal(otherKey, service.projectStore.keyOf(otherProjectDir));
356
+ service.deleteApiUi(otherProjectDir, 'other-api-2');
357
+ });
358
+
359
+ console.log('log:');
360
+ await ok('首次读取只取末尾一段', () => {
361
+ const result = service.readLog(projectDir, 'app-log', {});
362
+ assert.equal(result.ok, true, result.message);
363
+ assert.match(result.text, /old line 2/);
364
+ assert.match(result.note, /首次读取/);
365
+ });
366
+ await ok('追加内容后增量读取只返回新增行', () => {
367
+ fs.appendFileSync(logPath, 'new A\nnew B\n');
368
+ const result = service.readLog(projectDir, 'app-log', {});
369
+ assert.equal(result.text.trim(), 'new A\nnew B');
370
+ assert.equal(result.totalLines, 2);
371
+ });
372
+ await ok('pattern 过滤生效', () => {
373
+ fs.appendFileSync(logPath, 'ERROR boom\nINFO fine\n');
374
+ const result = service.readLog(projectDir, 'app-log', { pattern: 'ERROR' });
375
+ assert.match(result.text, /ERROR boom/);
376
+ assert.doesNotMatch(result.text, /INFO fine/);
377
+ });
378
+ await ok('reset 从头读取', () => {
379
+ const result = service.readLog(projectDir, 'app-log', { reset: true, maxLines: 100 });
380
+ assert.match(result.text, /old line 1/);
381
+ });
382
+ await ok('wait_log 能在超时内等到新出现的特征', async () => {
383
+ setTimeout(() => fs.appendFileSync(logPath, 'Started Application in 3.2 seconds\n'), 300);
384
+ const result = await service.waitLog(projectDir, 'app-log', { pattern: 'Started Application', timeoutMs: 5000, fromOffset: fs.statSync(logPath).size });
385
+ assert.equal(result.ok, true);
386
+ assert.equal(result.matched, true, JSON.stringify(result));
387
+ });
388
+ await ok('文件截断时自动从头读', () => {
389
+ fs.writeFileSync(logPath, 'truncated first line\n');
390
+ const result = service.readLog(projectDir, 'app-log', {});
391
+ assert.match(result.note, /截断/);
392
+ assert.match(result.text, /truncated first line/);
393
+ });
394
+
395
+ console.log('service:');
396
+ await ok('status 能识别 observe/managed 与健康状态', async () => {
397
+ const status = await service.serviceStatus(projectDir, 'fake');
398
+ assert.equal(status.ok, true);
399
+ assert.equal(status.mode, 'managed');
400
+ assert.ok(Array.isArray(status.checks));
401
+ });
402
+ await ok('observe 模式拒绝 start/restart', async () => {
403
+ fs.writeFileSync(
404
+ path.join(homeDir, 'api-runner.yml'),
405
+ fs.readFileSync(path.join(homeDir, 'api-runner.yml'), 'utf8').replace('mode: managed', 'mode: observe'),
406
+ 'utf8',
407
+ );
408
+ const result = await service.serviceAction(projectDir, 'fake', 'restart', {});
409
+ assert.equal(result.ok, false);
410
+ assert.equal(result.reason, 'OBSERVE_MODE');
411
+ fs.writeFileSync(
412
+ path.join(homeDir, 'api-runner.yml'),
413
+ fs.readFileSync(path.join(homeDir, 'api-runner.yml'), 'utf8').replace('mode: observe', 'mode: managed'),
414
+ 'utf8',
415
+ );
416
+ });
417
+ await ok('verify 串起 restart+log+call', async () => {
418
+ const result = await service.verify(projectDir, { service: 'fake', apiId: 'health', logId: 'app-log', restart: false });
419
+ assert.equal(result.steps.length >= 2, true, JSON.stringify(result));
420
+ const call = result.steps.find((step) => step.stage === 'call');
421
+ assert.equal(call.ok, true, JSON.stringify(call));
422
+ });
423
+
424
+ console.log('prompt:');
425
+ await ok('describe 不泄漏 header 值', () => {
426
+ const info = service.describe(projectDir);
427
+ const text = JSON.stringify(info);
428
+ assert.doesNotMatch(text, /secret-token-123/);
429
+ assert.ok(info.apis.some((api) => api.id === 'echo'));
430
+ });
431
+ await ok('describe 展示完整地址(含路径)并列出占位符', () => {
432
+ const info = service.describe(projectDir);
433
+ const echo = info.apis.find((api) => api.id === 'echo');
434
+ assert.match(echo.url, /\/echo$/);
435
+ // id 来自 body 模板,fixed 来自 query(query 默认值可由 params 覆盖)
436
+ assert.deepEqual(echo.params.sort(), ['fixed', 'id']);
437
+ });
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
+
524
+ service.dispose();
525
+ server.close();
526
+ fs.rmSync(tmp, { recursive: true, force: true });
527
+
528
+ console.log(`\n${process.exitCode ? 'FAILED' : 'PASSED'}: ${passed} checks`);