@jackwener/opencli 0.7.6 → 0.7.8

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.
Files changed (55) hide show
  1. package/.agents/skills/cross-project-adapter-migration/SKILL.md +249 -0
  2. package/.agents/workflows/cross-project-adapter-migration.md +54 -0
  3. package/dist/browser/discover.d.ts +8 -0
  4. package/dist/browser/discover.js +83 -0
  5. package/dist/browser/errors.d.ts +21 -0
  6. package/dist/browser/errors.js +54 -0
  7. package/dist/browser/index.d.ts +22 -0
  8. package/dist/browser/index.js +22 -0
  9. package/dist/browser/mcp.d.ts +33 -0
  10. package/dist/browser/mcp.js +304 -0
  11. package/dist/browser/page.d.ts +41 -0
  12. package/dist/browser/page.js +142 -0
  13. package/dist/browser/tabs.d.ts +13 -0
  14. package/dist/browser/tabs.js +70 -0
  15. package/dist/browser.test.js +1 -1
  16. package/dist/completion.js +2 -2
  17. package/dist/doctor.js +7 -7
  18. package/dist/engine.js +6 -4
  19. package/dist/errors.d.ts +25 -0
  20. package/dist/errors.js +42 -0
  21. package/dist/logger.d.ts +22 -0
  22. package/dist/logger.js +47 -0
  23. package/dist/main.js +8 -2
  24. package/dist/pipeline/executor.js +8 -8
  25. package/dist/pipeline/steps/browser.d.ts +7 -7
  26. package/dist/pipeline/steps/intercept.d.ts +1 -1
  27. package/dist/pipeline/steps/tap.d.ts +1 -1
  28. package/dist/setup.js +1 -1
  29. package/package.json +3 -3
  30. package/scripts/clean-yaml.cjs +19 -0
  31. package/scripts/copy-yaml.cjs +21 -0
  32. package/scripts/postinstall.js +30 -9
  33. package/src/bilibili.ts +1 -1
  34. package/src/browser/discover.ts +90 -0
  35. package/src/browser/errors.ts +89 -0
  36. package/src/browser/index.ts +26 -0
  37. package/src/browser/mcp.ts +305 -0
  38. package/src/browser/page.ts +152 -0
  39. package/src/browser/tabs.ts +76 -0
  40. package/src/browser.test.ts +1 -1
  41. package/src/completion.ts +2 -2
  42. package/src/doctor.ts +13 -1
  43. package/src/engine.ts +9 -4
  44. package/src/errors.ts +48 -0
  45. package/src/logger.ts +57 -0
  46. package/src/main.ts +10 -3
  47. package/src/pipeline/executor.ts +8 -7
  48. package/src/pipeline/steps/browser.ts +18 -18
  49. package/src/pipeline/steps/intercept.ts +8 -8
  50. package/src/pipeline/steps/tap.ts +2 -2
  51. package/src/setup.ts +1 -1
  52. package/tsconfig.json +1 -2
  53. package/dist/browser.d.ts +0 -105
  54. package/dist/browser.js +0 -644
  55. package/src/browser.ts +0 -698
package/src/browser.ts DELETED
@@ -1,698 +0,0 @@
1
- /**
2
- * Browser interaction via Playwright MCP Bridge extension.
3
- * Connects to an existing Chrome browser through the extension.
4
- */
5
-
6
- import { spawn, execSync, type ChildProcess } from 'node:child_process';
7
- import { createHash } from 'node:crypto';
8
- import { fileURLToPath } from 'node:url';
9
- import * as fs from 'node:fs';
10
- import * as os from 'node:os';
11
- import * as path from 'node:path';
12
- import { formatSnapshot } from './snapshotFormatter.js';
13
- import { PKG_VERSION } from './version.js';
14
- import { normalizeEvaluateSource } from './pipeline/template.js';
15
- import { generateInterceptorJs, generateReadInterceptedJs } from './interceptor.js';
16
- import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from './runtime.js';
17
- const STDERR_BUFFER_LIMIT = 16 * 1024;
18
- const INITIAL_TABS_TIMEOUT_MS = 1500;
19
- const TAB_CLEANUP_TIMEOUT_MS = 2000;
20
- let _cachedMcpServerPath: string | null | undefined;
21
-
22
- type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown';
23
- type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
24
-
25
- type ConnectFailureInput = {
26
- kind: ConnectFailureKind;
27
- timeout: number;
28
- hasExtensionToken: boolean;
29
- tokenFingerprint?: string | null;
30
- stderr?: string;
31
- exitCode?: number | null;
32
- rawMessage?: string;
33
- };
34
-
35
- export function getTokenFingerprint(token: string | undefined): string | null {
36
- if (!token) return null;
37
- return createHash('sha256').update(token).digest('hex').slice(0, 8);
38
- }
39
-
40
- export function formatBrowserConnectError(input: ConnectFailureInput): Error {
41
- const stderr = input.stderr?.trim();
42
- const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
43
- const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
44
-
45
- if (input.kind === 'missing-token') {
46
- return new Error(
47
- 'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
48
- 'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
49
- 'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' +
50
- suffix,
51
- );
52
- }
53
-
54
- if (input.kind === 'extension-not-installed') {
55
- return new Error(
56
- 'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' +
57
- 'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' +
58
- 'If Chrome shows an approval dialog, click Allow.' +
59
- suffix,
60
- );
61
- }
62
-
63
- if (input.kind === 'extension-timeout') {
64
- const likelyCause = input.hasExtensionToken
65
- ? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.`
66
- : 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.';
67
- return new Error(
68
- `Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` +
69
- `${likelyCause} If a browser prompt is visible, click Allow.` +
70
- suffix,
71
- );
72
- }
73
-
74
- if (input.kind === 'mcp-init') {
75
- return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
76
- }
77
-
78
- if (input.kind === 'process-exit') {
79
- return new Error(
80
- `Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
81
- suffix,
82
- );
83
- }
84
-
85
- return new Error(input.rawMessage ?? 'Failed to connect to browser');
86
- }
87
-
88
- function inferConnectFailureKind(args: {
89
- hasExtensionToken: boolean;
90
- stderr: string;
91
- rawMessage?: string;
92
- exited?: boolean;
93
- }): ConnectFailureKind {
94
- const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
95
-
96
- if (!args.hasExtensionToken)
97
- return 'missing-token';
98
- if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
99
- return 'extension-not-installed';
100
- if (args.rawMessage?.startsWith('MCP init failed:'))
101
- return 'mcp-init';
102
- if (args.exited)
103
- return 'process-exit';
104
- return 'extension-timeout';
105
- }
106
-
107
- // JSON-RPC helpers
108
- let _nextId = 1;
109
- function createJsonRpcRequest(method: string, params: Record<string, any> = {}): { id: number; message: string } {
110
- const id = _nextId++;
111
- return {
112
- id,
113
- message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
114
- };
115
- }
116
-
117
- import type { IPage } from './types.js';
118
-
119
- /**
120
- * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
121
- */
122
- export class Page implements IPage {
123
- constructor(private _request: (method: string, params?: Record<string, any>) => Promise<any>) {}
124
-
125
- async call(method: string, params: Record<string, any> = {}): Promise<any> {
126
- const resp = await this._request(method, params);
127
- if (resp.error) throw new Error(`page.${method}: ${resp.error.message ?? JSON.stringify(resp.error)}`);
128
- // Extract text content from MCP result
129
- const result = resp.result;
130
- if (result?.content) {
131
- const textParts = result.content.filter((c: any) => c.type === 'text');
132
- if (textParts.length === 1) {
133
- let text = textParts[0].text;
134
- // MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
135
- // Strip the "### Ran Playwright code" suffix to get clean JSON
136
- const codeMarker = text.indexOf('### Ran Playwright code');
137
- if (codeMarker !== -1) {
138
- text = text.slice(0, codeMarker).trim();
139
- }
140
- // Also handle "### Result\n[JSON]" format (some MCP versions)
141
- const resultMarker = text.indexOf('### Result\n');
142
- if (resultMarker !== -1) {
143
- text = text.slice(resultMarker + '### Result\n'.length).trim();
144
- }
145
- try { return JSON.parse(text); } catch { return text; }
146
- }
147
- }
148
- return result;
149
- }
150
-
151
- // --- High-level methods ---
152
-
153
- async goto(url: string): Promise<void> {
154
- await this.call('tools/call', { name: 'browser_navigate', arguments: { url } });
155
- }
156
-
157
- async evaluate(js: string): Promise<any> {
158
- // Normalize IIFE format to function format expected by MCP browser_evaluate
159
- const normalized = normalizeEvaluateSource(js);
160
- return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
161
- }
162
-
163
- async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
164
- const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
165
- if (opts.raw) return raw;
166
- if (typeof raw === 'string') return formatSnapshot(raw, opts);
167
- return raw;
168
- }
169
-
170
- async click(ref: string): Promise<void> {
171
- await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } });
172
- }
173
-
174
- async typeText(ref: string, text: string): Promise<void> {
175
- await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } });
176
- }
177
-
178
- async pressKey(key: string): Promise<void> {
179
- await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
180
- }
181
-
182
- async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise<void> {
183
- if (typeof options === 'number') {
184
- await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } });
185
- } else {
186
- // Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling
187
- await this.call('tools/call', { name: 'browser_wait_for', arguments: options });
188
- }
189
- }
190
-
191
- async tabs(): Promise<any> {
192
- return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
193
- }
194
-
195
- async closeTab(index?: number): Promise<void> {
196
- await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } });
197
- }
198
-
199
- async newTab(): Promise<void> {
200
- await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } });
201
- }
202
-
203
- async selectTab(index: number): Promise<void> {
204
- await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } });
205
- }
206
-
207
- async networkRequests(includeStatic: boolean = false): Promise<any> {
208
- return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } });
209
- }
210
-
211
- async consoleMessages(level: string = 'info'): Promise<any> {
212
- return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
213
- }
214
-
215
- async scroll(direction: string = 'down', _amount: number = 500): Promise<void> {
216
- await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
217
- }
218
-
219
- async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
220
- const times = options.times ?? 3;
221
- const delayMs = options.delayMs ?? 2000;
222
- const js = `
223
- async () => {
224
- const maxTimes = ${times};
225
- const maxWaitMs = ${delayMs};
226
- for (let i = 0; i < maxTimes; i++) {
227
- const lastHeight = document.body.scrollHeight;
228
- window.scrollTo(0, lastHeight);
229
- await new Promise(resolve => {
230
- let timeoutId;
231
- const observer = new MutationObserver(() => {
232
- if (document.body.scrollHeight > lastHeight) {
233
- clearTimeout(timeoutId);
234
- observer.disconnect();
235
- setTimeout(resolve, 100); // Small debounce for rendering
236
- }
237
- });
238
- observer.observe(document.body, { childList: true, subtree: true });
239
- timeoutId = setTimeout(() => {
240
- observer.disconnect();
241
- resolve(null);
242
- }, maxWaitMs);
243
- });
244
- }
245
- }
246
- `;
247
- await this.evaluate(js);
248
- }
249
-
250
- async installInterceptor(pattern: string): Promise<void> {
251
- await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
252
- arrayName: '__opencli_xhr',
253
- patchGuard: '__opencli_interceptor_patched',
254
- }));
255
- }
256
-
257
- async getInterceptedRequests(): Promise<any[]> {
258
- const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
259
- return result || [];
260
- }
261
- }
262
-
263
- /**
264
- * Playwright MCP process manager.
265
- */
266
- export class PlaywrightMCP {
267
- private static _activeInsts: Set<PlaywrightMCP> = new Set();
268
- private static _cleanupRegistered = false;
269
-
270
- private static _registerGlobalCleanup() {
271
- if (this._cleanupRegistered) return;
272
- this._cleanupRegistered = true;
273
- const cleanup = () => {
274
- for (const inst of this._activeInsts) {
275
- if (inst._proc && !inst._proc.killed) {
276
- try { inst._proc.kill('SIGKILL'); } catch {}
277
- }
278
- }
279
- };
280
- process.on('exit', cleanup);
281
- process.on('SIGINT', () => { cleanup(); process.exit(130); });
282
- process.on('SIGTERM', () => { cleanup(); process.exit(143); });
283
- }
284
-
285
- private _proc: ChildProcess | null = null;
286
- private _buffer = '';
287
- private _pending = new Map<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
288
- private _initialTabIdentities: string[] = [];
289
- private _closingPromise: Promise<void> | null = null;
290
- private _state: PlaywrightMCPState = 'idle';
291
-
292
- private _page: Page | null = null;
293
-
294
- get state(): PlaywrightMCPState {
295
- return this._state;
296
- }
297
-
298
- private _sendRequest(method: string, params: Record<string, any> = {}): Promise<any> {
299
- return new Promise<any>((resolve, reject) => {
300
- if (!this._proc?.stdin?.writable) {
301
- reject(new Error('Playwright MCP process is not writable'));
302
- return;
303
- }
304
- const { id, message } = createJsonRpcRequest(method, params);
305
- this._pending.set(id, { resolve, reject });
306
- this._proc.stdin.write(message, (err) => {
307
- if (!err) return;
308
- this._pending.delete(id);
309
- reject(err);
310
- });
311
- });
312
- }
313
-
314
- private _rejectPendingRequests(error: Error): void {
315
- const pending = [...this._pending.values()];
316
- this._pending.clear();
317
- for (const waiter of pending) waiter.reject(error);
318
- }
319
-
320
- private _resetAfterFailedConnect(): void {
321
- const proc = this._proc;
322
- this._page = null;
323
- this._proc = null;
324
- this._buffer = '';
325
- this._initialTabIdentities = [];
326
- this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
327
- PlaywrightMCP._activeInsts.delete(this);
328
- if (proc && !proc.killed) {
329
- try { proc.kill('SIGKILL'); } catch {}
330
- }
331
- }
332
-
333
- async connect(opts: { timeout?: number } = {}): Promise<Page> {
334
- if (this._state === 'connected' && this._page) return this._page;
335
- if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting');
336
- if (this._state === 'closing') throw new Error('Playwright MCP is closing');
337
- if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
338
-
339
- const mcpPath = findMcpServerPath();
340
- if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp');
341
-
342
- PlaywrightMCP._registerGlobalCleanup();
343
- PlaywrightMCP._activeInsts.add(this);
344
- this._state = 'connecting';
345
- const timeout = opts.timeout ?? DEFAULT_BROWSER_CONNECT_TIMEOUT;
346
-
347
- return new Promise<Page>((resolve, reject) => {
348
- const isDebug = process.env.DEBUG?.includes('opencli:mcp');
349
- const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
350
- const useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
351
- const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
352
- const tokenFingerprint = getTokenFingerprint(extensionToken);
353
- let stderrBuffer = '';
354
- let settled = false;
355
-
356
- const settleError = (kind: ConnectFailureKind, extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
357
- if (settled) return;
358
- settled = true;
359
- this._state = 'idle';
360
- clearTimeout(timer);
361
- this._resetAfterFailedConnect();
362
- reject(formatBrowserConnectError({
363
- kind,
364
- timeout,
365
- hasExtensionToken: !!extensionToken,
366
- tokenFingerprint,
367
- stderr: stderrBuffer,
368
- exitCode: extra.exitCode,
369
- rawMessage: extra.rawMessage,
370
- }));
371
- };
372
-
373
- const settleSuccess = (pageToResolve: Page) => {
374
- if (settled) return;
375
- settled = true;
376
- this._state = 'connected';
377
- clearTimeout(timer);
378
- resolve(pageToResolve);
379
- };
380
-
381
- const timer = setTimeout(() => {
382
- debugLog('Connection timed out');
383
- settleError(inferConnectFailureKind({
384
- hasExtensionToken: !!extensionToken,
385
- stderr: stderrBuffer,
386
- }));
387
- }, timeout * 1000);
388
-
389
- const mcpArgs = buildMcpArgs({
390
- mcpPath,
391
- executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
392
- });
393
- if (process.env.OPENCLI_VERBOSE) {
394
- console.error(`[opencli] Mode: ${useExtension ? 'extension' : 'standalone'}`);
395
- if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
396
- }
397
- debugLog(`Spawning node ${mcpArgs.join(' ')}`);
398
-
399
- this._proc = spawn('node', mcpArgs, {
400
- stdio: ['pipe', 'pipe', 'pipe'],
401
- env: { ...process.env },
402
- });
403
-
404
- // Increase max listeners to avoid warnings
405
- this._proc.setMaxListeners(20);
406
- if (this._proc.stdout) this._proc.stdout.setMaxListeners(20);
407
-
408
- const page = new Page((method, params = {}) => this._sendRequest(method, params));
409
- this._page = page;
410
-
411
- this._proc.stdout?.on('data', (chunk: Buffer) => {
412
- this._buffer += chunk.toString();
413
- const lines = this._buffer.split('\n');
414
- this._buffer = lines.pop() ?? '';
415
- for (const line of lines) {
416
- if (!line.trim()) continue;
417
- debugLog(`RECV: ${line}`);
418
- try {
419
- const parsed = JSON.parse(line);
420
- if (typeof parsed?.id === 'number') {
421
- const waiter = this._pending.get(parsed.id);
422
- if (waiter) {
423
- this._pending.delete(parsed.id);
424
- waiter.resolve(parsed);
425
- }
426
- }
427
- } catch (e) {
428
- debugLog(`Parse error: ${e}`);
429
- }
430
- }
431
- });
432
-
433
- this._proc.stderr?.on('data', (chunk: Buffer) => {
434
- const text = chunk.toString();
435
- stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
436
- debugLog(`STDERR: ${text}`);
437
- });
438
- this._proc.on('error', (err) => {
439
- debugLog(`Subprocess error: ${err.message}`);
440
- this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
441
- settleError('process-exit', { rawMessage: err.message });
442
- });
443
- this._proc.on('close', (code) => {
444
- debugLog(`Subprocess closed with code ${code}`);
445
- this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
446
- if (!settled) {
447
- settleError(inferConnectFailureKind({
448
- hasExtensionToken: !!extensionToken,
449
- stderr: stderrBuffer,
450
- exited: true,
451
- }), { exitCode: code });
452
- }
453
- });
454
-
455
- // Initialize: send initialize request
456
- debugLog('Waiting for initialize response...');
457
- this._sendRequest('initialize', {
458
- protocolVersion: '2024-11-05',
459
- capabilities: {},
460
- clientInfo: { name: 'opencli', version: PKG_VERSION },
461
- }).then((resp) => {
462
- debugLog('Got initialize response');
463
- if (resp.error) {
464
- settleError(inferConnectFailureKind({
465
- hasExtensionToken: !!extensionToken,
466
- stderr: stderrBuffer,
467
- rawMessage: `MCP init failed: ${resp.error.message}`,
468
- }), { rawMessage: resp.error.message });
469
- return;
470
- }
471
-
472
- const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
473
- debugLog(`SEND: ${initializedMsg.trim()}`);
474
- this._proc?.stdin?.write(initializedMsg);
475
-
476
- // Use tabs as a readiness probe and for tab cleanup bookkeeping.
477
- debugLog('Fetching initial tabs count...');
478
- withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
479
- debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
480
- this._initialTabIdentities = extractTabIdentities(tabs);
481
- settleSuccess(page);
482
- }).catch((err) => {
483
- debugLog(`Tabs fetch error: ${err.message}`);
484
- settleSuccess(page);
485
- });
486
- }).catch((err) => {
487
- debugLog(`Init promise rejected: ${err.message}`);
488
- settleError('mcp-init', { rawMessage: err.message });
489
- });
490
- });
491
- }
492
-
493
-
494
- async close(): Promise<void> {
495
- if (this._closingPromise) return this._closingPromise;
496
- if (this._state === 'closed') return;
497
- this._state = 'closing';
498
- this._closingPromise = (async () => {
499
- try {
500
- // Extension mode opens bridge/session tabs that we can clean up best-effort.
501
- if (this._page && this._proc && !this._proc.killed) {
502
- try {
503
- const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
504
- const tabEntries = extractTabEntries(tabs);
505
- const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
506
- for (const index of tabsToClose) {
507
- try { await this._page.closeTab(index); } catch {}
508
- }
509
- } catch {}
510
- }
511
- if (this._proc && !this._proc.killed) {
512
- this._proc.kill('SIGTERM');
513
- const exited = await new Promise<boolean>((res) => {
514
- let done = false;
515
- const finish = (value: boolean) => {
516
- if (done) return;
517
- done = true;
518
- res(value);
519
- };
520
- this._proc?.once('exit', () => finish(true));
521
- setTimeout(() => finish(false), 3000);
522
- });
523
- if (!exited && this._proc && !this._proc.killed) {
524
- try { this._proc.kill('SIGKILL'); } catch {}
525
- }
526
- }
527
- } finally {
528
- this._rejectPendingRequests(new Error('Playwright MCP session closed'));
529
- this._page = null;
530
- this._proc = null;
531
- this._state = 'closed';
532
- PlaywrightMCP._activeInsts.delete(this);
533
- }
534
- })();
535
- return this._closingPromise;
536
- }
537
- }
538
-
539
- function extractTabEntries(raw: any): Array<{ index: number; identity: string }> {
540
- if (Array.isArray(raw)) {
541
- return raw.map((tab: any, index: number) => ({
542
- index,
543
- identity: [
544
- tab?.id ?? '',
545
- tab?.url ?? '',
546
- tab?.title ?? '',
547
- tab?.name ?? '',
548
- ].join('|'),
549
- }));
550
- }
551
-
552
- if (typeof raw === 'string') {
553
- return raw
554
- .split('\n')
555
- .map(line => line.trim())
556
- .filter(Boolean)
557
- .map(line => {
558
- // Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)"
559
- const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
560
- if (mcpMatch) {
561
- return {
562
- index: parseInt(mcpMatch[1], 10),
563
- identity: mcpMatch[2].trim() || `tab-${mcpMatch[1]}`,
564
- };
565
- }
566
- // Legacy format: "Tab 0 ..."
567
- const legacyMatch = line.match(/Tab\s+(\d+)\s*(.*)$/);
568
- if (legacyMatch) {
569
- return {
570
- index: parseInt(legacyMatch[1], 10),
571
- identity: legacyMatch[2].trim() || `tab-${legacyMatch[1]}`,
572
- };
573
- }
574
- return null;
575
- })
576
- .filter((entry): entry is { index: number; identity: string } => entry !== null);
577
- }
578
-
579
- return [];
580
- }
581
-
582
- function extractTabIdentities(raw: any): string[] {
583
- return extractTabEntries(raw).map(tab => tab.identity);
584
- }
585
-
586
- function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] {
587
- if (initialIdentities.length === 0 || currentTabs.length === 0) return [];
588
- const remaining = new Map<string, number>();
589
- for (const identity of initialIdentities) {
590
- remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
591
- }
592
-
593
- const tabsToClose: number[] = [];
594
- for (const tab of currentTabs) {
595
- const count = remaining.get(tab.identity) ?? 0;
596
- if (count > 0) {
597
- remaining.set(tab.identity, count - 1);
598
- continue;
599
- }
600
- tabsToClose.push(tab.index);
601
- }
602
-
603
- return tabsToClose.sort((a, b) => b - a);
604
- }
605
-
606
- function appendLimited(current: string, chunk: string, limit: number): string {
607
- const next = current + chunk;
608
- if (next.length <= limit) return next;
609
- return next.slice(-limit);
610
- }
611
-
612
- function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] {
613
- const args = [input.mcpPath];
614
- if (!process.env.CI) {
615
- // Local: always connect to user's running Chrome via MCP Bridge extension
616
- args.push('--extension');
617
- }
618
- // CI: standalone mode — @playwright/mcp launches its own browser (headed by default).
619
- // xvfb provides a virtual display for headed mode in GitHub Actions.
620
- if (input.executablePath) {
621
- args.push('--executable-path', input.executablePath);
622
- }
623
- return args;
624
- }
625
-
626
- export const __test__ = {
627
- createJsonRpcRequest,
628
- extractTabEntries,
629
- diffTabIndexes,
630
- appendLimited,
631
- buildMcpArgs,
632
- withTimeoutMs,
633
- };
634
-
635
- function findMcpServerPath(): string | null {
636
- if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
637
-
638
- const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
639
- if (envMcp && fs.existsSync(envMcp)) {
640
- _cachedMcpServerPath = envMcp;
641
- return _cachedMcpServerPath;
642
- }
643
-
644
- // Check local node_modules first (@playwright/mcp is the modern package)
645
- const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
646
- if (fs.existsSync(localMcp)) {
647
- _cachedMcpServerPath = localMcp;
648
- return _cachedMcpServerPath;
649
- }
650
-
651
- // Check project-relative path
652
- const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
653
- const projectMcp = path.resolve(__dirname2, '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
654
- if (fs.existsSync(projectMcp)) {
655
- _cachedMcpServerPath = projectMcp;
656
- return _cachedMcpServerPath;
657
- }
658
-
659
- // Check common locations
660
- const candidates = [
661
- path.join(os.homedir(), '.npm', '_npx'),
662
- path.join(os.homedir(), 'node_modules', '.bin'),
663
- '/usr/local/lib/node_modules',
664
- ];
665
-
666
- // Try npx resolution (legacy package name)
667
- try {
668
- const result = execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
669
- if (result && fs.existsSync(result)) {
670
- _cachedMcpServerPath = result;
671
- return _cachedMcpServerPath;
672
- }
673
- } catch {}
674
-
675
- // Try which
676
- try {
677
- const result = execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
678
- if (result && fs.existsSync(result)) {
679
- _cachedMcpServerPath = result;
680
- return _cachedMcpServerPath;
681
- }
682
- } catch {}
683
-
684
- // Search in common npx cache
685
- for (const base of candidates) {
686
- if (!fs.existsSync(base)) continue;
687
- try {
688
- const found = execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
689
- if (found) {
690
- _cachedMcpServerPath = found;
691
- return _cachedMcpServerPath;
692
- }
693
- } catch {}
694
- }
695
-
696
- _cachedMcpServerPath = null;
697
- return _cachedMcpServerPath;
698
- }