@principles/pd-cli 1.75.0 → 1.77.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/dist/commands/config-doctor.d.ts +3 -6
- package/dist/commands/config-doctor.d.ts.map +1 -1
- package/dist/commands/config-doctor.js +30 -31
- package/dist/commands/config-doctor.js.map +1 -1
- package/dist/commands/runtime-features.d.ts +23 -8
- package/dist/commands/runtime-features.d.ts.map +1 -1
- package/dist/commands/runtime-features.js +72 -31
- package/dist/commands/runtime-features.js.map +1 -1
- package/dist/services/config-doctor.d.ts +26 -66
- package/dist/services/config-doctor.d.ts.map +1 -1
- package/dist/services/config-doctor.js +197 -374
- package/dist/services/config-doctor.js.map +1 -1
- package/dist/services/pd-config-loader.d.ts +64 -0
- package/dist/services/pd-config-loader.d.ts.map +1 -0
- package/dist/services/pd-config-loader.js +156 -0
- package/dist/services/pd-config-loader.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/config-doctor.ts +30 -30
- package/src/commands/runtime-features.ts +98 -44
- package/src/services/config-doctor.ts +236 -425
- package/src/services/pd-config-loader.ts +213 -0
- package/tests/commands/config-doctor.test.ts +207 -506
- package/tests/commands/console-launcher-edge-cases.test.ts +421 -0
- package/tests/commands/runtime-features.test.ts +220 -85
- package/tests/services/pd-config-loader.test.ts +479 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console Launcher Edge Cases — PRI-300
|
|
3
|
+
*
|
|
4
|
+
* 补充测试覆盖缺口:
|
|
5
|
+
* - 端口竞争和并发启动场景
|
|
6
|
+
* - 网络错误和异常处理
|
|
7
|
+
* - 资源清理和超时处理
|
|
8
|
+
* - 多次启动和停止场景
|
|
9
|
+
* - 边界端口值处理
|
|
10
|
+
* - 错误恢复和降级路径
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
14
|
+
import * as net from 'node:net';
|
|
15
|
+
import * as http from 'node:http';
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as os from 'node:os';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import {
|
|
20
|
+
isPortInUse,
|
|
21
|
+
findAvailablePort,
|
|
22
|
+
planConsoleLaunch,
|
|
23
|
+
probeConsoleHealth,
|
|
24
|
+
isLoopbackHost,
|
|
25
|
+
normalizeLoopbackHost,
|
|
26
|
+
} from '../../src/services/console-launcher.js';
|
|
27
|
+
|
|
28
|
+
// ── Port Competition and Concurrency ────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
describe('Port competition scenarios', () => {
|
|
31
|
+
it('handles rapid sequential port probes without race conditions', async () => {
|
|
32
|
+
// Start a server on a high port
|
|
33
|
+
const server = net.createServer();
|
|
34
|
+
const port = await new Promise<number>((resolve) => {
|
|
35
|
+
server.listen(0, '127.0.0.1', () => {
|
|
36
|
+
const addr = server.address();
|
|
37
|
+
if (typeof addr === 'object' && addr) {
|
|
38
|
+
resolve(addr.port);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
// Rapid sequential probes should be consistent
|
|
45
|
+
const results = await Promise.all([
|
|
46
|
+
isPortInUse('127.0.0.1', port, 200),
|
|
47
|
+
isPortInUse('127.0.0.1', port, 200),
|
|
48
|
+
isPortInUse('127.0.0.1', port, 200),
|
|
49
|
+
]);
|
|
50
|
+
// All should detect the port as in use
|
|
51
|
+
expect(results.every(r => r === true)).toBe(true);
|
|
52
|
+
} finally {
|
|
53
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('findAvailablePort skips occupied ports in sequence', async () => {
|
|
58
|
+
// Occupy 3 consecutive ports using dynamically allocated base port
|
|
59
|
+
const servers: net.Server[] = [];
|
|
60
|
+
// First, get a dynamic port to use as base
|
|
61
|
+
const probeServer = net.createServer();
|
|
62
|
+
const basePort = await new Promise<number>((resolve) => {
|
|
63
|
+
probeServer.listen(0, '127.0.0.1', () => {
|
|
64
|
+
const addr = probeServer.address();
|
|
65
|
+
if (typeof addr === 'object' && addr) resolve(addr.port);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
await new Promise<void>((resolve) => probeServer.close(() => resolve()));
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < 3; i++) {
|
|
71
|
+
const s = net.createServer();
|
|
72
|
+
await new Promise<void>((resolve) => {
|
|
73
|
+
s.listen(basePort + i, '127.0.0.1', () => resolve());
|
|
74
|
+
});
|
|
75
|
+
servers.push(s);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
// Should skip all 3 and return the next free one
|
|
80
|
+
const port = await findAvailablePort('127.0.0.1', basePort, 5);
|
|
81
|
+
expect(port).toBe(basePort + 3);
|
|
82
|
+
} finally {
|
|
83
|
+
for (const s of servers) {
|
|
84
|
+
await new Promise<void>((resolve) => s.close(() => resolve()));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns null when all fallback ports are exhausted', async () => {
|
|
90
|
+
// Occupy a range of ports using dynamically allocated base port
|
|
91
|
+
const servers: net.Server[] = [];
|
|
92
|
+
const probeServer = net.createServer();
|
|
93
|
+
const basePort = await new Promise<number>((resolve) => {
|
|
94
|
+
probeServer.listen(0, '127.0.0.1', () => {
|
|
95
|
+
const addr = probeServer.address();
|
|
96
|
+
if (typeof addr === 'object' && addr) resolve(addr.port);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
await new Promise<void>((resolve) => probeServer.close(() => resolve()));
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < 10; i++) {
|
|
102
|
+
const s = net.createServer();
|
|
103
|
+
await new Promise<void>((resolve) => {
|
|
104
|
+
s.listen(basePort + i, '127.0.0.1', () => resolve());
|
|
105
|
+
});
|
|
106
|
+
servers.push(s);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
// With limit=5, should return null after exhausting fallback
|
|
111
|
+
const port = await findAvailablePort('127.0.0.1', basePort, 5);
|
|
112
|
+
expect(port).toBeNull();
|
|
113
|
+
} finally {
|
|
114
|
+
for (const s of servers) {
|
|
115
|
+
await new Promise<void>((resolve) => s.close(() => resolve()));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ── Network Error Handling ───────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe('Network error handling', () => {
|
|
124
|
+
it('probeConsoleHealth handles connection timeout gracefully', async () => {
|
|
125
|
+
// Create a server that doesn't respond to health probe
|
|
126
|
+
const server = http.createServer((req, res) => {
|
|
127
|
+
// Intentionally delay response beyond timeout
|
|
128
|
+
setTimeout(() => {
|
|
129
|
+
res.writeHead(200);
|
|
130
|
+
res.end('{}');
|
|
131
|
+
}, 3000);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const port = await new Promise<number>((resolve) => {
|
|
135
|
+
server.listen(0, '127.0.0.1', () => {
|
|
136
|
+
const addr = server.address();
|
|
137
|
+
if (typeof addr === 'object' && addr) {
|
|
138
|
+
resolve(addr.port);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
// Should timeout and return unhealthy
|
|
145
|
+
const result = await probeConsoleHealth({
|
|
146
|
+
host: '127.0.0.1',
|
|
147
|
+
port,
|
|
148
|
+
timeoutMs: 500,
|
|
149
|
+
});
|
|
150
|
+
expect(result.healthy).toBe(false);
|
|
151
|
+
expect(result.reason).toBeDefined();
|
|
152
|
+
} finally {
|
|
153
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('probeConsoleHealth handles malformed JSON response', async () => {
|
|
158
|
+
const server = http.createServer((req, res) => {
|
|
159
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
160
|
+
res.end('not valid json {');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const port = await new Promise<number>((resolve) => {
|
|
164
|
+
server.listen(0, '127.0.0.1', () => {
|
|
165
|
+
const addr = server.address();
|
|
166
|
+
if (typeof addr === 'object' && addr) {
|
|
167
|
+
resolve(addr.port);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const result = await probeConsoleHealth({
|
|
174
|
+
host: '127.0.0.1',
|
|
175
|
+
port,
|
|
176
|
+
timeoutMs: 1000,
|
|
177
|
+
});
|
|
178
|
+
expect(result.healthy).toBe(false);
|
|
179
|
+
expect(result.reason).toMatch(/parse|json/i);
|
|
180
|
+
} finally {
|
|
181
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('probeConsoleHealth handles 500 error response', async () => {
|
|
186
|
+
const server = http.createServer((req, res) => {
|
|
187
|
+
res.writeHead(500);
|
|
188
|
+
res.end('Internal Server Error');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const port = await new Promise<number>((resolve) => {
|
|
192
|
+
server.listen(0, '127.0.0.1', () => {
|
|
193
|
+
const addr = server.address();
|
|
194
|
+
if (typeof addr === 'object' && addr) {
|
|
195
|
+
resolve(addr.port);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const result = await probeConsoleHealth({
|
|
202
|
+
host: '127.0.0.1',
|
|
203
|
+
port,
|
|
204
|
+
timeoutMs: 1000,
|
|
205
|
+
});
|
|
206
|
+
expect(result.healthy).toBe(false);
|
|
207
|
+
expect(result.reason).toMatch(/500|error/i);
|
|
208
|
+
} finally {
|
|
209
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('probeConsoleHealth handles connection refused', async () => {
|
|
214
|
+
// Use a port that's not listening
|
|
215
|
+
const result = await probeConsoleHealth({
|
|
216
|
+
host: '127.0.0.1',
|
|
217
|
+
port: 49999, // High port unlikely to be in use
|
|
218
|
+
timeoutMs: 500,
|
|
219
|
+
});
|
|
220
|
+
expect(result.healthy).toBe(false);
|
|
221
|
+
expect(result.reason).toBeDefined();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ── Resource Cleanup ──────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
describe('Resource cleanup', () => {
|
|
228
|
+
it('isPortInUse cleans up socket after probe', async () => {
|
|
229
|
+
const server = net.createServer();
|
|
230
|
+
const port = await new Promise<number>((resolve) => {
|
|
231
|
+
server.listen(0, '127.0.0.1', () => {
|
|
232
|
+
const addr = server.address();
|
|
233
|
+
if (typeof addr === 'object' && addr) {
|
|
234
|
+
resolve(addr.port);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
// First probe
|
|
241
|
+
await isPortInUse('127.0.0.1', port, 500);
|
|
242
|
+
// Second probe should work (socket was cleaned up)
|
|
243
|
+
const result = await isPortInUse('127.0.0.1', port, 500);
|
|
244
|
+
expect(result).toBe(true);
|
|
245
|
+
} finally {
|
|
246
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('probeConsoleHealth cleans up HTTP request after timeout', async () => {
|
|
251
|
+
const server = http.createServer((req, res) => {
|
|
252
|
+
// Never respond
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const port = await new Promise<number>((resolve) => {
|
|
256
|
+
server.listen(0, '127.0.0.1', () => {
|
|
257
|
+
const addr = server.address();
|
|
258
|
+
if (typeof addr === 'object' && addr) {
|
|
259
|
+
resolve(addr.port);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
// First probe with timeout
|
|
266
|
+
await probeConsoleHealth({ host: '127.0.0.1', port, timeoutMs: 300 });
|
|
267
|
+
// Second probe should also timeout cleanly
|
|
268
|
+
const result = await probeConsoleHealth({ host: '127.0.0.1', port, timeoutMs: 300 });
|
|
269
|
+
expect(result.healthy).toBe(false);
|
|
270
|
+
} finally {
|
|
271
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// ── Boundary Port Values ──────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
describe('Boundary port values', () => {
|
|
279
|
+
it('handles port 1 (lowest valid port)', async () => {
|
|
280
|
+
// Port 1 is typically reserved, but we test the logic
|
|
281
|
+
const result = await isPortInUse('127.0.0.1', 1, 200);
|
|
282
|
+
// Should return false (not in use) or handle gracefully
|
|
283
|
+
expect(typeof result).toBe('boolean');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('handles port 65535 (highest valid port)', async () => {
|
|
287
|
+
(globalThis as any).__mockIsPortInUse = async (host: string, port: number) => {
|
|
288
|
+
return port === 65535;
|
|
289
|
+
};
|
|
290
|
+
try {
|
|
291
|
+
const result = await isPortInUse('127.0.0.1', 65535, 200);
|
|
292
|
+
expect(result).toBe(true);
|
|
293
|
+
} finally {
|
|
294
|
+
delete (globalThis as any).__mockIsPortInUse;
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('findAvailablePort does not exceed 65535', async () => {
|
|
299
|
+
(globalThis as any).__mockIsPortInUse = async (host: string, port: number) => {
|
|
300
|
+
// All ports "in use" near boundary
|
|
301
|
+
return port >= 65530;
|
|
302
|
+
};
|
|
303
|
+
try {
|
|
304
|
+
const port = await findAvailablePort('127.0.0.1', 65530, 10);
|
|
305
|
+
// Should return null since all ports up to 65535 are "in use"
|
|
306
|
+
expect(port).toBeNull();
|
|
307
|
+
} finally {
|
|
308
|
+
delete (globalThis as any).__mockIsPortInUse;
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ── Loopback Host Edge Cases ──────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
describe('Loopback host edge cases', () => {
|
|
316
|
+
it('accepts all 127.x.x.x addresses', () => {
|
|
317
|
+
expect(isLoopbackHost('127.0.0.1')).toBe(true);
|
|
318
|
+
expect(isLoopbackHost('127.0.0.2')).toBe(true);
|
|
319
|
+
expect(isLoopbackHost('127.255.255.255')).toBe(true);
|
|
320
|
+
expect(isLoopbackHost('127.1.2.3')).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('rejects similar but non-loopback addresses', () => {
|
|
324
|
+
expect(isLoopbackHost('126.0.0.1')).toBe(false);
|
|
325
|
+
expect(isLoopbackHost('128.0.0.1')).toBe(false);
|
|
326
|
+
expect(isLoopbackHost('10.127.0.1')).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('handles IPv6 loopback variations', () => {
|
|
330
|
+
expect(isLoopbackHost('::1')).toBe(true);
|
|
331
|
+
expect(isLoopbackHost('[::1]')).toBe(true);
|
|
332
|
+
// Full IPv6 loopback
|
|
333
|
+
expect(isLoopbackHost('0:0:0:0:0:0:0:1')).toBe(true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('normalizes various IPv6 formats', () => {
|
|
337
|
+
expect(normalizeLoopbackHost('[::1]')).toBe('::1');
|
|
338
|
+
expect(normalizeLoopbackHost('::1')).toBe('::1');
|
|
339
|
+
expect(normalizeLoopbackHost('0:0:0:0:0:0:0:1')).toBe('0:0:0:0:0:0:0:1');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('handles localhost variations', () => {
|
|
343
|
+
expect(isLoopbackHost('localhost')).toBe(true);
|
|
344
|
+
expect(isLoopbackHost('LOCALHOST')).toBe(false); // Case-sensitive
|
|
345
|
+
expect(isLoopbackHost('localhost.localdomain')).toBe(false);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ── planConsoleLaunch Error Recovery ───────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
describe('planConsoleLaunch error recovery', () => {
|
|
352
|
+
it('returns refused for non-loopback hosts', async () => {
|
|
353
|
+
const result = await planConsoleLaunch({
|
|
354
|
+
workspaceDir: '/tmp/anywhere',
|
|
355
|
+
preferredPort: 3100,
|
|
356
|
+
host: '192.168.1.1',
|
|
357
|
+
});
|
|
358
|
+
expect(result.status).toBe('refused');
|
|
359
|
+
expect(result.reason).toMatch(/non-loopback|192\.168/i);
|
|
360
|
+
expect(result.nextAction).toBeDefined();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Note: Port fallback and workspace validation tests removed
|
|
364
|
+
// These scenarios are already covered in the existing console-open.test.ts
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// ── Health Probe Authentication ────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
describe('Health probe authentication scenarios', () => {
|
|
370
|
+
it('probeConsoleHealth accepts token parameter', async () => {
|
|
371
|
+
// Test that the function accepts the token parameter without error
|
|
372
|
+
const result = await probeConsoleHealth({
|
|
373
|
+
host: '127.0.0.1',
|
|
374
|
+
port: 49999, // Non-existent port
|
|
375
|
+
token: 'test-token-123',
|
|
376
|
+
timeoutMs: 500,
|
|
377
|
+
});
|
|
378
|
+
// Should return unhealthy (connection refused) but not crash
|
|
379
|
+
expect(result.healthy).toBe(false);
|
|
380
|
+
expect(result.reason).toBeDefined();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('probeConsoleHealth works without token parameter', async () => {
|
|
384
|
+
const result = await probeConsoleHealth({
|
|
385
|
+
host: '127.0.0.1',
|
|
386
|
+
port: 49998, // Non-existent port
|
|
387
|
+
timeoutMs: 500,
|
|
388
|
+
});
|
|
389
|
+
expect(result.healthy).toBe(false);
|
|
390
|
+
expect(result.reason).toBeDefined();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('probeConsoleHealth handles 401 unauthorized response', async () => {
|
|
394
|
+
const server = http.createServer((req, res) => {
|
|
395
|
+
res.writeHead(401);
|
|
396
|
+
res.end('Unauthorized');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const port = await new Promise<number>((resolve) => {
|
|
400
|
+
server.listen(0, '127.0.0.1', () => {
|
|
401
|
+
const addr = server.address();
|
|
402
|
+
if (typeof addr === 'object' && addr) {
|
|
403
|
+
resolve(addr.port);
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
const result = await probeConsoleHealth({
|
|
410
|
+
host: '127.0.0.1',
|
|
411
|
+
port,
|
|
412
|
+
token: 'invalid-token',
|
|
413
|
+
timeoutMs: 1000,
|
|
414
|
+
});
|
|
415
|
+
expect(result.healthy).toBe(false);
|
|
416
|
+
expect(result.reason).toMatch(/401|unauthorized/i);
|
|
417
|
+
} finally {
|
|
418
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
});
|