@jackwener/opencli 0.4.3 → 0.4.4
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/{CLI-CREATOR.md → CLI-EXPLORER.md} +5 -1
- package/CLI-ONESHOT.md +216 -0
- package/README.md +4 -3
- package/README.zh-CN.md +4 -3
- package/SKILL.md +6 -4
- package/dist/browser.d.ts +32 -8
- package/dist/browser.js +233 -107
- package/dist/browser.test.js +51 -38
- package/package.json +1 -1
- package/src/browser.test.ts +69 -43
- package/src/browser.ts +232 -86
package/src/browser.test.ts
CHANGED
|
@@ -1,51 +1,77 @@
|
|
|
1
|
-
import { describe,
|
|
2
|
-
import {
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { PlaywrightMCP, __test__ } from './browser.js';
|
|
3
3
|
|
|
4
|
-
describe('
|
|
5
|
-
it('
|
|
6
|
-
|
|
4
|
+
describe('browser helpers', () => {
|
|
5
|
+
it('creates JSON-RPC requests with unique ids', () => {
|
|
6
|
+
const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' });
|
|
7
|
+
const second = __test__.createJsonRpcRequest('tools/call', { name: 'browser_snapshot' });
|
|
8
|
+
|
|
9
|
+
expect(second.id).toBe(first.id + 1);
|
|
10
|
+
expect(first.message).toContain(`"id":${first.id}`);
|
|
11
|
+
expect(second.message).toContain(`"id":${second.id}`);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('extracts tab entries from string snapshots', () => {
|
|
15
|
+
const entries = __test__.extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
|
|
16
|
+
|
|
17
|
+
expect(entries).toEqual([
|
|
18
|
+
{ index: 0, identity: 'https://example.com' },
|
|
19
|
+
{ index: 1, identity: 'Chrome Extension' },
|
|
20
|
+
]);
|
|
7
21
|
});
|
|
8
22
|
|
|
9
|
-
it('
|
|
10
|
-
|
|
23
|
+
it('closes only tabs that were opened during the session', () => {
|
|
24
|
+
const tabsToClose = __test__.diffTabIndexes(
|
|
25
|
+
['https://example.com', 'Chrome Extension'],
|
|
26
|
+
[
|
|
27
|
+
{ index: 0, identity: 'https://example.com' },
|
|
28
|
+
{ index: 1, identity: 'Chrome Extension' },
|
|
29
|
+
{ index: 2, identity: 'https://target.example/page' },
|
|
30
|
+
{ index: 3, identity: 'chrome-extension://bridge' },
|
|
31
|
+
],
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(tabsToClose).toEqual([3, 2]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('keeps only the tail of stderr buffers', () => {
|
|
38
|
+
expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('times out slow promises', async () => {
|
|
42
|
+
await expect(__test__.withTimeout(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
|
|
11
43
|
});
|
|
12
44
|
});
|
|
13
45
|
|
|
14
|
-
describe('
|
|
15
|
-
it('
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
expect(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
timeout: 30,
|
|
45
|
-
hasExtensionToken: false,
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
expect(err.message).toContain('via CDP');
|
|
49
|
-
expect(err.message).toContain('chrome://inspect#remote-debugging');
|
|
46
|
+
describe('PlaywrightMCP state', () => {
|
|
47
|
+
it('transitions to closed after close()', async () => {
|
|
48
|
+
const mcp = new PlaywrightMCP();
|
|
49
|
+
|
|
50
|
+
expect(mcp.state).toBe('idle');
|
|
51
|
+
|
|
52
|
+
await mcp.close();
|
|
53
|
+
|
|
54
|
+
expect(mcp.state).toBe('closed');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rejects connect() after the session has been closed', async () => {
|
|
58
|
+
const mcp = new PlaywrightMCP();
|
|
59
|
+
await mcp.close();
|
|
60
|
+
|
|
61
|
+
await expect(mcp.connect()).rejects.toThrow('Playwright MCP session is closed');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects connect() while already connecting', async () => {
|
|
65
|
+
const mcp = new PlaywrightMCP();
|
|
66
|
+
(mcp as any)._state = 'connecting';
|
|
67
|
+
|
|
68
|
+
await expect(mcp.connect()).rejects.toThrow('Playwright MCP is already connecting');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects connect() while closing', async () => {
|
|
72
|
+
const mcp = new PlaywrightMCP();
|
|
73
|
+
(mcp as any)._state = 'closing';
|
|
74
|
+
|
|
75
|
+
await expect(mcp.connect()).rejects.toThrow('Playwright MCP is closing');
|
|
50
76
|
});
|
|
51
77
|
});
|
package/src/browser.ts
CHANGED
|
@@ -80,12 +80,13 @@ export async function discoverChromeEndpoint(): Promise<string | null> {
|
|
|
80
80
|
const __browser_dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
81
81
|
const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.resolve(__browser_dirname, '..', 'package.json'), 'utf-8')).version; } catch { return '0.0.0'; } })();
|
|
82
82
|
|
|
83
|
-
const EXTENSION_LOCK_TIMEOUT = parseInt(process.env.OPENCLI_EXTENSION_LOCK_TIMEOUT ?? '120', 10);
|
|
84
|
-
const EXTENSION_LOCK_POLL = parseInt(process.env.OPENCLI_EXTENSION_LOCK_POLL_INTERVAL ?? '1', 10);
|
|
85
83
|
const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
|
|
86
|
-
const
|
|
84
|
+
const STDERR_BUFFER_LIMIT = 16 * 1024;
|
|
85
|
+
const TAB_CLEANUP_TIMEOUT_MS = 2000;
|
|
86
|
+
let _cachedMcpServerPath: string | null | undefined;
|
|
87
87
|
|
|
88
88
|
type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'cdp-timeout' | 'mcp-init' | 'process-exit' | 'unknown';
|
|
89
|
+
type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
|
|
89
90
|
|
|
90
91
|
type ConnectFailureInput = {
|
|
91
92
|
kind: ConnectFailureKind;
|
|
@@ -187,8 +188,12 @@ function inferConnectFailureKind(args: {
|
|
|
187
188
|
|
|
188
189
|
// JSON-RPC helpers
|
|
189
190
|
let _nextId = 1;
|
|
190
|
-
function
|
|
191
|
-
|
|
191
|
+
function createJsonRpcRequest(method: string, params: Record<string, any> = {}): { id: number; message: string } {
|
|
192
|
+
const id = _nextId++;
|
|
193
|
+
return {
|
|
194
|
+
id,
|
|
195
|
+
message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
|
|
196
|
+
};
|
|
192
197
|
}
|
|
193
198
|
|
|
194
199
|
import type { IPage } from './types.js';
|
|
@@ -197,11 +202,10 @@ import type { IPage } from './types.js';
|
|
|
197
202
|
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
|
|
198
203
|
*/
|
|
199
204
|
export class Page implements IPage {
|
|
200
|
-
constructor(private
|
|
205
|
+
constructor(private _request: (method: string, params?: Record<string, any>) => Promise<any>) {}
|
|
201
206
|
|
|
202
207
|
async call(method: string, params: Record<string, any> = {}): Promise<any> {
|
|
203
|
-
this.
|
|
204
|
-
const resp = await this._recv();
|
|
208
|
+
const resp = await this._request(method, params);
|
|
205
209
|
if (resp.error) throw new Error(`page.${method}: ${resp.error.message ?? JSON.stringify(resp.error)}`);
|
|
206
210
|
// Extract text content from MCP result
|
|
207
211
|
const result = resp.result;
|
|
@@ -405,10 +409,6 @@ export class PlaywrightMCP {
|
|
|
405
409
|
this._cleanupRegistered = true;
|
|
406
410
|
const cleanup = () => {
|
|
407
411
|
for (const inst of this._activeInsts) {
|
|
408
|
-
if (inst._lockAcquired) {
|
|
409
|
-
try { fs.rmdirSync(LOCK_DIR); } catch {}
|
|
410
|
-
inst._lockAcquired = false;
|
|
411
|
-
}
|
|
412
412
|
if (inst._proc && !inst._proc.killed) {
|
|
413
413
|
try { inst._proc.kill('SIGKILL'); } catch {}
|
|
414
414
|
}
|
|
@@ -421,18 +421,66 @@ export class PlaywrightMCP {
|
|
|
421
421
|
|
|
422
422
|
private _proc: ChildProcess | null = null;
|
|
423
423
|
private _buffer = '';
|
|
424
|
-
private
|
|
425
|
-
private
|
|
426
|
-
private
|
|
424
|
+
private _pending = new Map<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
|
|
425
|
+
private _initialTabIdentities: string[] = [];
|
|
426
|
+
private _closingPromise: Promise<void> | null = null;
|
|
427
|
+
private _state: PlaywrightMCPState = 'idle';
|
|
427
428
|
|
|
428
429
|
private _page: Page | null = null;
|
|
429
430
|
|
|
431
|
+
get state(): PlaywrightMCPState {
|
|
432
|
+
return this._state;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private _sendRequest(method: string, params: Record<string, any> = {}): Promise<any> {
|
|
436
|
+
return new Promise<any>((resolve, reject) => {
|
|
437
|
+
if (!this._proc?.stdin?.writable) {
|
|
438
|
+
reject(new Error('Playwright MCP process is not writable'));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const { id, message } = createJsonRpcRequest(method, params);
|
|
442
|
+
this._pending.set(id, { resolve, reject });
|
|
443
|
+
this._proc.stdin.write(message, (err) => {
|
|
444
|
+
if (!err) return;
|
|
445
|
+
this._pending.delete(id);
|
|
446
|
+
reject(err);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private _rejectPendingRequests(error: Error): void {
|
|
452
|
+
const pending = [...this._pending.values()];
|
|
453
|
+
this._pending.clear();
|
|
454
|
+
for (const waiter of pending) waiter.reject(error);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private _resetAfterFailedConnect(): void {
|
|
458
|
+
const proc = this._proc;
|
|
459
|
+
this._page = null;
|
|
460
|
+
this._proc = null;
|
|
461
|
+
this._buffer = '';
|
|
462
|
+
this._initialTabIdentities = [];
|
|
463
|
+
this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
|
|
464
|
+
PlaywrightMCP._activeInsts.delete(this);
|
|
465
|
+
if (proc && !proc.killed) {
|
|
466
|
+
try { proc.kill('SIGKILL'); } catch {}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
430
470
|
async connect(opts: { timeout?: number; forceExtension?: boolean } = {}): Promise<Page> {
|
|
431
|
-
|
|
432
|
-
|
|
471
|
+
if (this._state === 'connected' && this._page) return this._page;
|
|
472
|
+
if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting');
|
|
473
|
+
if (this._state === 'closing') throw new Error('Playwright MCP is closing');
|
|
474
|
+
if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
|
|
475
|
+
|
|
433
476
|
const mcpPath = findMcpServerPath();
|
|
434
477
|
if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp');
|
|
435
478
|
|
|
479
|
+
PlaywrightMCP._registerGlobalCleanup();
|
|
480
|
+
PlaywrightMCP._activeInsts.add(this);
|
|
481
|
+
this._state = 'connecting';
|
|
482
|
+
const timeout = opts.timeout ?? CONNECT_TIMEOUT;
|
|
483
|
+
|
|
436
484
|
// Connection priority:
|
|
437
485
|
// 1. OPENCLI_CDP_ENDPOINT env var → explicit CDP endpoint
|
|
438
486
|
// 2. OPENCLI_USE_CDP=1 → auto-discover via DevToolsActivePort
|
|
@@ -460,7 +508,9 @@ export class PlaywrightMCP {
|
|
|
460
508
|
const settleError = (kind: ConnectFailureKind, extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
|
|
461
509
|
if (settled) return;
|
|
462
510
|
settled = true;
|
|
511
|
+
this._state = 'idle';
|
|
463
512
|
clearTimeout(timer);
|
|
513
|
+
this._resetAfterFailedConnect();
|
|
464
514
|
reject(formatBrowserConnectError({
|
|
465
515
|
kind,
|
|
466
516
|
mode,
|
|
@@ -476,6 +526,7 @@ export class PlaywrightMCP {
|
|
|
476
526
|
const settleSuccess = (pageToResolve: Page) => {
|
|
477
527
|
if (settled) return;
|
|
478
528
|
settled = true;
|
|
529
|
+
this._state = 'connected';
|
|
479
530
|
clearTimeout(timer);
|
|
480
531
|
resolve(pageToResolve);
|
|
481
532
|
};
|
|
@@ -515,10 +566,7 @@ export class PlaywrightMCP {
|
|
|
515
566
|
this._proc.setMaxListeners(20);
|
|
516
567
|
if (this._proc.stdout) this._proc.stdout.setMaxListeners(20);
|
|
517
568
|
|
|
518
|
-
const page = new Page(
|
|
519
|
-
(msg) => { if (this._proc?.stdin?.writable) this._proc.stdin.write(msg); },
|
|
520
|
-
() => new Promise<any>((res) => { this._waiters.push(res); }),
|
|
521
|
-
);
|
|
569
|
+
const page = new Page((method, params = {}) => this._sendRequest(method, params));
|
|
522
570
|
this._page = page;
|
|
523
571
|
|
|
524
572
|
this._proc.stdout?.on('data', (chunk: Buffer) => {
|
|
@@ -530,8 +578,13 @@ export class PlaywrightMCP {
|
|
|
530
578
|
debugLog(`RECV: ${line}`);
|
|
531
579
|
try {
|
|
532
580
|
const parsed = JSON.parse(line);
|
|
533
|
-
|
|
534
|
-
|
|
581
|
+
if (typeof parsed?.id === 'number') {
|
|
582
|
+
const waiter = this._pending.get(parsed.id);
|
|
583
|
+
if (waiter) {
|
|
584
|
+
this._pending.delete(parsed.id);
|
|
585
|
+
waiter.resolve(parsed);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
535
588
|
} catch (e) {
|
|
536
589
|
debugLog(`Parse error: ${e}`);
|
|
537
590
|
}
|
|
@@ -540,15 +593,17 @@ export class PlaywrightMCP {
|
|
|
540
593
|
|
|
541
594
|
this._proc.stderr?.on('data', (chunk: Buffer) => {
|
|
542
595
|
const text = chunk.toString();
|
|
543
|
-
stderrBuffer
|
|
596
|
+
stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
|
|
544
597
|
debugLog(`STDERR: ${text}`);
|
|
545
598
|
});
|
|
546
599
|
this._proc.on('error', (err) => {
|
|
547
600
|
debugLog(`Subprocess error: ${err.message}`);
|
|
601
|
+
this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
|
|
548
602
|
settleError('process-exit', { rawMessage: err.message });
|
|
549
603
|
});
|
|
550
604
|
this._proc.on('close', (code) => {
|
|
551
605
|
debugLog(`Subprocess closed with code ${code}`);
|
|
606
|
+
this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
|
|
552
607
|
if (!settled) {
|
|
553
608
|
settleError(inferConnectFailureKind({
|
|
554
609
|
mode,
|
|
@@ -560,18 +615,12 @@ export class PlaywrightMCP {
|
|
|
560
615
|
});
|
|
561
616
|
|
|
562
617
|
// Initialize: send initialize request
|
|
563
|
-
|
|
618
|
+
debugLog('Waiting for initialize response...');
|
|
619
|
+
this._sendRequest('initialize', {
|
|
564
620
|
protocolVersion: '2024-11-05',
|
|
565
621
|
capabilities: {},
|
|
566
622
|
clientInfo: { name: 'opencli', version: PKG_VERSION },
|
|
567
|
-
})
|
|
568
|
-
debugLog(`SEND: ${initMsg.trim()}`);
|
|
569
|
-
this._proc.stdin?.write(initMsg);
|
|
570
|
-
|
|
571
|
-
// Wait for initialize response, then send initialized notification
|
|
572
|
-
const origRecv = () => new Promise<any>((res) => { this._waiters.push(res); });
|
|
573
|
-
debugLog('Waiting for initialize response...');
|
|
574
|
-
origRecv().then((resp) => {
|
|
623
|
+
}).then((resp) => {
|
|
575
624
|
debugLog('Got initialize response');
|
|
576
625
|
if (resp.error) {
|
|
577
626
|
settleError(inferConnectFailureKind({
|
|
@@ -591,11 +640,7 @@ export class PlaywrightMCP {
|
|
|
591
640
|
debugLog('Fetching initial tabs count...');
|
|
592
641
|
page.tabs().then((tabs: any) => {
|
|
593
642
|
debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
|
|
594
|
-
|
|
595
|
-
this._initialTabCount = (tabs.match(/Tab \d+/g) || []).length;
|
|
596
|
-
} else if (Array.isArray(tabs)) {
|
|
597
|
-
this._initialTabCount = tabs.length;
|
|
598
|
-
}
|
|
643
|
+
this._initialTabIdentities = extractTabIdentities(tabs);
|
|
599
644
|
settleSuccess(page);
|
|
600
645
|
}).catch((err) => {
|
|
601
646
|
debugLog(`Tabs fetch error: ${err.message}`);
|
|
@@ -609,68 +654,159 @@ export class PlaywrightMCP {
|
|
|
609
654
|
}
|
|
610
655
|
|
|
611
656
|
async close(): Promise<void> {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
try { await this._page.closeTab(i); } catch {}
|
|
657
|
+
if (this._closingPromise) return this._closingPromise;
|
|
658
|
+
if (this._state === 'closed') return;
|
|
659
|
+
this._state = 'closing';
|
|
660
|
+
this._closingPromise = (async () => {
|
|
661
|
+
try {
|
|
662
|
+
// Close tabs opened during this session (site tabs + extension tabs)
|
|
663
|
+
if (this._page && this._proc && !this._proc.killed) {
|
|
664
|
+
try {
|
|
665
|
+
const tabs = await withTimeout(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
|
|
666
|
+
const tabEntries = extractTabEntries(tabs);
|
|
667
|
+
const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
|
|
668
|
+
for (const index of tabsToClose) {
|
|
669
|
+
try { await this._page.closeTab(index); } catch {}
|
|
626
670
|
}
|
|
671
|
+
} catch {}
|
|
672
|
+
}
|
|
673
|
+
if (this._proc && !this._proc.killed) {
|
|
674
|
+
this._proc.kill('SIGTERM');
|
|
675
|
+
const exited = await new Promise<boolean>((res) => {
|
|
676
|
+
let done = false;
|
|
677
|
+
const finish = (value: boolean) => {
|
|
678
|
+
if (done) return;
|
|
679
|
+
done = true;
|
|
680
|
+
res(value);
|
|
681
|
+
};
|
|
682
|
+
this._proc?.once('exit', () => finish(true));
|
|
683
|
+
setTimeout(() => finish(false), 3000);
|
|
684
|
+
});
|
|
685
|
+
if (!exited && this._proc && !this._proc.killed) {
|
|
686
|
+
try { this._proc.kill('SIGKILL'); } catch {}
|
|
627
687
|
}
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
this.
|
|
632
|
-
|
|
688
|
+
}
|
|
689
|
+
} finally {
|
|
690
|
+
this._rejectPendingRequests(new Error('Playwright MCP session closed'));
|
|
691
|
+
this._page = null;
|
|
692
|
+
this._proc = null;
|
|
693
|
+
this._state = 'closed';
|
|
694
|
+
PlaywrightMCP._activeInsts.delete(this);
|
|
633
695
|
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
this._releaseLock();
|
|
637
|
-
PlaywrightMCP._activeInsts.delete(this);
|
|
638
|
-
}
|
|
696
|
+
})();
|
|
697
|
+
return this._closingPromise;
|
|
639
698
|
}
|
|
699
|
+
}
|
|
640
700
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
701
|
+
function extractTabEntries(raw: any): Array<{ index: number; identity: string }> {
|
|
702
|
+
if (Array.isArray(raw)) {
|
|
703
|
+
return raw.map((tab: any, index: number) => ({
|
|
704
|
+
index,
|
|
705
|
+
identity: [
|
|
706
|
+
tab?.id ?? '',
|
|
707
|
+
tab?.url ?? '',
|
|
708
|
+
tab?.title ?? '',
|
|
709
|
+
tab?.name ?? '',
|
|
710
|
+
].join('|'),
|
|
711
|
+
}));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (typeof raw === 'string') {
|
|
715
|
+
return raw
|
|
716
|
+
.split('\n')
|
|
717
|
+
.map(line => line.trim())
|
|
718
|
+
.filter(Boolean)
|
|
719
|
+
.map(line => {
|
|
720
|
+
const match = line.match(/Tab\s+(\d+)\s*(.*)$/);
|
|
721
|
+
if (!match) return null;
|
|
722
|
+
return {
|
|
723
|
+
index: parseInt(match[1], 10),
|
|
724
|
+
identity: match[2].trim() || `tab-${match[1]}`,
|
|
725
|
+
};
|
|
726
|
+
})
|
|
727
|
+
.filter((entry): entry is { index: number; identity: string } => entry !== null);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return [];
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function extractTabIdentities(raw: any): string[] {
|
|
734
|
+
return extractTabEntries(raw).map(tab => tab.identity);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] {
|
|
738
|
+
if (initialIdentities.length === 0 || currentTabs.length === 0) return [];
|
|
739
|
+
const remaining = new Map<string, number>();
|
|
740
|
+
for (const identity of initialIdentities) {
|
|
741
|
+
remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
|
|
655
742
|
}
|
|
656
743
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
744
|
+
const tabsToClose: number[] = [];
|
|
745
|
+
for (const tab of currentTabs) {
|
|
746
|
+
const count = remaining.get(tab.identity) ?? 0;
|
|
747
|
+
if (count > 0) {
|
|
748
|
+
remaining.set(tab.identity, count - 1);
|
|
749
|
+
continue;
|
|
661
750
|
}
|
|
751
|
+
tabsToClose.push(tab.index);
|
|
662
752
|
}
|
|
753
|
+
|
|
754
|
+
return tabsToClose.sort((a, b) => b - a);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function appendLimited(current: string, chunk: string, limit: number): string {
|
|
758
|
+
const next = current + chunk;
|
|
759
|
+
if (next.length <= limit) return next;
|
|
760
|
+
return next.slice(-limit);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
764
|
+
return new Promise<T>((resolve, reject) => {
|
|
765
|
+
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
766
|
+
promise.then(
|
|
767
|
+
(value) => {
|
|
768
|
+
clearTimeout(timer);
|
|
769
|
+
resolve(value);
|
|
770
|
+
},
|
|
771
|
+
(error) => {
|
|
772
|
+
clearTimeout(timer);
|
|
773
|
+
reject(error);
|
|
774
|
+
},
|
|
775
|
+
);
|
|
776
|
+
});
|
|
663
777
|
}
|
|
664
778
|
|
|
779
|
+
export const __test__ = {
|
|
780
|
+
createJsonRpcRequest,
|
|
781
|
+
extractTabEntries,
|
|
782
|
+
diffTabIndexes,
|
|
783
|
+
appendLimited,
|
|
784
|
+
withTimeout,
|
|
785
|
+
};
|
|
786
|
+
|
|
665
787
|
function findMcpServerPath(): string | null {
|
|
788
|
+
if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
|
|
789
|
+
|
|
790
|
+
const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
|
|
791
|
+
if (envMcp && fs.existsSync(envMcp)) {
|
|
792
|
+
_cachedMcpServerPath = envMcp;
|
|
793
|
+
return _cachedMcpServerPath;
|
|
794
|
+
}
|
|
795
|
+
|
|
666
796
|
// Check local node_modules first (@playwright/mcp is the modern package)
|
|
667
797
|
const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
|
|
668
|
-
if (fs.existsSync(localMcp))
|
|
798
|
+
if (fs.existsSync(localMcp)) {
|
|
799
|
+
_cachedMcpServerPath = localMcp;
|
|
800
|
+
return _cachedMcpServerPath;
|
|
801
|
+
}
|
|
669
802
|
|
|
670
803
|
// Check project-relative path
|
|
671
804
|
const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
|
|
672
805
|
const projectMcp = path.resolve(__dirname2, '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
|
|
673
|
-
if (fs.existsSync(projectMcp))
|
|
806
|
+
if (fs.existsSync(projectMcp)) {
|
|
807
|
+
_cachedMcpServerPath = projectMcp;
|
|
808
|
+
return _cachedMcpServerPath;
|
|
809
|
+
}
|
|
674
810
|
|
|
675
811
|
// Check common locations
|
|
676
812
|
const candidates = [
|
|
@@ -682,13 +818,19 @@ function findMcpServerPath(): string | null {
|
|
|
682
818
|
// Try npx resolution (legacy package name)
|
|
683
819
|
try {
|
|
684
820
|
const result = execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
|
|
685
|
-
if (result && fs.existsSync(result))
|
|
821
|
+
if (result && fs.existsSync(result)) {
|
|
822
|
+
_cachedMcpServerPath = result;
|
|
823
|
+
return _cachedMcpServerPath;
|
|
824
|
+
}
|
|
686
825
|
} catch {}
|
|
687
826
|
|
|
688
827
|
// Try which
|
|
689
828
|
try {
|
|
690
829
|
const result = execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
691
|
-
if (result && fs.existsSync(result))
|
|
830
|
+
if (result && fs.existsSync(result)) {
|
|
831
|
+
_cachedMcpServerPath = result;
|
|
832
|
+
return _cachedMcpServerPath;
|
|
833
|
+
}
|
|
692
834
|
} catch {}
|
|
693
835
|
|
|
694
836
|
// Search in common npx cache
|
|
@@ -696,9 +838,13 @@ function findMcpServerPath(): string | null {
|
|
|
696
838
|
if (!fs.existsSync(base)) continue;
|
|
697
839
|
try {
|
|
698
840
|
const found = execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
699
|
-
if (found)
|
|
841
|
+
if (found) {
|
|
842
|
+
_cachedMcpServerPath = found;
|
|
843
|
+
return _cachedMcpServerPath;
|
|
844
|
+
}
|
|
700
845
|
} catch {}
|
|
701
846
|
}
|
|
702
847
|
|
|
703
|
-
|
|
848
|
+
_cachedMcpServerPath = null;
|
|
849
|
+
return _cachedMcpServerPath;
|
|
704
850
|
}
|