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,162 @@
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/java.mjs ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * 「接管 IDEA 服务」真实进程自测。
3
+ *
4
+ * 单测没法覆盖这条链路的关键点,所以真的起一个 Java 进程当替身:
5
+ * 1. 用 javac 编译一个自带 HTTP 服务的 FakeService(模仿 IDEA 启动:带 jdwp 参数);
6
+ * 2. 用 java 直接拉起它 → 这就是「IDEA 起的那个进程」;
7
+ * 3. 调 JavaRunner.start 接管:快照 → 杀旧进程 → 用去掉调试参数的方式重新拉起;
8
+ * 4. 验证新进程能讲 HTTP、日志文件有内容、面板能区分 owner;
9
+ * 5. 验证 stop 只停插件自己的、restart 换 pid。
10
+ *
11
+ * 依赖本机 JDK。找不到 java/javac 时整体跳过(不算失败),
12
+ * 否则 CI 机器上没装 JDK 会一片红。
13
+ */
14
+ import assert from 'node:assert/strict';
15
+ import fs from 'node:fs';
16
+ import net from 'node:net';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { execFileSync, spawnSync } from 'node:child_process';
20
+ import { pathToFileURL, fileURLToPath } from 'node:url';
21
+
22
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const load = (rel) => import(pathToFileURL(path.join(root, rel)).href);
24
+
25
+ let passed = 0;
26
+ async function ok(name, fn) {
27
+ try {
28
+ await fn();
29
+ passed += 1;
30
+ console.log(` ✓ ${name}`);
31
+ } catch (error) {
32
+ console.error(` ✗ ${name}\n ${error?.stack || error}`);
33
+ process.exitCode = 1;
34
+ }
35
+ }
36
+
37
+ async function freePort() {
38
+ const server = net.createServer();
39
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
40
+ const port = server.address().port;
41
+ await new Promise((r) => server.close(r));
42
+ return port;
43
+ }
44
+
45
+ /** 找一个可用的 JDK(JAVA_HOME 优先,其次 PATH 上的 java/javac) */
46
+ function findJdk() {
47
+ const exe = process.platform === 'win32' ? '.exe' : '';
48
+ const names = (home) => ({ home, java: path.join(home, 'bin', `java${exe}`), javac: path.join(home, 'bin', `javac${exe}`) });
49
+ const candidates = [process.env.JAVA_HOME].filter(Boolean).map(names);
50
+ // macOS 下 JAVA_HOME 常为空,用系统自带的 java_home 工具问一下
51
+ if (process.platform === 'darwin') {
52
+ try {
53
+ const detected = execFileSync('/usr/libexec/java_home', { encoding: 'utf8' }).trim();
54
+ if (detected) candidates.push(names(detected));
55
+ } catch { /* 没装 JDK 就走下面的跳过分支 */ }
56
+ }
57
+ for (const item of candidates) {
58
+ if (fs.existsSync(item.java) && fs.existsSync(item.javac)) return item;
59
+ }
60
+ // 最后退回 PATH:先问 java 自己 java.home 在哪,必须用它 bin 下的真身。
61
+ // 坑:`C:\Program Files\Common Files\Oracle\Java\javapath\java.exe` 只是个转发壳,
62
+ // 它会再起一个子进程跑真正的 JVM,于是 spawn 拿到的 pid 是壳的、jcmd 附加到壳上会报
63
+ // AttachNotSupportedException: jvm.dll not loaded by target process。
64
+ try {
65
+ execFileSync('javac', ['-version'], { stdio: 'ignore' });
66
+ // 注意:`-XshowSettings:properties` 的内容打在 stderr,不是 stdout
67
+ const probe = spawnSync('java', ['-XshowSettings:properties', '-version'], { encoding: 'utf8' });
68
+ const found = /java\.home = (.+)/.exec(`${probe.stdout || ''}\n${probe.stderr || ''}`);
69
+ if (found) {
70
+ const real = names(found[1].trim());
71
+ if (fs.existsSync(real.java) && fs.existsSync(real.javac)) return real;
72
+ }
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ const jdk = findJdk();
80
+ if (!jdk) {
81
+ console.log('java runner: SKIPPED(本机没有 JDK,跳过接管链路自测)');
82
+ process.exit(0);
83
+ }
84
+
85
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'api-runner-java-'));
86
+ const homeDir = path.join(tmp, 'home');
87
+ const cwd = tmp;
88
+ const port = await freePort();
89
+ const debugPort = await freePort();
90
+
91
+ // 替身服务:只用 com.sun.net.httpserver(JDK 自带,不依赖第三方 jar)
92
+ const source = path.join(tmp, 'FakeService.java');
93
+ fs.writeFileSync(
94
+ source,
95
+ `import com.sun.net.httpserver.HttpServer;
96
+ import java.net.InetSocketAddress;
97
+ import java.nio.charset.StandardCharsets;
98
+ public class FakeService {
99
+ public static void main(String[] args) throws Exception {
100
+ int port = Integer.parseInt(System.getProperty("fake.port", "${port}"));
101
+ HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
102
+ server.createContext("/", exchange -> {
103
+ byte[] body = ("{\\"ok\\":true,\\"port\\":" + port + "}").getBytes(StandardCharsets.UTF_8);
104
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
105
+ exchange.sendResponseHeaders(200, body.length);
106
+ exchange.getResponseBody().write(body);
107
+ exchange.close();
108
+ });
109
+ server.start();
110
+ System.out.println("fake service started on " + port);
111
+ Thread.currentThread().join();
112
+ }
113
+ }
114
+ `,
115
+ 'utf8',
116
+ );
117
+
118
+ let compiled = true;
119
+ try {
120
+ execFileSync(jdk.javac, ['-encoding', 'UTF-8', '-d', tmp, source], { stdio: 'ignore', timeout: 60_000 });
121
+ } catch (error) {
122
+ compiled = false;
123
+ console.error(' ! javac 编译失败,跳过接管测试', error?.message);
124
+ }
125
+
126
+ if (compiled) {
127
+ const { JavaRunner } = await load('src/java-runner.js');
128
+ const { isAlive, getJavaSnapshot, buildRestartArgv, killProcessTree, compileModule } = await load('src/restart.js');
129
+ const { listJavaProcesses } = await load('src/java.js');
130
+
131
+ const { spawn } = await import('node:child_process');
132
+ const runner = new JavaRunner({ homeDir });
133
+ let sourcePid = 0;
134
+
135
+ // 模仿 IDEA:把调试参数放在最前面(suspend=n 才能自动化)
136
+ const ideaLikeArgs = [
137
+ `-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:${debugPort}`,
138
+ `-Dfake.port=${port}`,
139
+ '-Dspring.output.ansi.enabled=always',
140
+ '-cp', tmp,
141
+ 'FakeService',
142
+ ];
143
+
144
+ console.log('java runner (接管 IDEA 进程):');
145
+ await ok('替身服务能启动并响应 HTTP(模拟 IDEA 起的进程)', async () => {
146
+ const child = spawn(jdk.java, ideaLikeArgs, { cwd: tmp, stdio: 'ignore', detached: true, windowsHide: true });
147
+ child.unref();
148
+ sourcePid = child.pid;
149
+ assert.ok(sourcePid > 0);
150
+ const deadline = Date.now() + 20_000;
151
+ let body = '';
152
+ while (Date.now() < deadline) {
153
+ // eslint-disable-next-line no-await-in-loop
154
+ const result = await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()).catch(() => '');
155
+ if (result) {
156
+ body = result;
157
+ break;
158
+ }
159
+ // eslint-disable-next-line no-await-in-loop
160
+ await new Promise((r) => setTimeout(r, 300));
161
+ }
162
+ assert.match(body, /"ok":true/);
163
+ });
164
+
165
+ await ok('getJavaSnapshot 能读出主类/classpath/工作目录', async () => {
166
+ const snapshot = await getJavaSnapshot(sourcePid);
167
+ assert.equal(snapshot.ok, true, snapshot.message);
168
+ assert.equal(snapshot.mainClass, 'FakeService');
169
+ assert.equal(path.resolve(snapshot.cwd).toLowerCase(), path.resolve(tmp).toLowerCase());
170
+ assert.match(snapshot.classPath, /api-runner-java-/);
171
+ assert.equal(snapshot.programArgs.length, 0);
172
+ });
173
+
174
+ await ok('classpath 取完整值(jcmd VM.command_line 会截断,必须用 system_properties)', async () => {
175
+ // 真实案例:某 Spring Cloud 服务有 218 条依赖、21335 字符,jcmd VM.command_line
176
+ // 只返回了 1998 字符(前 19 条),照它重启会缺 jar。这里造一个超长 classpath 复现。
177
+ const pad = path.join(tmp, 'x'.repeat(200), 'pad.jar');
178
+ const hugeCp = Array.from({ length: 12 }, (_unused, index) => (index % 2 ? pad : tmp)).join(';');
179
+ const otherPort = await freePort();
180
+ const longJava = spawn(jdk.java, [`-Dfake.port=${otherPort}`, '-cp', hugeCp, 'FakeService'], {
181
+ cwd: tmp,
182
+ stdio: 'ignore',
183
+ detached: true,
184
+ windowsHide: true,
185
+ });
186
+ longJava.unref();
187
+ try {
188
+ // jcmd 对新进程要等一下才能附加
189
+ let snapshot;
190
+ const until = Date.now() + 10_000;
191
+ while (Date.now() < until) {
192
+ // eslint-disable-next-line no-await-in-loop
193
+ snapshot = await getJavaSnapshot(longJava.pid);
194
+ if (snapshot.ok) break;
195
+ // eslint-disable-next-line no-await-in-loop
196
+ await new Promise((r) => setTimeout(r, 400));
197
+ }
198
+ assert.equal(snapshot.ok, true, snapshot.message);
199
+ assert.equal(snapshot.classPath, hugeCp, 'classpath 必须与启动时完全一致,不能被截断');
200
+ } finally {
201
+ await killProcessTree(longJava.pid);
202
+ }
203
+ });
204
+
205
+ await ok('buildRestartArgv 剥掉 suspend=y 的调试参数,改成可远程挂载的 server=y', async () => {
206
+ const snapshot = await getJavaSnapshot(sourcePid);
207
+ const built = buildRestartArgv(snapshot, { debug: 'port', debugPort });
208
+ assert.ok(built.argv.some((arg) => /-agentlib:jdwp=[^\s]*server=y,suspend=n/.test(arg)), '应带 server=y,suspend=n');
209
+ assert.ok(!built.argv.some((arg) => /suspend=y/.test(arg)), '不应残留 suspend=y');
210
+ assert.equal(built.argv[built.argv.length - 1], 'FakeService', '主类必须是最后一个参数');
211
+ });
212
+
213
+ await ok('start 接管:杀掉 IDEA 的进程,用同样参数拉起新的,端口恢复可用', async () => {
214
+ const result = await runner.start(cwd, { pid: sourcePid, port, compile: false, timeoutMs: 40_000 });
215
+ assert.equal(result.ok, true, JSON.stringify(result));
216
+ assert.notEqual(result.pid, sourcePid, '应产生新 pid');
217
+ assert.equal(isAlive(sourcePid), false, '原 IDEA 进程应已退出');
218
+ assert.equal(isAlive(result.pid), true);
219
+ const body = await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text());
220
+ assert.match(body, /"ok":true/);
221
+ });
222
+
223
+ await ok('日志文件里有启动输出(面板「详情」读的就是它)', async () => {
224
+ const runs = runner.runs(cwd);
225
+ assert.equal(runs.length, 1);
226
+ const run = runs[0];
227
+ assert.equal(run.id, 'FakeService');
228
+ const log = runner.logs(cwd, 'FakeService', { fromOffset: 0 });
229
+ assert.equal(log.ok, true);
230
+ assert.match(log.text, /fake service started on \d+/);
231
+ assert.equal(log.alive, true);
232
+ assert.equal(log.pid, run.pid);
233
+ });
234
+
235
+ await ok('日志支持按偏移增量读取', async () => {
236
+ const first = runner.logs(cwd, 'FakeService', { fromOffset: 0 });
237
+ const second = runner.logs(cwd, 'FakeService', { fromOffset: first.nextOffset });
238
+ assert.equal(second.fromOffset, first.nextOffset);
239
+ assert.equal(second.text, '', '没有新内容时增量应为空');
240
+ assert.equal(second.remainingBytes, 0);
241
+ });
242
+
243
+ await ok('listJavaProcesses 能识别插件托管的进程(带 -DdshApiRunner.runId)', async () => {
244
+ const list = await listJavaProcesses();
245
+ const mine = list.processes.find((item) => item.pid === runner.runs(cwd)[0].pid);
246
+ assert.ok(mine, 'jps 应能看到插件拉起的进程');
247
+ assert.match(mine.systemProperties['dshApiRunner.runId'] || '', /FakeService/);
248
+ });
249
+
250
+ await ok('stop 只停插件自己起的进程,停止后记录仍在(可再次启动)', async () => {
251
+ const pid = runner.runs(cwd)[0].pid;
252
+ const result = await runner.stop(cwd, 'FakeService');
253
+ assert.equal(result.ok, true, JSON.stringify(result));
254
+ await new Promise((r) => setTimeout(r, 400));
255
+ assert.equal(isAlive(pid), false);
256
+ const runs = runner.runs(cwd);
257
+ assert.equal(runs.length, 1, '记录应保留,便于看历史日志/再启动');
258
+ assert.equal(runs[0].alive, false);
259
+ assert.ok(runs[0].stoppedAt, '应记录停止时间');
260
+ });
261
+
262
+ await ok('stop 对不存在的服务给出可读提示', async () => {
263
+ const result = await runner.stop(cwd, 'NotExistService');
264
+ assert.equal(result.ok, false);
265
+ assert.equal(result.code, 'RUN_NOT_FOUND');
266
+ assert.match(result.message, /IDEA/);
267
+ });
268
+
269
+ await ok('restart 能在停止状态下直接拉起(换新 pid)', async () => {
270
+ const before = runner.runs(cwd)[0].pid;
271
+ const result = await runner.restart(cwd, 'FakeService', { compile: false, timeoutMs: 40_000 });
272
+ assert.equal(result.ok, true, JSON.stringify(result));
273
+ assert.notEqual(result.pid, before, '重启应换 pid');
274
+ const body = await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text());
275
+ assert.match(body, /"ok":true/);
276
+ });
277
+
278
+ await ok('编译:mvn 路径含空格时能正确执行(cmd.exe 引号处理)', async () => {
279
+ // IDEA 自带的 mvn 路径通常含空格(<安装目录>\plugins\maven\lib\maven3\bin\mvn.cmd),
280
+ // 拼命令行时少一层引号就会把路径截断到第一个空格。这里用一个必然失败但
281
+ // 语法合法的 mvn 替身脚本验证「命令真的被调起来过」而不是「路径被截断」。
282
+ const fakeMvn = path.join(tmp, 'with space', 'mvn.cmd');
283
+ fs.mkdirSync(path.dirname(fakeMvn), { recursive: true });
284
+ fs.writeFileSync(fakeMvn, '@echo off\r\necho FAKE_MVN_RAN %*\r\nexit /b 0\r\n', 'utf8');
285
+ const result = await compileModule({ mvn: fakeMvn, rootDir: tmp, artifactId: 'demo', timeoutMs: 30_000 });
286
+ assert.equal(result.ok, true, JSON.stringify(result));
287
+ assert.match(result.output || '', /FAKE_MVN_RAN/, '替身脚本应被执行到');
288
+ });
289
+
290
+ await ok('清理:停掉测试进程', async () => {
291
+ const run = runner.runs(cwd)[0];
292
+ if (run && isAlive(run.pid)) await killProcessTree(run.pid);
293
+ assert.equal(isAlive(run.pid), false);
294
+ });
295
+ }
296
+
297
+ fs.rmSync(tmp, { recursive: true, force: true });
298
+ console.log(`\n${process.exitCode ? 'FAILED' : 'PASSED'}: ${passed} checks`);
@@ -0,0 +1,137 @@
1
+ /**
2
+ * managed 模式真实进程自测:起一个 detached 的 node HTTP 服务,验证
3
+ * 启动 → 健康检查 → 日志文件落盘 → 重启(pid 变化)→ 停止(进程消失)。
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import net from 'node:net';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
+
12
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
+ const load = (rel) => import(pathToFileURL(path.join(root, rel)).href);
14
+ const { ServiceManager } = await load('src/service.js');
15
+ const { isPidAlive } = await load('src/util.js');
16
+
17
+ let passed = 0;
18
+ async function ok(name, fn) {
19
+ try {
20
+ await fn();
21
+ passed += 1;
22
+ console.log(` ✓ ${name}`);
23
+ } catch (error) {
24
+ console.error(` ✗ ${name}\n ${error?.stack || error}`);
25
+ process.exitCode = 1;
26
+ }
27
+ }
28
+
29
+ async function freePort() {
30
+ const server = net.createServer();
31
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
32
+ const port = server.address().port;
33
+ await new Promise((r) => server.close(r));
34
+ return port;
35
+ }
36
+
37
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'api-runner-svc-'));
38
+ const statePath = path.join(tmp, 'state.json');
39
+ const logPath = path.join(tmp, 'svc.log');
40
+ const scriptPath = path.join(tmp, 'server.cjs');
41
+ const port = await freePort();
42
+
43
+ fs.writeFileSync(
44
+ scriptPath,
45
+ `const http = require('http');
46
+ const port = Number(process.argv[2]);
47
+ http.createServer((req, res) => {
48
+ res.setHeader('Content-Type', 'application/json');
49
+ res.end(JSON.stringify({ pid: process.pid }));
50
+ }).listen(port, '127.0.0.1', () => {
51
+ console.log('listening on ' + port);
52
+ setInterval(() => {}, 1000);
53
+ });
54
+ process.on('SIGTERM', () => { console.log('got SIGTERM'); process.exit(0); });
55
+ `,
56
+ 'utf8',
57
+ );
58
+
59
+ const service = {
60
+ id: 'demo',
61
+ name: '演示服务',
62
+ mode: 'managed',
63
+ cwd: tmp,
64
+ command: process.execPath,
65
+ args: [scriptPath, String(port)],
66
+ env: {},
67
+ startupTimeoutMs: 20_000,
68
+ stopTimeoutMs: 8_000,
69
+ killTree: true,
70
+ logPath,
71
+ health: { url: `http://127.0.0.1:${port}/`, method: 'GET', expectStatus: 200, timeoutMs: 2000, intervalMs: 300, ports: [port], host: '127.0.0.1' },
72
+ };
73
+
74
+ const manager = new ServiceManager({ statePath });
75
+
76
+ console.log('managed service:');
77
+ let firstPid = 0;
78
+ await ok('check 在未启动时报告不健康', async () => {
79
+ const health = await manager.check(service);
80
+ assert.equal(health.ok, false);
81
+ });
82
+ await ok('start 能拉起进程并通过健康检查', async () => {
83
+ const result = await manager.start(service, {});
84
+ assert.equal(result.ok, true, JSON.stringify(result));
85
+ assert.equal(typeof result.pid, 'number');
86
+ assert.ok(isPidAlive(result.pid));
87
+ firstPid = result.pid;
88
+ });
89
+ await ok('子进程 stdout 写入配置的 logPath', async () => {
90
+ await new Promise((r) => setTimeout(r, 500));
91
+ assert.ok(fs.existsSync(logPath), '日志文件应存在');
92
+ assert.match(fs.readFileSync(logPath, 'utf8'), /listening on \d+/);
93
+ });
94
+ await ok('重复 start 幂等(不重复拉起)', async () => {
95
+ const result = await manager.start(service, {});
96
+ assert.equal(result.reason, 'ALREADY_RUNNING');
97
+ assert.equal(result.pid, firstPid);
98
+ });
99
+ await ok('restart 后 pid 变化且旧进程退出', async () => {
100
+ const result = await manager.restart(service, {});
101
+ assert.equal(result.ok, true, JSON.stringify(result));
102
+ assert.notEqual(result.start.pid, firstPid, 'restart 应产生新 pid');
103
+ assert.equal(isPidAlive(firstPid), false, '旧进程应已退出');
104
+ firstPid = result.start.pid;
105
+ });
106
+ await ok('stop 后进程退出且健康检查失败', async () => {
107
+ const result = await manager.stop(service);
108
+ assert.equal(result.ok, true, JSON.stringify(result));
109
+ await new Promise((r) => setTimeout(r, 300));
110
+ assert.equal(isPidAlive(firstPid), false);
111
+ const health = await manager.check(service);
112
+ assert.equal(health.ok, false);
113
+ });
114
+ await ok('restart 能在停止状态下直接拉起(自恢复)', async () => {
115
+ const result = await manager.restart(service, {});
116
+ assert.equal(result.ok, true, JSON.stringify(result));
117
+ firstPid = result.start.pid;
118
+ });
119
+ await ok('状态文件记录 pid,dispose 不会杀进程', async () => {
120
+ manager.dispose();
121
+ assert.ok(fs.existsSync(statePath));
122
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
123
+ assert.equal(state.services.demo.pid, firstPid);
124
+ assert.equal(isPidAlive(firstPid), true, 'dispose 后进程应继续存活');
125
+ });
126
+ await ok('新实例能从状态文件接管并停止旧进程', async () => {
127
+ const manager2 = new ServiceManager({ statePath });
128
+ const status = await manager2.statusAll([service]);
129
+ assert.equal(status[0].pid, firstPid);
130
+ const result = await manager2.stop(service);
131
+ assert.equal(result.ok, true, JSON.stringify(result));
132
+ await new Promise((r) => setTimeout(r, 300));
133
+ assert.equal(isPidAlive(firstPid), false);
134
+ });
135
+
136
+ fs.rmSync(tmp, { recursive: true, force: true });
137
+ console.log(`\n${process.exitCode ? 'FAILED' : 'PASSED'}: ${passed} checks`);