dsh-api-runner 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +391 -2
- package/client/client.js +1604 -0
- package/cordis.patch.yml +9 -0
- package/docs/screenshot-service.png +0 -0
- package/docs/screenshot-settings.png +0 -0
- package/docs/verified-dsh-version.png +0 -0
- package/dsh-api-runner.example.yml +92 -0
- package/package.json +75 -4
- package/scripts/stage.mjs +78 -0
- package/src/api.js +194 -0
- package/src/config.js +265 -0
- package/src/java-runner.js +623 -0
- package/src/java.js +658 -0
- package/src/log.js +209 -0
- package/src/main.js +586 -0
- package/src/ops.js +1039 -0
- package/src/project-store.js +334 -0
- package/src/restart.js +518 -0
- package/src/service.js +329 -0
- package/src/settings.js +109 -0
- package/src/util.js +182 -0
- package/test/client.mjs +109 -0
- package/test/java.mjs +298 -0
- package/test/managed.mjs +137 -0
- package/test/plugin.mjs +433 -0
- package/test/smoke.mjs +443 -0
package/test/client.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client/client.js 的形态测试。
|
|
3
|
+
*
|
|
4
|
+
* 客户端 bundle 是「经典脚本」:加载时立刻调用 window.__ModuleLoader__.load(...),
|
|
5
|
+
* 由 DSH 前端注入的 loader 提供 require。这里用最小桩模拟这套环境,
|
|
6
|
+
* 校验:① 注册的 id 与包名一致;② 导出 inject/apply;③ apply 会注册一个侧边栏 tab;
|
|
7
|
+
* ④ 面板组件能在没有 scope 的情况下渲染不崩。
|
|
8
|
+
*/
|
|
9
|
+
import assert from 'node:assert';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
const source = fs.readFileSync(path.join(root, 'client', 'client.js'), 'utf8');
|
|
16
|
+
|
|
17
|
+
let recorded;
|
|
18
|
+
/** 极简 React 桩:只够执行 createElement 与 hooks */
|
|
19
|
+
const React = {
|
|
20
|
+
createElement: (type, props, ...children) => ({ type, props: { ...(props || {}), children: children.length > 1 ? children : children[0] } }),
|
|
21
|
+
Fragment: 'Fragment',
|
|
22
|
+
useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
|
|
23
|
+
useEffect: () => {},
|
|
24
|
+
useMemo: (factory) => factory(),
|
|
25
|
+
useCallback: (fn) => fn,
|
|
26
|
+
useRef: (initial) => ({ current: initial }),
|
|
27
|
+
};
|
|
28
|
+
const requireStub = (name) => {
|
|
29
|
+
if (name === 'react') return React;
|
|
30
|
+
throw new Error(`客户端 bundle 请求了未桩的模块:${name}`);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const windowStub = {
|
|
34
|
+
__ModuleLoader__: {
|
|
35
|
+
load: (entry) => {
|
|
36
|
+
recorded = entry;
|
|
37
|
+
recorded.exports = entry.factory(requireStub);
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
confirm: () => true,
|
|
41
|
+
document: {
|
|
42
|
+
getElementById: () => null,
|
|
43
|
+
createElement: () => ({ set textContent(v) {}, id: '' }),
|
|
44
|
+
head: { appendChild: () => {} },
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
// bundle 里会读 document / window,通过函数参数作用域注入
|
|
48
|
+
const run = new Function('window', 'document', 'setInterval', 'clearInterval', 'fetch', source);
|
|
49
|
+
run(windowStub, windowStub.document, () => 0, () => {}, () => { throw new Error('测试里不应真的发请求'); });
|
|
50
|
+
|
|
51
|
+
assert.ok(recorded, '应当调用 window.__ModuleLoader__.load');
|
|
52
|
+
assert.equal(recorded.id, 'dsh-api-runner', 'bundle id 必须与包名一致');
|
|
53
|
+
assert.deepEqual(recorded.exports.inject, ['betterSidebar']);
|
|
54
|
+
assert.equal(typeof recorded.exports.apply, 'function');
|
|
55
|
+
|
|
56
|
+
// apply:用一个只提供 betterSidebar 的 ctx,断言注册了一个 tab
|
|
57
|
+
const tabs = [];
|
|
58
|
+
const effects = [];
|
|
59
|
+
const ctx = {
|
|
60
|
+
betterSidebar: {
|
|
61
|
+
registerTab: (descriptor) => {
|
|
62
|
+
tabs.push(descriptor);
|
|
63
|
+
return () => {};
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
// 没有 slots 时也要能正常注册 tab(设置页是可选的)
|
|
67
|
+
get: () => undefined,
|
|
68
|
+
effect: (callback, label) => {
|
|
69
|
+
effects.push(label);
|
|
70
|
+
return callback();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
recorded.exports.apply(ctx);
|
|
74
|
+
assert.equal(tabs.length, 1, 'apply 应注册一个侧边栏 tab');
|
|
75
|
+
assert.equal(tabs[0].id, 'dsh-api-runner');
|
|
76
|
+
assert.equal(tabs[0].single, true);
|
|
77
|
+
assert.equal(typeof tabs[0].title, 'function');
|
|
78
|
+
assert.equal(tabs[0].title(), '接口调试');
|
|
79
|
+
|
|
80
|
+
// 有 slots 时,还要在设置里挂一个插件主页(settings.section)
|
|
81
|
+
const sections = [];
|
|
82
|
+
const injectedSlots = [];
|
|
83
|
+
const ctxWithSlots = {
|
|
84
|
+
betterSidebar: { registerTab: () => () => {} },
|
|
85
|
+
get: (name) => (name === 'slots' ? {
|
|
86
|
+
inject: (slot, callback) => { injectedSlots.push(slot); callback(); return () => {}; },
|
|
87
|
+
register: (descriptor, render) => { sections.push({ descriptor, render }); return () => {}; },
|
|
88
|
+
} : undefined),
|
|
89
|
+
effect: (callback) => callback(),
|
|
90
|
+
};
|
|
91
|
+
recorded.exports.apply(ctxWithSlots);
|
|
92
|
+
assert.deepEqual(injectedSlots, ['settings.section'], '设置页应通过 slots.inject 等待 slot 就绪');
|
|
93
|
+
assert.equal(sections.length, 1, '应注册一个设置页');
|
|
94
|
+
assert.equal(sections[0].descriptor.name, 'settings.section');
|
|
95
|
+
assert.equal(sections[0].descriptor.id, 'dsh-api-runner');
|
|
96
|
+
assert.equal(sections[0].descriptor.label(), '接口调试');
|
|
97
|
+
const page = sections[0].render();
|
|
98
|
+
assert.ok(page, '设置页应返回元素');
|
|
99
|
+
assert.equal(typeof page.type, 'function');
|
|
100
|
+
|
|
101
|
+
// 面板组件:没有 scope 也不能崩
|
|
102
|
+
const panel = tabs[0].component({ visible: false, scope: {} });
|
|
103
|
+
assert.ok(panel, '面板组件应返回元素');
|
|
104
|
+
assert.equal(typeof panel.type, 'function');
|
|
105
|
+
|
|
106
|
+
// 没有 betterSidebar 时 apply 要安全退出,不能抛
|
|
107
|
+
recorded.exports.apply({ effect: () => {} });
|
|
108
|
+
|
|
109
|
+
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`);
|
package/test/managed.mjs
ADDED
|
@@ -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`);
|