@jackwener/opencli 0.9.8 → 1.0.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.
Files changed (97) hide show
  1. package/CDP.md +1 -1
  2. package/CDP.zh-CN.md +1 -1
  3. package/CLI-ELECTRON.md +2 -2
  4. package/CLI-EXPLORER.md +4 -4
  5. package/README.md +15 -57
  6. package/README.zh-CN.md +16 -59
  7. package/SKILL.md +10 -8
  8. package/TESTING.md +7 -7
  9. package/dist/browser/daemon-client.d.ts +37 -0
  10. package/dist/browser/daemon-client.js +82 -0
  11. package/dist/browser/discover.d.ts +11 -34
  12. package/dist/browser/discover.js +15 -205
  13. package/dist/browser/errors.d.ts +6 -20
  14. package/dist/browser/errors.js +24 -63
  15. package/dist/browser/index.d.ts +2 -11
  16. package/dist/browser/index.js +5 -11
  17. package/dist/browser/mcp.d.ts +9 -18
  18. package/dist/browser/mcp.js +70 -284
  19. package/dist/browser/page.d.ts +28 -6
  20. package/dist/browser/page.js +210 -85
  21. package/dist/browser.test.js +4 -225
  22. package/dist/cli-manifest.json +167 -0
  23. package/dist/clis/neteasemusic/like.d.ts +1 -0
  24. package/dist/clis/neteasemusic/like.js +25 -0
  25. package/dist/clis/neteasemusic/lyrics.d.ts +1 -0
  26. package/dist/clis/neteasemusic/lyrics.js +47 -0
  27. package/dist/clis/neteasemusic/next.d.ts +1 -0
  28. package/dist/clis/neteasemusic/next.js +26 -0
  29. package/dist/clis/neteasemusic/play.d.ts +1 -0
  30. package/dist/clis/neteasemusic/play.js +26 -0
  31. package/dist/clis/neteasemusic/playing.d.ts +1 -0
  32. package/dist/clis/neteasemusic/playing.js +59 -0
  33. package/dist/clis/neteasemusic/playlist.d.ts +1 -0
  34. package/dist/clis/neteasemusic/playlist.js +46 -0
  35. package/dist/clis/neteasemusic/prev.d.ts +1 -0
  36. package/dist/clis/neteasemusic/prev.js +25 -0
  37. package/dist/clis/neteasemusic/search.d.ts +1 -0
  38. package/dist/clis/neteasemusic/search.js +52 -0
  39. package/dist/clis/neteasemusic/status.d.ts +1 -0
  40. package/dist/clis/neteasemusic/status.js +16 -0
  41. package/dist/clis/neteasemusic/volume.d.ts +1 -0
  42. package/dist/clis/neteasemusic/volume.js +54 -0
  43. package/dist/daemon.d.ts +13 -0
  44. package/dist/daemon.js +187 -0
  45. package/dist/doctor.d.ts +27 -61
  46. package/dist/doctor.js +70 -601
  47. package/dist/doctor.test.js +30 -170
  48. package/dist/main.js +6 -25
  49. package/dist/pipeline/executor.test.js +1 -0
  50. package/dist/pipeline/steps/browser.js +2 -2
  51. package/dist/pipeline/steps/intercept.js +1 -2
  52. package/dist/setup.d.ts +6 -0
  53. package/dist/setup.js +46 -160
  54. package/dist/types.d.ts +6 -0
  55. package/extension/icons/icon-128.png +0 -0
  56. package/extension/icons/icon-16.png +0 -0
  57. package/extension/icons/icon-32.png +0 -0
  58. package/extension/icons/icon-48.png +0 -0
  59. package/extension/manifest.json +31 -0
  60. package/extension/package.json +16 -0
  61. package/extension/src/background.ts +293 -0
  62. package/extension/src/cdp.ts +125 -0
  63. package/extension/src/protocol.ts +57 -0
  64. package/extension/store-assets/screenshot-1280x800.png +0 -0
  65. package/extension/tsconfig.json +15 -0
  66. package/extension/vite.config.ts +18 -0
  67. package/package.json +5 -5
  68. package/src/browser/daemon-client.ts +113 -0
  69. package/src/browser/discover.ts +18 -232
  70. package/src/browser/errors.ts +30 -100
  71. package/src/browser/index.ts +6 -12
  72. package/src/browser/mcp.ts +78 -278
  73. package/src/browser/page.ts +222 -88
  74. package/src/browser.test.ts +3 -233
  75. package/src/clis/chatgpt/README.md +1 -1
  76. package/src/clis/chatgpt/README.zh-CN.md +1 -1
  77. package/src/clis/neteasemusic/README.md +31 -0
  78. package/src/clis/neteasemusic/README.zh-CN.md +31 -0
  79. package/src/clis/neteasemusic/like.ts +28 -0
  80. package/src/clis/neteasemusic/lyrics.ts +53 -0
  81. package/src/clis/neteasemusic/next.ts +30 -0
  82. package/src/clis/neteasemusic/play.ts +30 -0
  83. package/src/clis/neteasemusic/playing.ts +62 -0
  84. package/src/clis/neteasemusic/playlist.ts +51 -0
  85. package/src/clis/neteasemusic/prev.ts +29 -0
  86. package/src/clis/neteasemusic/search.ts +58 -0
  87. package/src/clis/neteasemusic/status.ts +18 -0
  88. package/src/clis/neteasemusic/volume.ts +61 -0
  89. package/src/daemon.ts +217 -0
  90. package/src/doctor.test.ts +32 -193
  91. package/src/doctor.ts +74 -668
  92. package/src/main.ts +6 -23
  93. package/src/pipeline/executor.test.ts +1 -0
  94. package/src/pipeline/steps/browser.ts +2 -2
  95. package/src/pipeline/steps/intercept.ts +1 -2
  96. package/src/setup.ts +47 -183
  97. package/src/types.ts +1 -0
@@ -1,68 +1,70 @@
1
1
  /**
2
- * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
2
+ * Page abstraction implements IPage by sending commands to the daemon.
3
+ *
4
+ * All browser operations are ultimately 'exec' (JS evaluation via CDP)
5
+ * plus a few native Chrome Extension APIs (tabs, cookies, navigate).
6
+ *
7
+ * IMPORTANT: After goto(), we remember the tabId returned by the navigate
8
+ * action and pass it to all subsequent commands. This avoids the issue
9
+ * where resolveTabId() in the extension picks a chrome:// or
10
+ * chrome-extension:// tab that can't be debugged.
3
11
  */
4
12
  import { formatSnapshot } from '../snapshotFormatter.js';
5
- import { normalizeEvaluateSource } from '../pipeline/template.js';
6
- import { generateInterceptorJs, generateReadInterceptedJs } from '../interceptor.js';
7
- import { BrowserConnectError } from '../errors.js';
13
+ import { sendCommand } from './daemon-client.js';
8
14
  /**
9
- * Page abstraction wrapping JSON-RPC calls to Playwright MCP.
15
+ * Page implements IPage by talking to the daemon via HTTP.
10
16
  */
11
17
  export class Page {
12
- _request;
13
- constructor(_request) {
14
- this._request = _request;
15
- }
16
- async call(method, params = {}) {
17
- const resp = await this._request(method, params);
18
- if (resp.error)
19
- throw new Error(`page.${method}: ${resp.error.message ?? JSON.stringify(resp.error)}`);
20
- // Extract text content from MCP result
21
- const result = resp.result;
22
- if (result?.isError) {
23
- const errorText = result.content?.find((c) => c.type === 'text')?.text || 'Unknown MCP Error';
24
- throw new BrowserConnectError(errorText, 'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.');
25
- }
26
- if (result?.content) {
27
- const textParts = result.content.filter((c) => c.type === 'text');
28
- if (textParts.length >= 1) {
29
- let text = textParts[textParts.length - 1].text; // Usually the main output is in the last text block
30
- // Some versions of the MCP return error text without the `isError` boolean flag
31
- if (typeof text === 'string' && text.trim().startsWith('### Error')) {
32
- throw new BrowserConnectError(text.trim(), 'Please check if the browser is running or if the Playwright MCP / CDP connection is configured correctly.');
33
- }
34
- // MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
35
- // Strip the "### Ran Playwright code" suffix to get clean JSON
36
- const codeMarker = text.indexOf('### Ran Playwright code');
37
- if (codeMarker !== -1) {
38
- text = text.slice(0, codeMarker).trim();
39
- }
40
- // Also handle "### Result\n[JSON]" format (some MCP versions)
41
- const resultMarker = text.indexOf('### Result\n');
42
- if (resultMarker !== -1) {
43
- text = text.slice(resultMarker + '### Result\n'.length).trim();
44
- }
45
- try {
46
- return JSON.parse(text);
47
- }
48
- catch {
49
- return text;
50
- }
51
- }
52
- }
53
- return result;
18
+ /** Active tab ID, set after navigate and used in all subsequent commands */
19
+ _tabId;
20
+ /** Helper: spread tabId into command params if we have one */
21
+ _tabOpt() {
22
+ return this._tabId !== undefined ? { tabId: this._tabId } : {};
54
23
  }
55
- // --- High-level methods ---
56
24
  async goto(url) {
57
- await this.call('tools/call', { name: 'browser_navigate', arguments: { url } });
25
+ const result = await sendCommand('navigate', {
26
+ url,
27
+ ...this._tabOpt(),
28
+ });
29
+ // Remember the tabId for subsequent exec calls
30
+ if (result?.tabId) {
31
+ this._tabId = result.tabId;
32
+ }
58
33
  }
59
34
  async evaluate(js) {
60
- // Normalize IIFE format to function format expected by MCP browser_evaluate
61
- const normalized = normalizeEvaluateSource(js);
62
- return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
35
+ const code = wrapForEval(js);
36
+ return sendCommand('exec', { code, ...this._tabOpt() });
63
37
  }
64
38
  async snapshot(opts = {}) {
65
- const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
39
+ const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));
40
+ const code = `
41
+ (async () => {
42
+ function buildTree(node, depth) {
43
+ if (depth > ${maxDepth}) return '';
44
+ const role = node.getAttribute?.('role') || node.tagName?.toLowerCase() || 'generic';
45
+ const name = node.getAttribute?.('aria-label') || node.getAttribute?.('alt') || node.textContent?.trim().slice(0, 80) || '';
46
+ const isInteractive = ['a', 'button', 'input', 'select', 'textarea'].includes(node.tagName?.toLowerCase()) || node.getAttribute?.('tabindex') != null;
47
+
48
+ ${opts.interactive ? 'if (!isInteractive && !node.children?.length) return "";' : ''}
49
+
50
+ let indent = ' '.repeat(depth);
51
+ let line = indent + role;
52
+ if (name) line += ' "' + name.replace(/"/g, '\\\\"') + '"';
53
+ if (node.tagName?.toLowerCase() === 'a' && node.href) line += ' [' + node.href + ']';
54
+ if (node.tagName?.toLowerCase() === 'input') line += ' [' + (node.type || 'text') + ']';
55
+
56
+ let result = line + '\\n';
57
+ if (node.children) {
58
+ for (const child of node.children) {
59
+ result += buildTree(child, depth + 1);
60
+ }
61
+ }
62
+ return result;
63
+ }
64
+ return buildTree(document.body, 0);
65
+ })()
66
+ `;
67
+ const raw = await sendCommand('exec', { code, ...this._tabOpt() });
66
68
  if (opts.raw)
67
69
  return raw;
68
70
  if (typeof raw === 'string')
@@ -70,52 +72,147 @@ export class Page {
70
72
  return raw;
71
73
  }
72
74
  async click(ref) {
73
- await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } });
75
+ const safeRef = JSON.stringify(ref);
76
+ const code = `
77
+ (() => {
78
+ const ref = ${safeRef};
79
+ const el = document.querySelector('[data-ref="' + ref + '"]')
80
+ || document.querySelectorAll('a, button, input, [role="button"], [tabindex]')[parseInt(ref, 10) || 0];
81
+ if (!el) throw new Error('Element not found: ' + ref);
82
+ el.scrollIntoView({ behavior: 'instant', block: 'center' });
83
+ el.click();
84
+ return 'clicked';
85
+ })()
86
+ `;
87
+ await sendCommand('exec', { code, ...this._tabOpt() });
74
88
  }
75
89
  async typeText(ref, text) {
76
- await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } });
90
+ const safeRef = JSON.stringify(ref);
91
+ const safeText = JSON.stringify(text);
92
+ const code = `
93
+ (() => {
94
+ const ref = ${safeRef};
95
+ const el = document.querySelector('[data-ref="' + ref + '"]')
96
+ || document.querySelectorAll('input, textarea, [contenteditable]')[parseInt(ref, 10) || 0];
97
+ if (!el) throw new Error('Element not found: ' + ref);
98
+ el.focus();
99
+ el.value = ${safeText};
100
+ el.dispatchEvent(new Event('input', { bubbles: true }));
101
+ el.dispatchEvent(new Event('change', { bubbles: true }));
102
+ return 'typed';
103
+ })()
104
+ `;
105
+ await sendCommand('exec', { code, ...this._tabOpt() });
77
106
  }
78
107
  async pressKey(key) {
79
- await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
108
+ const code = `
109
+ (() => {
110
+ const el = document.activeElement || document.body;
111
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true }));
112
+ el.dispatchEvent(new KeyboardEvent('keyup', { key: ${JSON.stringify(key)}, bubbles: true }));
113
+ return 'pressed';
114
+ })()
115
+ `;
116
+ await sendCommand('exec', { code, ...this._tabOpt() });
80
117
  }
81
118
  async wait(options) {
82
119
  if (typeof options === 'number') {
83
- await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } });
120
+ await new Promise(resolve => setTimeout(resolve, options * 1000));
121
+ return;
122
+ }
123
+ if (options.time) {
124
+ await new Promise(resolve => setTimeout(resolve, options.time * 1000));
125
+ return;
84
126
  }
85
- else {
86
- // Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling
87
- await this.call('tools/call', { name: 'browser_wait_for', arguments: options });
127
+ if (options.text) {
128
+ const timeout = (options.timeout ?? 30) * 1000;
129
+ const code = `
130
+ new Promise((resolve, reject) => {
131
+ const deadline = Date.now() + ${timeout};
132
+ const check = () => {
133
+ if (document.body.innerText.includes(${JSON.stringify(options.text)})) return resolve('found');
134
+ if (Date.now() > deadline) return reject(new Error('Text not found: ' + ${JSON.stringify(options.text)}));
135
+ setTimeout(check, 200);
136
+ };
137
+ check();
138
+ })
139
+ `;
140
+ await sendCommand('exec', { code, ...this._tabOpt() });
88
141
  }
89
142
  }
90
143
  async tabs() {
91
- return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
144
+ return sendCommand('tabs', { op: 'list' });
92
145
  }
93
146
  async closeTab(index) {
94
- await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } });
147
+ await sendCommand('tabs', { op: 'close', ...(index !== undefined ? { index } : {}) });
95
148
  }
96
149
  async newTab() {
97
- await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } });
150
+ await sendCommand('tabs', { op: 'new' });
98
151
  }
99
152
  async selectTab(index) {
100
- await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } });
153
+ await sendCommand('tabs', { op: 'select', index });
101
154
  }
102
155
  async networkRequests(includeStatic = false) {
103
- return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } });
156
+ const code = `
157
+ (() => {
158
+ const entries = performance.getEntriesByType('resource');
159
+ return entries
160
+ ${includeStatic ? '' : '.filter(e => !["img", "font", "css", "script"].some(t => e.initiatorType === t))'}
161
+ .map(e => ({
162
+ url: e.name,
163
+ type: e.initiatorType,
164
+ duration: Math.round(e.duration),
165
+ size: e.transferSize || 0,
166
+ }));
167
+ })()
168
+ `;
169
+ return sendCommand('exec', { code, ...this._tabOpt() });
104
170
  }
105
171
  async consoleMessages(level = 'info') {
106
- return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
172
+ // Console messages can't be retrospectively read via CDP Runtime.evaluate.
173
+ // Would need Runtime.consoleAPICalled event listener, which is not yet implemented.
174
+ if (process.env.OPENCLI_VERBOSE) {
175
+ console.error('[page] consoleMessages() not supported in lightweight mode — returning empty');
176
+ }
177
+ return [];
107
178
  }
108
- async scroll(direction = 'down', _amount = 500) {
109
- await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
179
+ /**
180
+ * Capture a screenshot via CDP Page.captureScreenshot.
181
+ * @param options.format - 'png' (default) or 'jpeg'
182
+ * @param options.quality - JPEG quality 0-100
183
+ * @param options.fullPage - capture full scrollable page
184
+ * @param options.path - save to file path (returns base64 if omitted)
185
+ */
186
+ async screenshot(options = {}) {
187
+ const base64 = await sendCommand('screenshot', {
188
+ format: options.format,
189
+ quality: options.quality,
190
+ fullPage: options.fullPage,
191
+ ...this._tabOpt(),
192
+ });
193
+ if (options.path) {
194
+ const fs = await import('node:fs');
195
+ const path = await import('node:path');
196
+ const dir = path.dirname(options.path);
197
+ fs.mkdirSync(dir, { recursive: true });
198
+ fs.writeFileSync(options.path, Buffer.from(base64, 'base64'));
199
+ }
200
+ return base64;
201
+ }
202
+ async scroll(direction = 'down', amount = 500) {
203
+ const dx = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
204
+ const dy = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
205
+ await sendCommand('exec', {
206
+ code: `window.scrollBy(${dx}, ${dy})`,
207
+ ...this._tabOpt(),
208
+ });
110
209
  }
111
210
  async autoScroll(options = {}) {
112
211
  const times = options.times ?? 3;
113
212
  const delayMs = options.delayMs ?? 2000;
114
- const js = `
115
- async () => {
116
- const maxTimes = ${times};
117
- const maxWaitMs = ${delayMs};
118
- for (let i = 0; i < maxTimes; i++) {
213
+ const code = `
214
+ (async () => {
215
+ for (let i = 0; i < ${times}; i++) {
119
216
  const lastHeight = document.body.scrollHeight;
120
217
  window.scrollTo(0, lastHeight);
121
218
  await new Promise(resolve => {
@@ -124,28 +221,56 @@ export class Page {
124
221
  if (document.body.scrollHeight > lastHeight) {
125
222
  clearTimeout(timeoutId);
126
223
  observer.disconnect();
127
- setTimeout(resolve, 100); // Small debounce for rendering
224
+ setTimeout(resolve, 100);
128
225
  }
129
226
  });
130
227
  observer.observe(document.body, { childList: true, subtree: true });
131
- timeoutId = setTimeout(() => {
132
- observer.disconnect();
133
- resolve(null);
134
- }, maxWaitMs);
228
+ timeoutId = setTimeout(() => { observer.disconnect(); resolve(null); }, ${delayMs});
135
229
  });
136
230
  }
137
- }
231
+ })()
138
232
  `;
139
- await this.evaluate(js);
233
+ await sendCommand('exec', { code, ...this._tabOpt() });
140
234
  }
141
235
  async installInterceptor(pattern) {
142
- await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
143
- arrayName: '__opencli_xhr',
144
- patchGuard: '__opencli_interceptor_patched',
145
- }));
236
+ const { generateInterceptorJs } = await import('../interceptor.js');
237
+ await sendCommand('exec', {
238
+ code: generateInterceptorJs(JSON.stringify(pattern), {
239
+ arrayName: '__opencli_xhr',
240
+ patchGuard: '__opencli_interceptor_patched',
241
+ }),
242
+ ...this._tabOpt(),
243
+ });
146
244
  }
147
245
  async getInterceptedRequests() {
148
- const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
246
+ const { generateReadInterceptedJs } = await import('../interceptor.js');
247
+ const result = await sendCommand('exec', {
248
+ code: generateReadInterceptedJs('__opencli_xhr'),
249
+ ...this._tabOpt(),
250
+ });
149
251
  return result || [];
150
252
  }
151
253
  }
254
+ // ─── Helpers ─────────────────────────────────────────────────────────
255
+ /**
256
+ * Wrap JS code for CDP Runtime.evaluate:
257
+ * - Already an IIFE `(...)()` → send as-is
258
+ * - Arrow/function literal → wrap as IIFE `(code)()`
259
+ * - `new Promise(...)` or raw expression → send as-is (expression)
260
+ */
261
+ function wrapForEval(js) {
262
+ const code = js.trim();
263
+ if (!code)
264
+ return 'undefined';
265
+ // Already an IIFE: `(async () => { ... })()` or `(function() {...})()`
266
+ if (/^\([\s\S]*\)\s*\(.*\)\s*$/.test(code))
267
+ return code;
268
+ // Arrow function: `() => ...` or `async () => ...`
269
+ if (/^(async\s+)?(\([^)]*\)|[A-Za-z_]\w*)\s*=>/.test(code))
270
+ return `(${code})()`;
271
+ // Function declaration: `function ...` or `async function ...`
272
+ if (/^(async\s+)?function[\s(]/.test(code))
273
+ return `(${code})()`;
274
+ // Everything else: bare expression, `new Promise(...)`, etc. → evaluate directly
275
+ return code;
276
+ }
@@ -1,20 +1,6 @@
1
- import { afterEach, describe, it, expect, vi } from 'vitest';
2
- import * as os from 'node:os';
3
- import * as path from 'node:path';
1
+ import { describe, it, expect } from 'vitest';
4
2
  import { PlaywrightMCP, __test__ } from './browser/index.js';
5
- afterEach(() => {
6
- __test__.resetMcpServerPathCache();
7
- __test__.setMcpDiscoveryTestHooks();
8
- delete process.env.OPENCLI_MCP_SERVER_PATH;
9
- });
10
3
  describe('browser helpers', () => {
11
- it('creates JSON-RPC requests with unique ids', () => {
12
- const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' });
13
- const second = __test__.createJsonRpcRequest('tools/call', { name: 'browser_snapshot' });
14
- expect(second.id).toBe(first.id + 1);
15
- expect(first.message).toContain(`"id":${first.id}`);
16
- expect(second.message).toContain(`"id":${second.id}`);
17
- });
18
4
  it('extracts tab entries from string snapshots', () => {
19
5
  const entries = __test__.extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
20
6
  expect(entries).toEqual([
@@ -41,216 +27,9 @@ describe('browser helpers', () => {
41
27
  it('keeps only the tail of stderr buffers', () => {
42
28
  expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
43
29
  });
44
- it('builds extension MCP args in local mode (no CI)', () => {
45
- const savedCI = process.env.CI;
46
- delete process.env.CI;
47
- try {
48
- expect(__test__.buildMcpArgs({
49
- mcpPath: '/tmp/cli.js',
50
- executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
51
- })).toEqual([
52
- '/tmp/cli.js',
53
- '--extension',
54
- '--executable-path',
55
- '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
56
- ]);
57
- expect(__test__.buildMcpArgs({
58
- mcpPath: '/tmp/cli.js',
59
- })).toEqual([
60
- '/tmp/cli.js',
61
- '--extension',
62
- ]);
63
- }
64
- finally {
65
- if (savedCI !== undefined) {
66
- process.env.CI = savedCI;
67
- }
68
- else {
69
- delete process.env.CI;
70
- }
71
- }
72
- });
73
- it('builds standalone MCP args in CI mode', () => {
74
- const savedCI = process.env.CI;
75
- process.env.CI = 'true';
76
- try {
77
- // CI mode: no --extension — browser launches in standalone headed mode
78
- expect(__test__.buildMcpArgs({
79
- mcpPath: '/tmp/cli.js',
80
- })).toEqual([
81
- '/tmp/cli.js',
82
- ]);
83
- expect(__test__.buildMcpArgs({
84
- mcpPath: '/tmp/cli.js',
85
- executablePath: '/usr/bin/chromium',
86
- })).toEqual([
87
- '/tmp/cli.js',
88
- '--executable-path',
89
- '/usr/bin/chromium',
90
- ]);
91
- }
92
- finally {
93
- if (savedCI !== undefined) {
94
- process.env.CI = savedCI;
95
- }
96
- else {
97
- delete process.env.CI;
98
- }
99
- }
100
- });
101
- it('builds a direct node launch spec when a local MCP path is available', () => {
102
- const savedCI = process.env.CI;
103
- delete process.env.CI;
104
- try {
105
- expect(__test__.buildMcpLaunchSpec({
106
- mcpPath: '/tmp/cli.js',
107
- executablePath: '/usr/bin/google-chrome',
108
- })).toEqual({
109
- command: 'node',
110
- args: ['/tmp/cli.js', '--extension', '--executable-path', '/usr/bin/google-chrome'],
111
- usedNpxFallback: false,
112
- });
113
- }
114
- finally {
115
- if (savedCI !== undefined) {
116
- process.env.CI = savedCI;
117
- }
118
- else {
119
- delete process.env.CI;
120
- }
121
- }
122
- });
123
- it('falls back to npx bootstrap when no MCP path is available', () => {
124
- const savedCI = process.env.CI;
125
- delete process.env.CI;
126
- try {
127
- expect(__test__.buildMcpLaunchSpec({
128
- mcpPath: null,
129
- })).toEqual({
130
- command: 'npx',
131
- args: ['-y', '@playwright/mcp@latest', '--extension'],
132
- usedNpxFallback: true,
133
- });
134
- }
135
- finally {
136
- if (savedCI !== undefined) {
137
- process.env.CI = savedCI;
138
- }
139
- else {
140
- delete process.env.CI;
141
- }
142
- }
143
- });
144
30
  it('times out slow promises', async () => {
145
31
  await expect(__test__.withTimeoutMs(new Promise(() => { }), 10, 'timeout')).rejects.toThrow('timeout');
146
32
  });
147
- it('prefers OPENCLI_MCP_SERVER_PATH over discovered locations', () => {
148
- process.env.OPENCLI_MCP_SERVER_PATH = '/env/mcp/cli.js';
149
- const existsSync = vi.fn((candidate) => candidate === '/env/mcp/cli.js');
150
- const execSync = vi.fn();
151
- __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync });
152
- expect(__test__.findMcpServerPath()).toBe('/env/mcp/cli.js');
153
- expect(execSync).not.toHaveBeenCalled();
154
- expect(existsSync).toHaveBeenCalledWith('/env/mcp/cli.js');
155
- });
156
- it('discovers global @playwright/mcp from the current Node runtime prefix', () => {
157
- const originalExecPath = process.execPath;
158
- const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
159
- const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js';
160
- Object.defineProperty(process, 'execPath', {
161
- value: runtimeExecPath,
162
- configurable: true,
163
- });
164
- const existsSync = vi.fn((candidate) => candidate === runtimeGlobalMcp);
165
- const execSync = vi.fn();
166
- __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync });
167
- try {
168
- expect(__test__.findMcpServerPath()).toBe(runtimeGlobalMcp);
169
- expect(execSync).not.toHaveBeenCalled();
170
- expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp);
171
- }
172
- finally {
173
- Object.defineProperty(process, 'execPath', {
174
- value: originalExecPath,
175
- configurable: true,
176
- });
177
- }
178
- });
179
- it('falls back to npm root -g when runtime prefix lookup misses', () => {
180
- const originalExecPath = process.execPath;
181
- const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
182
- const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js';
183
- const npmRootGlobal = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules';
184
- const npmGlobalMcp = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules/@playwright/mcp/cli.js';
185
- Object.defineProperty(process, 'execPath', {
186
- value: runtimeExecPath,
187
- configurable: true,
188
- });
189
- const existsSync = vi.fn((candidate) => candidate === npmGlobalMcp);
190
- const execSync = vi.fn((command) => {
191
- if (String(command).includes('npm root -g'))
192
- return `${npmRootGlobal}\n`;
193
- throw new Error(`unexpected command: ${String(command)}`);
194
- });
195
- __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync });
196
- try {
197
- expect(__test__.findMcpServerPath()).toBe(npmGlobalMcp);
198
- expect(execSync).toHaveBeenCalledOnce();
199
- expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp);
200
- expect(existsSync).toHaveBeenCalledWith(npmGlobalMcp);
201
- }
202
- finally {
203
- Object.defineProperty(process, 'execPath', {
204
- value: originalExecPath,
205
- configurable: true,
206
- });
207
- }
208
- });
209
- it('returns null when new global discovery paths are unavailable', () => {
210
- const originalExecPath = process.execPath;
211
- const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
212
- Object.defineProperty(process, 'execPath', {
213
- value: runtimeExecPath,
214
- configurable: true,
215
- });
216
- const existsSync = vi.fn(() => false);
217
- const execSync = vi.fn((command) => {
218
- if (String(command).includes('npm root -g'))
219
- return '/missing/global/node_modules\n';
220
- throw new Error(`missing command: ${String(command)}`);
221
- });
222
- __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync });
223
- try {
224
- expect(__test__.findMcpServerPath()).toBeNull();
225
- }
226
- finally {
227
- Object.defineProperty(process, 'execPath', {
228
- value: originalExecPath,
229
- configurable: true,
230
- });
231
- }
232
- });
233
- it('ignores non-server playwright cli paths discovered from fallback scans', () => {
234
- const wrongCli = '/root/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/lib/mcp/terminal/cli.js';
235
- const npxCacheBase = path.join(os.homedir(), '.npm', '_npx');
236
- const existsSync = vi.fn((candidate) => {
237
- const value = String(candidate);
238
- return value === npxCacheBase || value === wrongCli;
239
- });
240
- const execSync = vi.fn((command) => {
241
- if (String(command).includes('npm root -g'))
242
- return '/missing/global/node_modules\n';
243
- if (String(command).includes('--package=@playwright/mcp which mcp-server-playwright'))
244
- return `${wrongCli}\n`;
245
- if (String(command).includes('which mcp-server-playwright'))
246
- return '';
247
- if (String(command).includes(`find "${npxCacheBase}"`))
248
- return `${wrongCli}\n`;
249
- throw new Error(`unexpected command: ${String(command)}`);
250
- });
251
- __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync });
252
- expect(__test__.findMcpServerPath()).toBeNull();
253
- });
254
33
  });
255
34
  describe('PlaywrightMCP state', () => {
256
35
  it('transitions to closed after close()', async () => {
@@ -262,16 +41,16 @@ describe('PlaywrightMCP state', () => {
262
41
  it('rejects connect() after the session has been closed', async () => {
263
42
  const mcp = new PlaywrightMCP();
264
43
  await mcp.close();
265
- await expect(mcp.connect()).rejects.toThrow('Playwright MCP session is closed');
44
+ await expect(mcp.connect()).rejects.toThrow('Session is closed');
266
45
  });
267
46
  it('rejects connect() while already connecting', async () => {
268
47
  const mcp = new PlaywrightMCP();
269
48
  mcp._state = 'connecting';
270
- await expect(mcp.connect()).rejects.toThrow('Playwright MCP is already connecting');
49
+ await expect(mcp.connect()).rejects.toThrow('Already connecting');
271
50
  });
272
51
  it('rejects connect() while closing', async () => {
273
52
  const mcp = new PlaywrightMCP();
274
53
  mcp._state = 'closing';
275
- await expect(mcp.connect()).rejects.toThrow('Playwright MCP is closing');
54
+ await expect(mcp.connect()).rejects.toThrow('Session is closing');
276
55
  });
277
56
  });