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