@principles/pd-cli 1.73.2 → 1.75.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,773 @@
1
+ /**
2
+ * pd console open tests (PRI-300).
3
+ *
4
+ * Covers:
5
+ * - isLoopbackHost() returns true for loopback, false for LAN/public
6
+ * - isPortInUse() / findAvailablePort() detect busy ports
7
+ * - planConsoleLaunch() classifies reused / started / refused / failed
8
+ * - non-loopback host is refused before any port work
9
+ * - port-in-use-by-non-console case (reused but unhealthy) is structured
10
+ * - console runtime not installed is structured failure
11
+ * - workspace missing is structured failure
12
+ * - CLI command wiring: pd console open --help, --workspace, --port, --json, --host
13
+ */
14
+
15
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
16
+ import * as net from 'node:net';
17
+ import * as http from 'node:http';
18
+ import * as fs from 'node:fs';
19
+ import * as os from 'node:os';
20
+ import * as path from 'node:path';
21
+ import { execFileSync } from 'node:child_process';
22
+ import * as childProcessModule from 'node:child_process';
23
+ import { EventEmitter } from 'node:events';
24
+ import {
25
+ isLoopbackHost,
26
+ normalizeLoopbackHost,
27
+ buildConsoleUrl,
28
+ isPortInUse,
29
+ findAvailablePort,
30
+ planConsoleLaunch,
31
+ probeConsoleHealth,
32
+ openBrowser,
33
+ } from '../../src/services/console-launcher.js';
34
+
35
+ vi.mock('child_process', async (importOriginal) => {
36
+ const original = await importOriginal<typeof import('child_process')>();
37
+ return {
38
+ ...original,
39
+ spawn: (...args: any[]) => {
40
+ if ((globalThis as any).__mockSpawn) {
41
+ return (globalThis as any).__mockSpawn(...args);
42
+ }
43
+ return original.spawn(...args as [any, any]);
44
+ },
45
+ };
46
+ });
47
+
48
+ // ─── Loopback safety ─────────────────────────────────────────────────────────
49
+
50
+ describe('isLoopbackHost', () => {
51
+ it('returns true for 127.0.0.1, localhost, ::1, 127.x.x.x', () => {
52
+ expect(isLoopbackHost('127.0.0.1')).toBe(true);
53
+ expect(isLoopbackHost('localhost')).toBe(true);
54
+ expect(isLoopbackHost('::1')).toBe(true);
55
+ expect(isLoopbackHost('[::1]')).toBe(true);
56
+ expect(isLoopbackHost('127.0.0.42')).toBe(true);
57
+ });
58
+
59
+ it('returns false for non-loopback hosts (LAN, public, 0.0.0.0)', () => {
60
+ expect(isLoopbackHost('0.0.0.0')).toBe(false);
61
+ expect(isLoopbackHost('192.168.1.5')).toBe(false);
62
+ expect(isLoopbackHost('10.0.0.1')).toBe(false);
63
+ expect(isLoopbackHost('172.16.0.1')).toBe(false);
64
+ expect(isLoopbackHost('8.8.8.8')).toBe(false);
65
+ expect(isLoopbackHost('myhost.example.com')).toBe(false);
66
+ });
67
+ });
68
+
69
+ // ─── IPv6 loopback normalization ─────────────────────────────────────────────
70
+
71
+ describe('normalizeLoopbackHost', () => {
72
+ it('strips brackets from [::1] → ::1', () => {
73
+ expect(normalizeLoopbackHost('[::1]')).toBe('::1');
74
+ });
75
+
76
+ it('passes through 127.0.0.1 unchanged', () => {
77
+ expect(normalizeLoopbackHost('127.0.0.1')).toBe('127.0.0.1');
78
+ });
79
+
80
+ it('passes through localhost unchanged', () => {
81
+ expect(normalizeLoopbackHost('localhost')).toBe('localhost');
82
+ });
83
+
84
+ it('passes through ::1 unchanged (already normalized)', () => {
85
+ expect(normalizeLoopbackHost('::1')).toBe('::1');
86
+ });
87
+
88
+ it('passes through non-loopback as-is (caller must reject)', () => {
89
+ expect(normalizeLoopbackHost('0.0.0.0')).toBe('0.0.0.0');
90
+ });
91
+ });
92
+
93
+ // ─── URL formatting for IPv6 ─────────────────────────────────────────────────
94
+
95
+ describe('buildConsoleUrl', () => {
96
+ it('wraps ::1 in brackets for valid URL', () => {
97
+ expect(buildConsoleUrl('::1', 3100)).toBe('http://[::1]:3100');
98
+ });
99
+
100
+ it('keeps 127.0.0.1 unchanged', () => {
101
+ expect(buildConsoleUrl('127.0.0.1', 3100)).toBe('http://127.0.0.1:3100');
102
+ });
103
+
104
+ it('keeps localhost unchanged', () => {
105
+ expect(buildConsoleUrl('localhost', 3100)).toBe('http://localhost:3100');
106
+ });
107
+
108
+ it('keeps 127.x.x.x unchanged', () => {
109
+ expect(buildConsoleUrl('127.0.0.42', 3119)).toBe('http://127.0.0.42:3119');
110
+ });
111
+ });
112
+
113
+ // ─── Port detection ──────────────────────────────────────────────────────────
114
+
115
+ describe('isPortInUse', () => {
116
+ let server: net.Server;
117
+
118
+ beforeEach(async () => {
119
+ server = net.createServer();
120
+ await new Promise<void>((resolve) => {
121
+ server.listen(0, '127.0.0.1', () => resolve());
122
+ });
123
+ });
124
+
125
+ afterEach(async () => {
126
+ await new Promise<void>((resolve) => {
127
+ server.close(() => resolve());
128
+ });
129
+ });
130
+
131
+ it('returns true for a port that accepts a TCP connection', async () => {
132
+ const addr = server.address();
133
+ if (typeof addr === 'object' && addr) {
134
+ const inUse = await isPortInUse(addr.address, addr.port, 1000);
135
+ expect(inUse).toBe(true);
136
+ }
137
+ });
138
+
139
+ it('returns false for a port that is closed', async () => {
140
+ const addr = server.address();
141
+ if (typeof addr === 'object' && addr) {
142
+ // Close the server, then probe
143
+ await new Promise<void>((resolve) => server.close(() => resolve()));
144
+ const inUse = await isPortInUse(addr.address, addr.port, 800);
145
+ expect(inUse).toBe(false);
146
+ }
147
+ });
148
+ });
149
+
150
+ describe('findAvailablePort', () => {
151
+ it('returns the first port in the range that is not in use', async () => {
152
+ // Pick a very high port to avoid colliding with running services
153
+ const port = await findAvailablePort('127.0.0.1', 49000, 5);
154
+ expect(port).not.toBeNull();
155
+ expect(port).toBeGreaterThanOrEqual(49000);
156
+ });
157
+
158
+ it('skips occupied ports and returns the next free one', async () => {
159
+ const server = net.createServer();
160
+ await new Promise<void>((resolve) => {
161
+ server.listen(49100, '127.0.0.1', () => resolve());
162
+ });
163
+ try {
164
+ // Should skip 49100 and return 49101
165
+ const port = await findAvailablePort('127.0.0.1', 49100, 5);
166
+ expect(port).toBe(49101);
167
+ } finally {
168
+ await new Promise<void>((resolve) => server.close(() => resolve()));
169
+ }
170
+ });
171
+
172
+ it('returns null when all ports in the range are occupied', async () => {
173
+ // Open 3 ports and check that findAvailablePort with limit=2 returns null
174
+ const servers: net.Server[] = [];
175
+ const ports: number[] = [];
176
+ for (let i = 0; i < 3; i++) {
177
+ const s = net.createServer();
178
+ await new Promise<void>((resolve) => {
179
+ s.listen(0, '127.0.0.1', () => {
180
+ const a = s.address();
181
+ if (typeof a === 'object' && a) ports.push(a.port);
182
+ resolve();
183
+ });
184
+ });
185
+ servers.push(s);
186
+ }
187
+ try {
188
+ const port = await findAvailablePort('127.0.0.1', ports[0], 1);
189
+ expect(port).toBeNull();
190
+ } finally {
191
+ for (const s of servers) {
192
+ await new Promise<void>((resolve) => s.close(() => resolve()));
193
+ }
194
+ }
195
+ });
196
+
197
+ it('does not return ports above 65535 when fallback goes out of range', async () => {
198
+ (globalThis as any).__mockIsPortInUse = async (host: string, port: number) => {
199
+ if (port === 65535) return true;
200
+ return false;
201
+ };
202
+ try {
203
+ const port = await findAvailablePort('127.0.0.1', 65535, 2);
204
+ expect(port).toBeNull();
205
+ } finally {
206
+ delete (globalThis as any).__mockIsPortInUse;
207
+ }
208
+ });
209
+ });
210
+
211
+ // ─── planConsoleLaunch: refused (non-loopback) ──────────────────────────────
212
+
213
+ describe('planConsoleLaunch — refused (non-loopback host)', () => {
214
+ it('refuses 0.0.0.0', async () => {
215
+ const result = await planConsoleLaunch({
216
+ workspaceDir: '/tmp/anywhere',
217
+ preferredPort: 3100,
218
+ host: '0.0.0.0',
219
+ });
220
+ expect(result.status).toBe('refused');
221
+ expect(result.reason).toMatch(/non-loopback/i);
222
+ expect(result.nextAction).toBeDefined();
223
+ });
224
+
225
+ it('refuses LAN host 192.168.1.5', async () => {
226
+ const result = await planConsoleLaunch({
227
+ workspaceDir: '/tmp/anywhere',
228
+ preferredPort: 3100,
229
+ host: '192.168.1.5',
230
+ });
231
+ expect(result.status).toBe('refused');
232
+ expect(result.reason).toMatch(/192\.168\.1\.5/);
233
+ });
234
+
235
+ it('normalizes [::1] to ::1 and uses it for port probing', async () => {
236
+ // [::1] should be normalized to ::1 and NOT refused
237
+ const preferred = 49250;
238
+ expect(await isPortInUse('::1', preferred)).toBe(false);
239
+ const result = await planConsoleLaunch({
240
+ workspaceDir: '/tmp/anywhere',
241
+ preferredPort: preferred,
242
+ host: '[::1]',
243
+ });
244
+ expect(result.status).toBe('started');
245
+ expect(result.host).toBe('::1');
246
+ // URL must have brackets for valid IPv6 URL format
247
+ expect(result.url).toBe(`http://[::1]:${preferred}`);
248
+ });
249
+
250
+ it('formats ::1 URL with brackets (no raw IPv6 in URL)', async () => {
251
+ const preferred = 49251;
252
+ expect(await isPortInUse('::1', preferred)).toBe(false);
253
+ const result = await planConsoleLaunch({
254
+ workspaceDir: '/tmp/anywhere',
255
+ preferredPort: preferred,
256
+ host: '::1',
257
+ });
258
+ expect(result.status).toBe('started');
259
+ expect(result.host).toBe('::1');
260
+ expect(result.url).toBe(`http://[::1]:${preferred}`);
261
+ });
262
+ });
263
+
264
+ // ─── planConsoleLaunch: started (port free) ─────────────────────────────────
265
+
266
+ describe('planConsoleLaunch — started (preferred port free)', () => {
267
+ it('returns started on the preferred port when no service is running', async () => {
268
+ const preferred = 49200;
269
+ // Verify the port is free first
270
+ expect(await isPortInUse('127.0.0.1', preferred)).toBe(false);
271
+ const result = await planConsoleLaunch({
272
+ workspaceDir: '/tmp/anywhere',
273
+ preferredPort: preferred,
274
+ host: '127.0.0.1',
275
+ });
276
+ expect(result.status).toBe('started');
277
+ expect(result.reused).toBe(false);
278
+ expect(result.port).toBe(preferred);
279
+ expect(result.url).toBe(`http://127.0.0.1:${preferred}`);
280
+ });
281
+ });
282
+
283
+ // ─── planConsoleLaunch: reused (existing healthy console) ──────────────────
284
+
285
+ describe('planConsoleLaunch — reused (healthy console on preferred port)', () => {
286
+ it('returns reused when /api/health returns 200 on the preferred port', async () => {
287
+ const server = http.createServer((req, res) => {
288
+ if (req.url === '/api/health') {
289
+ res.statusCode = 200;
290
+ res.end(JSON.stringify({ success: true }));
291
+ return;
292
+ }
293
+ res.statusCode = 404;
294
+ res.end('not found');
295
+ });
296
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
297
+ const addr = server.address();
298
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
299
+ try {
300
+ const result = await planConsoleLaunch({
301
+ workspaceDir: '/tmp/anywhere',
302
+ preferredPort: addr.port,
303
+ host: '127.0.0.1',
304
+ });
305
+ expect(result.status).toBe('reused');
306
+ expect(result.reused).toBe(true);
307
+ expect(result.url).toBe(`http://127.0.0.1:${addr.port}`);
308
+ } finally {
309
+ await new Promise<void>((resolve) => server.close(() => resolve()));
310
+ }
311
+ });
312
+
313
+ it('does NOT classify a non-console responder as reused', async () => {
314
+ // Server returns 200 on any path but with a non-OK status code from /api/health
315
+ const server = http.createServer((req, res) => {
316
+ res.statusCode = 500;
317
+ res.end('error');
318
+ });
319
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
320
+ const addr = server.address();
321
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
322
+ try {
323
+ const result = await planConsoleLaunch({
324
+ workspaceDir: '/tmp/anywhere',
325
+ preferredPort: addr.port,
326
+ host: '127.0.0.1',
327
+ });
328
+ // health 500 → not healthy → falls through to "started" (port is occupied by non-console → next port)
329
+ // but it may also stay in "started" if no fallback is found
330
+ expect(result.status === 'started' || result.status === 'failed').toBe(true);
331
+ if (result.status === 'started') {
332
+ // It should have moved to a different port (not addr.port)
333
+ expect(result.port).not.toBe(addr.port);
334
+ }
335
+ } finally {
336
+ await new Promise<void>((resolve) => server.close(() => resolve()));
337
+ }
338
+ });
339
+ });
340
+
341
+ // ─── planConsoleLaunch: started with port fallback ──────────────────────────
342
+
343
+ describe('planConsoleLaunch — port fallback', () => {
344
+ it('moves to next free port when preferred is occupied by a non-console', async () => {
345
+ const server = http.createServer((req, res) => {
346
+ res.statusCode = 500; // not a healthy console
347
+ res.end('error');
348
+ });
349
+ await new Promise<void>((resolve) => server.listen(49300, '127.0.0.1', () => resolve()));
350
+ try {
351
+ const result = await planConsoleLaunch({
352
+ workspaceDir: '/tmp/anywhere',
353
+ preferredPort: 49300,
354
+ host: '127.0.0.1',
355
+ });
356
+ expect(result.status).toBe('started');
357
+ expect(result.port).toBe(49301);
358
+ expect(result.reason).toMatch(/busy|49300/i);
359
+ } finally {
360
+ await new Promise<void>((resolve) => server.close(() => resolve()));
361
+ }
362
+ });
363
+ });
364
+
365
+ // ─── probeConsoleHealth ─────────────────────────────────────────────────────
366
+
367
+ describe('probeConsoleHealth', () => {
368
+ it('returns healthy=true for a server that returns 200 on /api/health', async () => {
369
+ const server = http.createServer((req, res) => {
370
+ if (req.url === '/api/health') {
371
+ res.statusCode = 200;
372
+ res.end(JSON.stringify({ success: true }));
373
+ return;
374
+ }
375
+ res.statusCode = 404;
376
+ res.end();
377
+ });
378
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
379
+ const addr = server.address();
380
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
381
+ try {
382
+ const h = await probeConsoleHealth({ host: '127.0.0.1', port: addr.port });
383
+ expect(h.healthy).toBe(true);
384
+ } finally {
385
+ await new Promise<void>((resolve) => server.close(() => resolve()));
386
+ }
387
+ });
388
+
389
+ it('returns healthy=false with reason for a server that returns 500', async () => {
390
+ const server = http.createServer((req, res) => {
391
+ res.statusCode = 500;
392
+ res.end('boom');
393
+ });
394
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
395
+ const addr = server.address();
396
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
397
+ try {
398
+ const h = await probeConsoleHealth({ host: '127.0.0.1', port: addr.port });
399
+ expect(h.healthy).toBe(false);
400
+ expect(h.reason).toBeDefined();
401
+ } finally {
402
+ await new Promise<void>((resolve) => server.close(() => resolve()));
403
+ }
404
+ });
405
+
406
+ it('sends Authorization header when token is provided', async () => {
407
+ let receivedAuth: string | undefined;
408
+ const server = http.createServer((req, res) => {
409
+ receivedAuth = req.headers.authorization;
410
+ res.statusCode = 200;
411
+ res.end(JSON.stringify({ success: true }));
412
+ });
413
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
414
+ const addr = server.address();
415
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
416
+ try {
417
+ const h = await probeConsoleHealth({ host: '127.0.0.1', port: addr.port, token: 'test-token-123' });
418
+ expect(h.healthy).toBe(true);
419
+ expect(receivedAuth).toBe('Bearer test-token-123');
420
+ } finally {
421
+ await new Promise<void>((resolve) => server.close(() => resolve()));
422
+ }
423
+ });
424
+
425
+ it('does NOT send Authorization header when token is undefined', async () => {
426
+ let receivedAuth: string | undefined;
427
+ const server = http.createServer((req, res) => {
428
+ receivedAuth = req.headers.authorization;
429
+ res.statusCode = 200;
430
+ res.end(JSON.stringify({ success: true }));
431
+ });
432
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
433
+ const addr = server.address();
434
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
435
+ try {
436
+ const h = await probeConsoleHealth({ host: '127.0.0.1', port: addr.port });
437
+ expect(h.healthy).toBe(true);
438
+ expect(receivedAuth).toBeUndefined();
439
+ } finally {
440
+ await new Promise<void>((resolve) => server.close(() => resolve()));
441
+ }
442
+ });
443
+
444
+ it('returns healthy=false with reason when server returns 401 (unauthorized)', async () => {
445
+ const server = http.createServer((req, res) => {
446
+ res.statusCode = 401;
447
+ res.end('unauthorized');
448
+ });
449
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
450
+ const addr = server.address();
451
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
452
+ try {
453
+ const h = await probeConsoleHealth({ host: '127.0.0.1', port: addr.port });
454
+ expect(h.healthy).toBe(false);
455
+ expect(h.reason).toMatch(/401/);
456
+ expect(h.reason).not.toMatch(/non-console/i);
457
+ } finally {
458
+ await new Promise<void>((resolve) => server.close(() => resolve()));
459
+ }
460
+ });
461
+
462
+ it('401 is NOT misclassified as healthy even with a valid token', async () => {
463
+ const server = http.createServer((req, res) => {
464
+ // Simulate a console that requires auth and rejects bad tokens
465
+ if (req.headers.authorization !== 'Bearer correct-token') {
466
+ res.statusCode = 401;
467
+ res.end('unauthorized');
468
+ return;
469
+ }
470
+ res.statusCode = 200;
471
+ res.end(JSON.stringify({ success: true }));
472
+ });
473
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
474
+ const addr = server.address();
475
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
476
+ try {
477
+ // Wrong token → 401 → should NOT be healthy
478
+ const h = await probeConsoleHealth({ host: '127.0.0.1', port: addr.port, token: 'wrong-token' });
479
+ expect(h.healthy).toBe(false);
480
+ expect(h.reason).toMatch(/401/);
481
+ } finally {
482
+ await new Promise<void>((resolve) => server.close(() => resolve()));
483
+ }
484
+ });
485
+ });
486
+
487
+ // ─── CLI command wiring ──────────────────────────────────────────────────────
488
+
489
+ describe('CLI command wiring (pd console open)', () => {
490
+ let cliPath: string;
491
+ let workspaceRoot: string;
492
+ let tmp: string;
493
+
494
+ beforeEach(() => {
495
+ workspaceRoot = path.resolve(__dirname, '../../../..');
496
+ cliPath = path.join(workspaceRoot, 'packages', 'pd-cli', 'dist', 'index.js');
497
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-console-open-test-'));
498
+ // Fake a console install: create dir + dist/server.js with a minimal HTTP server
499
+ const consoleDir = path.join(os.homedir(), '.openclaw', 'extensions', 'principles-disciple', 'console');
500
+ fs.mkdirSync(path.join(consoleDir, 'dist'), { recursive: true });
501
+ fs.writeFileSync(path.join(consoleDir, 'dist', 'server.js'), `
502
+ const http = require('http');
503
+ const args = process.argv.slice(2);
504
+ const portIdx = args.indexOf('--port');
505
+ const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) : 3100;
506
+ const hostIdx = args.indexOf('--host');
507
+ const host = hostIdx >= 0 ? args[hostIdx + 1] : '127.0.0.1';
508
+ const server = http.createServer((req, res) => {
509
+ if (req.url === '/api/health') {
510
+ res.writeHead(200, {'Content-Type': 'application/json'});
511
+ res.end(JSON.stringify({success: true}));
512
+ } else {
513
+ res.writeHead(404);
514
+ res.end();
515
+ }
516
+ });
517
+ server.listen(port, host, () => {
518
+ console.log(JSON.stringify({status: 'running', port, host}));
519
+ });
520
+ `);
521
+ });
522
+
523
+ afterEach(() => {
524
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
525
+ try {
526
+ const consoleDir = path.join(os.homedir(), '.openclaw', 'extensions', 'principles-disciple', 'console');
527
+ fs.rmSync(consoleDir, { recursive: true, force: true });
528
+ } catch { /* ignore */ }
529
+ });
530
+
531
+ it('console open subcommand is registered (pd console open --help)', () => {
532
+ const out = runPd(['console', 'open', '--help'], workspaceRoot);
533
+ expect(out).toContain('--workspace');
534
+ expect(out).toContain('--port');
535
+ expect(out).toContain('--host');
536
+ expect(out).toContain('--json');
537
+ expect(out).toContain('--no-browser');
538
+ });
539
+
540
+ it('console subcommand list shows "open" subcommand', () => {
541
+ const out = runPd(['console', '--help'], workspaceRoot);
542
+ expect(out).toMatch(/\bopen\b/);
543
+ });
544
+
545
+ it('pd console open --host 0.0.0.0 --json returns a refused JSON object', () => {
546
+ const out = runPd(['console', 'open', '--workspace', tmp, '--host', '0.0.0.0', '--json', '--no-browser'], workspaceRoot);
547
+ const parsed = JSON.parse(out);
548
+ expect(parsed.status).toBe('refused');
549
+ expect(parsed.reason).toMatch(/non-loopback/i);
550
+ expect(parsed.nextAction).toBeDefined();
551
+ });
552
+
553
+ it('pd console open --json (port free) returns a structured JSON object with required fields', () => {
554
+ const out = runPd(['console', 'open', '--workspace', tmp, '--json', '--no-browser'], workspaceRoot);
555
+ // The CLI will attempt to start the fake server.js; since the file is a stub, the
556
+ // child will exit early → we should get a structured "failed" JSON, not a crash.
557
+ // The required-field contract (status, url, port, host, workspaceDir, reason, nextAction, reused, browserOpened) is
558
+ // what we assert.
559
+ const parsed = JSON.parse(out);
560
+ expect(parsed).toHaveProperty('status');
561
+ expect(['started', 'reused', 'failed', 'refused']).toContain(parsed.status);
562
+ expect(parsed).toHaveProperty('port');
563
+ expect(parsed).toHaveProperty('host');
564
+ expect(parsed).toHaveProperty('workspaceDir');
565
+ expect(parsed).toHaveProperty('reused');
566
+ expect(parsed).toHaveProperty('browserOpened');
567
+ });
568
+
569
+ it('pd console open --port 99999 --json returns a structured failure (invalid port)', () => {
570
+ const out = runPd(['console', 'open', '--workspace', tmp, '--port', '99999', '--json', '--no-browser'], workspaceRoot);
571
+ const parsed = JSON.parse(out);
572
+ expect(parsed.status).toBe('failed');
573
+ expect(parsed.reason).toMatch(/Invalid --port/);
574
+ });
575
+
576
+ it('pd console open without --workspace and no env var returns a structured workspace_missing failure', () => {
577
+ const previous = process.env.PD_WORKSPACE_DIR;
578
+ delete process.env.PD_WORKSPACE_DIR;
579
+ try {
580
+ const out = runPd(['console', 'open', '--json', '--no-browser'], workspaceRoot);
581
+ const parsed = JSON.parse(out);
582
+ expect(parsed.status).toBe('failed');
583
+ expect(parsed.reason).toBe('workspace_missing');
584
+ expect(parsed.nextAction).toBeDefined();
585
+ } finally {
586
+ if (previous !== undefined) process.env.PD_WORKSPACE_DIR = previous;
587
+ }
588
+ });
589
+
590
+ it('pd console open --json with --no-auth and --no-browser parses options correctly', () => {
591
+ const out = runPd(['console', 'open', '--workspace', tmp, '--json', '--no-auth', '--no-browser'], workspaceRoot);
592
+ const parsed = JSON.parse(out);
593
+ expect(parsed).toHaveProperty('status');
594
+ expect(parsed.browserOpened).toBe(false);
595
+ });
596
+
597
+ it('pd console --no-auth --json legacy path parses --no-auth correctly', () => {
598
+ const out = runPd(['console', '--workspace', tmp, '--json', '--no-auth'], workspaceRoot);
599
+ expect(out.trim()).not.toBe('');
600
+ const parsed = JSON.parse(out);
601
+ expect(parsed).toBeDefined();
602
+ });
603
+
604
+ describe('openBrowser', () => {
605
+ afterEach(() => {
606
+ delete (globalThis as any).__mockSpawn;
607
+ });
608
+
609
+ it('returns opened: true when spawn does not emit error in the short window', async () => {
610
+ const mockChild = new EventEmitter() as any;
611
+ mockChild.unref = vi.fn();
612
+ let spawnCalled = false;
613
+ (globalThis as any).__mockSpawn = () => {
614
+ spawnCalled = true;
615
+ return mockChild;
616
+ };
617
+
618
+ const result = await openBrowser('http://127.0.0.1:3100');
619
+ expect(result.opened).toBe(true);
620
+ expect(spawnCalled).toBe(true);
621
+ });
622
+
623
+ it('returns opened: false when spawn emits an error within the short window', async () => {
624
+ const mockChild = new EventEmitter() as any;
625
+ mockChild.unref = vi.fn();
626
+ let spawnCalled = false;
627
+ (globalThis as any).__mockSpawn = () => {
628
+ spawnCalled = true;
629
+ process.nextTick(() => {
630
+ mockChild.emit('error', new Error('spawn ENOENT'));
631
+ });
632
+ return mockChild;
633
+ };
634
+
635
+ const result = await openBrowser('http://127.0.0.1:3100');
636
+ expect(result.opened).toBe(false);
637
+ expect(result.reason).toContain('Failed to spawn browser process: spawn ENOENT');
638
+ expect(spawnCalled).toBe(true);
639
+ });
640
+
641
+ it('returns opened: false when spawn throws synchronously', async () => {
642
+ let spawnCalled = false;
643
+ (globalThis as any).__mockSpawn = () => {
644
+ spawnCalled = true;
645
+ throw new Error('Sync spawn failure');
646
+ };
647
+
648
+ const result = await openBrowser('http://127.0.0.1:3100');
649
+ expect(result.opened).toBe(false);
650
+ expect(result.reason).toContain('Sync spawn failure');
651
+ expect(spawnCalled).toBe(true);
652
+ });
653
+ });
654
+
655
+ describe('handleConsoleOpen browser failure reporting', () => {
656
+ afterEach(() => {
657
+ delete (globalThis as any).__mockSpawn;
658
+ delete (globalThis as any).__mockPlanConsoleLaunch;
659
+ delete (globalThis as any).__mockProbeConsoleHealth;
660
+ });
661
+
662
+ it('sets browserOpened: false when browser fails to open', async () => {
663
+ const { handleConsoleOpen } = await import('../../src/commands/console.js');
664
+
665
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
666
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
667
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
668
+
669
+ const mockChild = new EventEmitter() as any;
670
+ mockChild.unref = vi.fn();
671
+ let spawnCalled = false;
672
+ (globalThis as any).__mockSpawn = () => {
673
+ spawnCalled = true;
674
+ process.nextTick(() => {
675
+ mockChild.emit('error', new Error('mock spawn error'));
676
+ });
677
+ return mockChild;
678
+ };
679
+
680
+ (globalThis as any).__mockPlanConsoleLaunch = async () => {
681
+ return {
682
+ status: 'reused',
683
+ url: 'http://127.0.0.1:3100',
684
+ port: 3100,
685
+ host: '127.0.0.1',
686
+ reused: true
687
+ };
688
+ };
689
+
690
+ (globalThis as any).__mockProbeConsoleHealth = async () => {
691
+ return {
692
+ healthy: true
693
+ };
694
+ };
695
+
696
+ await handleConsoleOpen({
697
+ workspace: tmp,
698
+ json: false,
699
+ });
700
+
701
+ await new Promise(resolve => setTimeout(resolve, 150));
702
+
703
+ const loggedOutput = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
704
+ expect(exitSpy).not.toHaveBeenCalled();
705
+ expect(spawnCalled).toBe(true);
706
+
707
+ expect(loggedOutput).not.toContain('Browser opened');
708
+ expect(loggedOutput).toContain('Open http://127.0.0.1:3100 in your browser');
709
+
710
+ exitSpy.mockRestore();
711
+ logSpy.mockRestore();
712
+ errorSpy.mockRestore();
713
+ });
714
+ });
715
+
716
+ describe('Strict port parsing and boundary validation', () => {
717
+ it('rejects partial numeric strings like 3100abc', () => {
718
+ const out = runPd(['console', 'open', '--workspace', tmp, '--port', '3100abc', '--json', '--no-browser'], workspaceRoot);
719
+ const parsed = JSON.parse(out);
720
+ expect(parsed.status).toBe('failed');
721
+ expect(parsed.reason).toMatch(/Invalid --port/);
722
+ });
723
+
724
+ it('rejects port 0', () => {
725
+ const out = runPd(['console', 'open', '--workspace', tmp, '--port', '0', '--json', '--no-browser'], workspaceRoot);
726
+ const parsed = JSON.parse(out);
727
+ expect(parsed.status).toBe('failed');
728
+ expect(parsed.reason).toMatch(/Invalid --port/);
729
+ });
730
+ });
731
+
732
+ describe('Non-loopback host checks before workspace resolution', () => {
733
+ it('refuses non-loopback hosts even when workspace is missing', () => {
734
+ const previous = process.env.PD_WORKSPACE_DIR;
735
+ delete process.env.PD_WORKSPACE_DIR;
736
+ try {
737
+ const out = runPd(['console', 'open', '--host', '192.168.1.100', '--json', '--no-browser'], workspaceRoot);
738
+ const parsed = JSON.parse(out);
739
+ expect(parsed.status).toBe('refused');
740
+ expect(parsed.reason).toMatch(/non-loopback/i);
741
+ expect(parsed.workspaceDir).toBe('');
742
+ } finally {
743
+ if (previous !== undefined) process.env.PD_WORKSPACE_DIR = previous;
744
+ }
745
+ });
746
+
747
+ it('[::1] is accepted and normalized to ::1 (not refused)', () => {
748
+ const out = runPd(['console', 'open', '--workspace', tmp, '--host', '[::1]', '--json', '--no-browser'], workspaceRoot);
749
+ const parsed = JSON.parse(out);
750
+ // Should NOT be refused — [::1] is loopback
751
+ expect(parsed.status).not.toBe('refused');
752
+ // Host should be normalized to ::1 (without brackets)
753
+ expect(parsed.host).toBe('::1');
754
+ });
755
+ });
756
+ });
757
+
758
+ function runPd(args: string[], cwd: string): string {
759
+ try {
760
+ return execFileSync('node', ['packages/pd-cli/dist/index.js', ...args], {
761
+ encoding: 'utf8',
762
+ cwd,
763
+ });
764
+ } catch (err: unknown) {
765
+ if (err && typeof err === 'object' && Object.hasOwn(err, 'stdout')) {
766
+ const stdoutVal = Reflect.get(err, 'stdout');
767
+ if (typeof stdoutVal === 'string' || stdoutVal instanceof Buffer) {
768
+ return String(stdoutVal);
769
+ }
770
+ }
771
+ throw err;
772
+ }
773
+ }