@principles/pd-cli 1.76.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principles/pd-cli",
3
- "version": "1.76.0",
3
+ "version": "1.77.0",
4
4
  "description": "PD CLI — Pain recording, sample management, and evolution tasks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ });