@jackwener/opencli 0.4.5 → 0.4.6

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/browser.d.ts CHANGED
@@ -2,6 +2,14 @@
2
2
  * Browser interaction via Chrome DevTools Protocol.
3
3
  * Connects to an existing Chrome browser through CDP auto-discovery or extension bridge.
4
4
  */
5
+ /**
6
+ * Verify the CDP HTTP JSON API is functional.
7
+ * Chrome's chrome://inspect#remote-debugging mode writes DevToolsActivePort
8
+ * but doesn't expose the full CDP HTTP API (/json/version), which means
9
+ * Playwright's connectOverCDP won't work properly (init succeeds but
10
+ * all tool calls hang silently).
11
+ */
12
+ export declare function isCdpApiAvailable(port: number, host?: string, timeoutMs?: number): Promise<boolean>;
5
13
  export declare function discoverChromeEndpoint(): Promise<string | null>;
6
14
  type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'cdp-timeout' | 'mcp-init' | 'process-exit' | 'unknown';
7
15
  type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
@@ -73,6 +81,7 @@ export declare class PlaywrightMCP {
73
81
  private _initialTabIdentities;
74
82
  private _closingPromise;
75
83
  private _state;
84
+ private _mode;
76
85
  private _page;
77
86
  get state(): PlaywrightMCPState;
78
87
  private _sendRequest;
@@ -100,5 +109,6 @@ export declare const __test__: {
100
109
  diffTabIndexes: typeof diffTabIndexes;
101
110
  appendLimited: typeof appendLimited;
102
111
  withTimeout: typeof withTimeout;
112
+ isCdpApiAvailable: typeof isCdpApiAvailable;
103
113
  };
104
114
  export {};
package/dist/browser.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { spawn, execSync } from 'node:child_process';
6
6
  import { createHash } from 'node:crypto';
7
+ import * as http from 'node:http';
7
8
  import * as net from 'node:net';
8
9
  import { fileURLToPath } from 'node:url';
9
10
  import * as fs from 'node:fs';
@@ -30,6 +31,33 @@ function isPortReachable(port, host = '127.0.0.1', timeoutMs = 800) {
30
31
  sock.on('timeout', () => { sock.destroy(); resolve(false); });
31
32
  });
32
33
  }
34
+ /**
35
+ * Verify the CDP HTTP JSON API is functional.
36
+ * Chrome's chrome://inspect#remote-debugging mode writes DevToolsActivePort
37
+ * but doesn't expose the full CDP HTTP API (/json/version), which means
38
+ * Playwright's connectOverCDP won't work properly (init succeeds but
39
+ * all tool calls hang silently).
40
+ */
41
+ export function isCdpApiAvailable(port, host = '127.0.0.1', timeoutMs = 2000) {
42
+ return new Promise(resolve => {
43
+ const req = http.get(`http://${host}:${port}/json/version`, { timeout: timeoutMs }, (res) => {
44
+ let body = '';
45
+ res.on('data', (chunk) => { body += chunk.toString(); });
46
+ res.on('end', () => {
47
+ try {
48
+ const data = JSON.parse(body);
49
+ // A valid CDP endpoint returns { Browser, ... } with a webSocketDebuggerUrl
50
+ resolve(!!data && typeof data === 'object' && !!data.Browser);
51
+ }
52
+ catch {
53
+ resolve(false);
54
+ }
55
+ });
56
+ });
57
+ req.on('error', () => resolve(false));
58
+ req.on('timeout', () => { req.destroy(); resolve(false); });
59
+ });
60
+ }
33
61
  export async function discoverChromeEndpoint() {
34
62
  const candidates = [];
35
63
  // User-specified Chrome data dir takes highest priority
@@ -62,7 +90,12 @@ export async function discoverChromeEndpoint() {
62
90
  const endpoint = `ws://127.0.0.1:${port}${browserPath}`;
63
91
  // Verify the port is actually reachable (Chrome may have closed, leaving a stale file)
64
92
  if (await isPortReachable(port)) {
65
- return endpoint;
93
+ // Verify CDP HTTP API is functional — chrome://inspect#remote-debugging
94
+ // writes DevToolsActivePort but doesn't expose the full CDP API,
95
+ // causing Playwright connectOverCDP to hang on all tool calls.
96
+ if (await isCdpApiAvailable(port)) {
97
+ return endpoint;
98
+ }
66
99
  }
67
100
  }
68
101
  }
@@ -81,6 +114,8 @@ catch {
81
114
  } })();
82
115
  const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
83
116
  const STDERR_BUFFER_LIMIT = 16 * 1024;
117
+ const INITIAL_TABS_TIMEOUT_MS = 1500;
118
+ const CDP_READINESS_PROBE_TIMEOUT_MS = 5000;
84
119
  const TAB_CLEANUP_TIMEOUT_MS = 2000;
85
120
  let _cachedMcpServerPath;
86
121
  export function getTokenFingerprint(token) {
@@ -378,6 +413,7 @@ export class PlaywrightMCP {
378
413
  _initialTabIdentities = [];
379
414
  _closingPromise = null;
380
415
  _state = 'idle';
416
+ _mode = null;
381
417
  _page = null;
382
418
  get state() {
383
419
  return this._state;
@@ -409,6 +445,7 @@ export class PlaywrightMCP {
409
445
  this._page = null;
410
446
  this._proc = null;
411
447
  this._buffer = '';
448
+ this._mode = null;
412
449
  this._initialTabIdentities = [];
413
450
  this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
414
451
  PlaywrightMCP._activeInsts.delete(this);
@@ -454,6 +491,7 @@ export class PlaywrightMCP {
454
491
  const isDebug = process.env.DEBUG?.includes('opencli:mcp');
455
492
  const debugLog = (msg) => isDebug && console.error(`[opencli:mcp] ${msg}`);
456
493
  const mode = cdpEndpoint ? 'cdp' : 'extension';
494
+ this._mode = mode;
457
495
  const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
458
496
  const tokenFingerprint = getTokenFingerprint(extensionToken);
459
497
  let stderrBuffer = '';
@@ -584,9 +622,30 @@ export class PlaywrightMCP {
584
622
  const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
585
623
  debugLog(`SEND: ${initializedMsg.trim()}`);
586
624
  this._proc?.stdin?.write(initializedMsg);
587
- // Get initial tab count for cleanup (with timeout — CDP mode may hang on browser_tabs)
625
+ if (mode === 'cdp') {
626
+ // CDP readiness probe: verify tool calls actually work.
627
+ // Some CDP endpoints (e.g. chrome://inspect mode) accept WebSocket
628
+ // connections and respond to MCP init but silently drop tool calls.
629
+ debugLog('CDP readiness probe (tabs)...');
630
+ withTimeout(page.tabs(), CDP_READINESS_PROBE_TIMEOUT_MS, 'CDP readiness probe timed out')
631
+ .then(() => {
632
+ debugLog('CDP readiness probe succeeded');
633
+ settleSuccess(page);
634
+ })
635
+ .catch((err) => {
636
+ debugLog(`CDP readiness probe failed: ${err.message}`);
637
+ settleError('cdp-timeout', {
638
+ rawMessage: 'CDP endpoint connected but tool calls are unresponsive. ' +
639
+ 'This usually means Chrome was opened with chrome://inspect#remote-debugging ' +
640
+ 'which is not fully compatible. Launch Chrome with --remote-debugging-port=9222 instead, ' +
641
+ 'or use the Playwright MCP Bridge extension (default mode).',
642
+ });
643
+ });
644
+ return;
645
+ }
646
+ // Extension mode uses tabs as a readiness probe and for tab cleanup bookkeeping.
588
647
  debugLog('Fetching initial tabs count...');
589
- withTimeout(page.tabs(), 5000, 'Timed out fetching initial tabs').then((tabs) => {
648
+ withTimeout(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs) => {
590
649
  debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
591
650
  this._initialTabIdentities = extractTabIdentities(tabs);
592
651
  settleSuccess(page);
@@ -608,8 +667,8 @@ export class PlaywrightMCP {
608
667
  this._state = 'closing';
609
668
  this._closingPromise = (async () => {
610
669
  try {
611
- // Close tabs opened during this session (site tabs + extension tabs)
612
- if (this._page && this._proc && !this._proc.killed) {
670
+ // Extension mode opens bridge/session tabs that we can clean up best-effort.
671
+ if (this._mode === 'extension' && this._page && this._proc && !this._proc.killed) {
613
672
  try {
614
673
  const tabs = await withTimeout(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
615
674
  const tabEntries = extractTabEntries(tabs);
@@ -648,6 +707,7 @@ export class PlaywrightMCP {
648
707
  this._rejectPendingRequests(new Error('Playwright MCP session closed'));
649
708
  this._page = null;
650
709
  this._proc = null;
710
+ this._mode = null;
651
711
  this._state = 'closed';
652
712
  PlaywrightMCP._activeInsts.delete(this);
653
713
  }
@@ -730,6 +790,7 @@ export const __test__ = {
730
790
  diffTabIndexes,
731
791
  appendLimited,
732
792
  withTimeout,
793
+ isCdpApiAvailable,
733
794
  };
734
795
  function findMcpServerPath() {
735
796
  if (_cachedMcpServerPath !== undefined)
@@ -53,4 +53,10 @@ describe('PlaywrightMCP state', () => {
53
53
  mcp._state = 'closing';
54
54
  await expect(mcp.connect()).rejects.toThrow('Playwright MCP is closing');
55
55
  });
56
+ it('tracks backend mode for lifecycle policy', async () => {
57
+ const mcp = new PlaywrightMCP();
58
+ expect(mcp._mode).toBeNull();
59
+ await mcp.close();
60
+ expect(mcp._mode).toBeNull();
61
+ });
56
62
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -74,4 +74,14 @@ describe('PlaywrightMCP state', () => {
74
74
 
75
75
  await expect(mcp.connect()).rejects.toThrow('Playwright MCP is closing');
76
76
  });
77
+
78
+ it('tracks backend mode for lifecycle policy', async () => {
79
+ const mcp = new PlaywrightMCP();
80
+
81
+ expect((mcp as any)._mode).toBeNull();
82
+
83
+ await mcp.close();
84
+
85
+ expect((mcp as any)._mode).toBeNull();
86
+ });
77
87
  });
package/src/browser.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { spawn, execSync, type ChildProcess } from 'node:child_process';
7
7
  import { createHash } from 'node:crypto';
8
+ import * as http from 'node:http';
8
9
  import * as net from 'node:net';
9
10
  import { fileURLToPath } from 'node:url';
10
11
  import * as fs from 'node:fs';
@@ -34,6 +35,33 @@ function isPortReachable(port: number, host = '127.0.0.1', timeoutMs = 800): Pro
34
35
  });
35
36
  }
36
37
 
38
+ /**
39
+ * Verify the CDP HTTP JSON API is functional.
40
+ * Chrome's chrome://inspect#remote-debugging mode writes DevToolsActivePort
41
+ * but doesn't expose the full CDP HTTP API (/json/version), which means
42
+ * Playwright's connectOverCDP won't work properly (init succeeds but
43
+ * all tool calls hang silently).
44
+ */
45
+ export function isCdpApiAvailable(port: number, host = '127.0.0.1', timeoutMs = 2000): Promise<boolean> {
46
+ return new Promise(resolve => {
47
+ const req = http.get(`http://${host}:${port}/json/version`, { timeout: timeoutMs }, (res) => {
48
+ let body = '';
49
+ res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
50
+ res.on('end', () => {
51
+ try {
52
+ const data = JSON.parse(body);
53
+ // A valid CDP endpoint returns { Browser, ... } with a webSocketDebuggerUrl
54
+ resolve(!!data && typeof data === 'object' && !!data.Browser);
55
+ } catch {
56
+ resolve(false);
57
+ }
58
+ });
59
+ });
60
+ req.on('error', () => resolve(false));
61
+ req.on('timeout', () => { req.destroy(); resolve(false); });
62
+ });
63
+ }
64
+
37
65
  export async function discoverChromeEndpoint(): Promise<string | null> {
38
66
  const candidates: string[] = [];
39
67
 
@@ -67,7 +95,12 @@ export async function discoverChromeEndpoint(): Promise<string | null> {
67
95
  const endpoint = `ws://127.0.0.1:${port}${browserPath}`;
68
96
  // Verify the port is actually reachable (Chrome may have closed, leaving a stale file)
69
97
  if (await isPortReachable(port)) {
70
- return endpoint;
98
+ // Verify CDP HTTP API is functional — chrome://inspect#remote-debugging
99
+ // writes DevToolsActivePort but doesn't expose the full CDP API,
100
+ // causing Playwright connectOverCDP to hang on all tool calls.
101
+ if (await isCdpApiAvailable(port)) {
102
+ return endpoint;
103
+ }
71
104
  }
72
105
  }
73
106
  }
@@ -82,11 +115,14 @@ const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.resolv
82
115
 
83
116
  const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
84
117
  const STDERR_BUFFER_LIMIT = 16 * 1024;
118
+ const INITIAL_TABS_TIMEOUT_MS = 1500;
119
+ const CDP_READINESS_PROBE_TIMEOUT_MS = 5000;
85
120
  const TAB_CLEANUP_TIMEOUT_MS = 2000;
86
121
  let _cachedMcpServerPath: string | null | undefined;
87
122
 
88
123
  type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'cdp-timeout' | 'mcp-init' | 'process-exit' | 'unknown';
89
124
  type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
125
+ type PlaywrightMCPMode = 'extension' | 'cdp' | null;
90
126
 
91
127
  type ConnectFailureInput = {
92
128
  kind: ConnectFailureKind;
@@ -425,6 +461,7 @@ export class PlaywrightMCP {
425
461
  private _initialTabIdentities: string[] = [];
426
462
  private _closingPromise: Promise<void> | null = null;
427
463
  private _state: PlaywrightMCPState = 'idle';
464
+ private _mode: PlaywrightMCPMode = null;
428
465
 
429
466
  private _page: Page | null = null;
430
467
 
@@ -459,6 +496,7 @@ export class PlaywrightMCP {
459
496
  this._page = null;
460
497
  this._proc = null;
461
498
  this._buffer = '';
499
+ this._mode = null;
462
500
  this._initialTabIdentities = [];
463
501
  this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
464
502
  PlaywrightMCP._activeInsts.delete(this);
@@ -500,6 +538,7 @@ export class PlaywrightMCP {
500
538
  const isDebug = process.env.DEBUG?.includes('opencli:mcp');
501
539
  const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
502
540
  const mode: 'extension' | 'cdp' = cdpEndpoint ? 'cdp' : 'extension';
541
+ this._mode = mode;
503
542
  const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
504
543
  const tokenFingerprint = getTokenFingerprint(extensionToken);
505
544
  let stderrBuffer = '';
@@ -636,9 +675,31 @@ export class PlaywrightMCP {
636
675
  debugLog(`SEND: ${initializedMsg.trim()}`);
637
676
  this._proc?.stdin?.write(initializedMsg);
638
677
 
639
- // Get initial tab count for cleanup (with timeout — CDP mode may hang on browser_tabs)
678
+ if (mode === 'cdp') {
679
+ // CDP readiness probe: verify tool calls actually work.
680
+ // Some CDP endpoints (e.g. chrome://inspect mode) accept WebSocket
681
+ // connections and respond to MCP init but silently drop tool calls.
682
+ debugLog('CDP readiness probe (tabs)...');
683
+ withTimeout(page.tabs(), CDP_READINESS_PROBE_TIMEOUT_MS, 'CDP readiness probe timed out')
684
+ .then(() => {
685
+ debugLog('CDP readiness probe succeeded');
686
+ settleSuccess(page);
687
+ })
688
+ .catch((err) => {
689
+ debugLog(`CDP readiness probe failed: ${err.message}`);
690
+ settleError('cdp-timeout', {
691
+ rawMessage: 'CDP endpoint connected but tool calls are unresponsive. ' +
692
+ 'This usually means Chrome was opened with chrome://inspect#remote-debugging ' +
693
+ 'which is not fully compatible. Launch Chrome with --remote-debugging-port=9222 instead, ' +
694
+ 'or use the Playwright MCP Bridge extension (default mode).',
695
+ });
696
+ });
697
+ return;
698
+ }
699
+
700
+ // Extension mode uses tabs as a readiness probe and for tab cleanup bookkeeping.
640
701
  debugLog('Fetching initial tabs count...');
641
- withTimeout(page.tabs(), 5000, 'Timed out fetching initial tabs').then((tabs: any) => {
702
+ withTimeout(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
642
703
  debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
643
704
  this._initialTabIdentities = extractTabIdentities(tabs);
644
705
  settleSuccess(page);
@@ -659,8 +720,8 @@ export class PlaywrightMCP {
659
720
  this._state = 'closing';
660
721
  this._closingPromise = (async () => {
661
722
  try {
662
- // Close tabs opened during this session (site tabs + extension tabs)
663
- if (this._page && this._proc && !this._proc.killed) {
723
+ // Extension mode opens bridge/session tabs that we can clean up best-effort.
724
+ if (this._mode === 'extension' && this._page && this._proc && !this._proc.killed) {
664
725
  try {
665
726
  const tabs = await withTimeout(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
666
727
  const tabEntries = extractTabEntries(tabs);
@@ -690,6 +751,7 @@ export class PlaywrightMCP {
690
751
  this._rejectPendingRequests(new Error('Playwright MCP session closed'));
691
752
  this._page = null;
692
753
  this._proc = null;
754
+ this._mode = null;
693
755
  this._state = 'closed';
694
756
  PlaywrightMCP._activeInsts.delete(this);
695
757
  }
@@ -782,6 +844,7 @@ export const __test__ = {
782
844
  diffTabIndexes,
783
845
  appendLimited,
784
846
  withTimeout,
847
+ isCdpApiAvailable,
785
848
  };
786
849
 
787
850
  function findMcpServerPath(): string | null {