@jackwener/opencli 0.9.8 → 1.0.1
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 +35 -58
- package/README.zh-CN.md +36 -60
- 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 -12
- package/dist/browser/index.js +2 -12
- package/dist/browser/mcp.d.ts +9 -21
- package/dist/browser/mcp.js +70 -285
- package/dist/browser/page.d.ts +36 -7
- package/dist/browser/page.js +212 -81
- package/dist/browser.test.js +10 -231
- package/dist/cli-manifest.json +561 -14
- package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
- package/dist/clis/apple-podcasts/episodes.js +28 -0
- package/dist/clis/apple-podcasts/search.d.ts +1 -0
- package/dist/clis/apple-podcasts/search.js +29 -0
- package/dist/clis/apple-podcasts/top.d.ts +1 -0
- package/dist/clis/apple-podcasts/top.js +34 -0
- package/dist/clis/apple-podcasts/utils.d.ts +11 -0
- package/dist/clis/apple-podcasts/utils.js +30 -0
- package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
- package/dist/clis/apple-podcasts/utils.test.js +57 -0
- package/dist/clis/chatwise/history.js +18 -1
- package/dist/clis/discord-app/channels.js +33 -21
- 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/clis/twitter/accept.d.ts +1 -0
- package/dist/clis/twitter/accept.js +202 -0
- package/dist/clis/twitter/followers.js +30 -22
- package/dist/clis/twitter/following.js +19 -14
- package/dist/clis/twitter/notifications.js +29 -22
- package/dist/clis/twitter/reply-dm.d.ts +1 -0
- package/dist/clis/twitter/reply-dm.js +181 -0
- package/dist/clis/twitter/search.js +50 -12
- package/dist/clis/weread/book.d.ts +1 -0
- package/dist/clis/weread/book.js +26 -0
- package/dist/clis/weread/highlights.d.ts +1 -0
- package/dist/clis/weread/highlights.js +23 -0
- package/dist/clis/weread/notebooks.d.ts +1 -0
- package/dist/clis/weread/notebooks.js +21 -0
- package/dist/clis/weread/notes.d.ts +1 -0
- package/dist/clis/weread/notes.js +29 -0
- package/dist/clis/weread/ranking.d.ts +1 -0
- package/dist/clis/weread/ranking.js +28 -0
- package/dist/clis/weread/search.d.ts +1 -0
- package/dist/clis/weread/search.js +25 -0
- package/dist/clis/weread/shelf.d.ts +1 -0
- package/dist/clis/weread/shelf.js +24 -0
- package/dist/clis/weread/utils.d.ts +20 -0
- package/dist/clis/weread/utils.js +72 -0
- package/dist/clis/weread/utils.test.d.ts +1 -0
- package/dist/clis/weread/utils.test.js +85 -0
- package/dist/daemon.d.ts +13 -0
- package/dist/daemon.js +187 -0
- package/dist/doctor.d.ts +10 -65
- package/dist/doctor.js +49 -602
- package/dist/doctor.test.js +30 -170
- package/dist/main.js +12 -41
- 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/runtime.d.ts +1 -4
- package/dist/runtime.js +1 -4
- package/dist/setup.d.ts +6 -0
- package/dist/setup.js +46 -160
- package/dist/types.d.ts +6 -0
- package/extension/dist/background.js +484 -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 +370 -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 +2 -13
- package/src/browser/mcp.ts +81 -282
- package/src/browser/page.ts +223 -83
- package/src/browser.test.ts +9 -239
- package/src/clis/apple-podcasts/episodes.ts +28 -0
- package/src/clis/apple-podcasts/search.ts +29 -0
- package/src/clis/apple-podcasts/top.ts +34 -0
- package/src/clis/apple-podcasts/utils.test.ts +72 -0
- package/src/clis/apple-podcasts/utils.ts +37 -0
- package/src/clis/chatgpt/README.md +1 -1
- package/src/clis/chatgpt/README.zh-CN.md +1 -1
- package/src/clis/chatwise/history.ts +15 -1
- package/src/clis/discord-app/channels.ts +33 -21
- 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/clis/twitter/accept.ts +213 -0
- package/src/clis/twitter/followers.ts +36 -29
- package/src/clis/twitter/following.ts +25 -20
- package/src/clis/twitter/notifications.ts +34 -27
- package/src/clis/twitter/reply-dm.ts +193 -0
- package/src/clis/twitter/search.ts +53 -13
- package/src/clis/weread/book.ts +28 -0
- package/src/clis/weread/highlights.ts +25 -0
- package/src/clis/weread/notebooks.ts +23 -0
- package/src/clis/weread/notes.ts +31 -0
- package/src/clis/weread/ranking.ts +29 -0
- package/src/clis/weread/search.ts +26 -0
- package/src/clis/weread/shelf.ts +26 -0
- package/src/clis/weread/utils.test.ts +104 -0
- package/src/clis/weread/utils.ts +74 -0
- package/src/daemon.ts +217 -0
- package/src/doctor.test.ts +32 -193
- package/src/doctor.ts +58 -669
- package/src/main.ts +11 -34
- 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/runtime.ts +2 -6
- package/src/setup.ts +47 -183
- package/src/types.ts +1 -0
- package/tests/e2e/public-commands.test.ts +68 -1
- package/dist/clis/grok/debug.d.ts +0 -1
- package/dist/clis/grok/debug.js +0 -45
- package/src/clis/grok/debug.ts +0 -49
package/dist/browser/page.js
CHANGED
|
@@ -1,68 +1,79 @@
|
|
|
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
|
import { formatSnapshot } from '../snapshotFormatter.js';
|
|
5
|
-
import {
|
|
6
|
-
import { generateInterceptorJs, generateReadInterceptedJs } from '../interceptor.js';
|
|
7
|
-
import { BrowserConnectError } from '../errors.js';
|
|
13
|
+
import { sendCommand } from './daemon-client.js';
|
|
8
14
|
/**
|
|
9
|
-
* Page
|
|
15
|
+
* Page — implements IPage by talking to the daemon via HTTP.
|
|
10
16
|
*/
|
|
11
17
|
export class Page {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Close the automation window in the extension */
|
|
35
|
+
async closeWindow() {
|
|
36
|
+
try {
|
|
37
|
+
await sendCommand('close-window', {});
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Window may already be closed or daemon may be down
|
|
41
|
+
}
|
|
58
42
|
}
|
|
59
43
|
async evaluate(js) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
|
|
44
|
+
const code = wrapForEval(js);
|
|
45
|
+
return sendCommand('exec', { code, ...this._tabOpt() });
|
|
63
46
|
}
|
|
64
47
|
async snapshot(opts = {}) {
|
|
65
|
-
const
|
|
48
|
+
const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));
|
|
49
|
+
const code = `
|
|
50
|
+
(async () => {
|
|
51
|
+
function buildTree(node, depth) {
|
|
52
|
+
if (depth > ${maxDepth}) return '';
|
|
53
|
+
const role = node.getAttribute?.('role') || node.tagName?.toLowerCase() || 'generic';
|
|
54
|
+
const name = node.getAttribute?.('aria-label') || node.getAttribute?.('alt') || node.textContent?.trim().slice(0, 80) || '';
|
|
55
|
+
const isInteractive = ['a', 'button', 'input', 'select', 'textarea'].includes(node.tagName?.toLowerCase()) || node.getAttribute?.('tabindex') != null;
|
|
56
|
+
|
|
57
|
+
${opts.interactive ? 'if (!isInteractive && !node.children?.length) return "";' : ''}
|
|
58
|
+
|
|
59
|
+
let indent = ' '.repeat(depth);
|
|
60
|
+
let line = indent + role;
|
|
61
|
+
if (name) line += ' "' + name.replace(/"/g, '\\\\"') + '"';
|
|
62
|
+
if (node.tagName?.toLowerCase() === 'a' && node.href) line += ' [' + node.href + ']';
|
|
63
|
+
if (node.tagName?.toLowerCase() === 'input') line += ' [' + (node.type || 'text') + ']';
|
|
64
|
+
|
|
65
|
+
let result = line + '\\n';
|
|
66
|
+
if (node.children) {
|
|
67
|
+
for (const child of node.children) {
|
|
68
|
+
result += buildTree(child, depth + 1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
return buildTree(document.body, 0);
|
|
74
|
+
})()
|
|
75
|
+
`;
|
|
76
|
+
const raw = await sendCommand('exec', { code, ...this._tabOpt() });
|
|
66
77
|
if (opts.raw)
|
|
67
78
|
return raw;
|
|
68
79
|
if (typeof raw === 'string')
|
|
@@ -70,52 +81,147 @@ export class Page {
|
|
|
70
81
|
return raw;
|
|
71
82
|
}
|
|
72
83
|
async click(ref) {
|
|
73
|
-
|
|
84
|
+
const safeRef = JSON.stringify(ref);
|
|
85
|
+
const code = `
|
|
86
|
+
(() => {
|
|
87
|
+
const ref = ${safeRef};
|
|
88
|
+
const el = document.querySelector('[data-ref="' + ref + '"]')
|
|
89
|
+
|| document.querySelectorAll('a, button, input, [role="button"], [tabindex]')[parseInt(ref, 10) || 0];
|
|
90
|
+
if (!el) throw new Error('Element not found: ' + ref);
|
|
91
|
+
el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
92
|
+
el.click();
|
|
93
|
+
return 'clicked';
|
|
94
|
+
})()
|
|
95
|
+
`;
|
|
96
|
+
await sendCommand('exec', { code, ...this._tabOpt() });
|
|
74
97
|
}
|
|
75
98
|
async typeText(ref, text) {
|
|
76
|
-
|
|
99
|
+
const safeRef = JSON.stringify(ref);
|
|
100
|
+
const safeText = JSON.stringify(text);
|
|
101
|
+
const code = `
|
|
102
|
+
(() => {
|
|
103
|
+
const ref = ${safeRef};
|
|
104
|
+
const el = document.querySelector('[data-ref="' + ref + '"]')
|
|
105
|
+
|| document.querySelectorAll('input, textarea, [contenteditable]')[parseInt(ref, 10) || 0];
|
|
106
|
+
if (!el) throw new Error('Element not found: ' + ref);
|
|
107
|
+
el.focus();
|
|
108
|
+
el.value = ${safeText};
|
|
109
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
110
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
111
|
+
return 'typed';
|
|
112
|
+
})()
|
|
113
|
+
`;
|
|
114
|
+
await sendCommand('exec', { code, ...this._tabOpt() });
|
|
77
115
|
}
|
|
78
116
|
async pressKey(key) {
|
|
79
|
-
|
|
117
|
+
const code = `
|
|
118
|
+
(() => {
|
|
119
|
+
const el = document.activeElement || document.body;
|
|
120
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true }));
|
|
121
|
+
el.dispatchEvent(new KeyboardEvent('keyup', { key: ${JSON.stringify(key)}, bubbles: true }));
|
|
122
|
+
return 'pressed';
|
|
123
|
+
})()
|
|
124
|
+
`;
|
|
125
|
+
await sendCommand('exec', { code, ...this._tabOpt() });
|
|
80
126
|
}
|
|
81
127
|
async wait(options) {
|
|
82
128
|
if (typeof options === 'number') {
|
|
83
|
-
await
|
|
129
|
+
await new Promise(resolve => setTimeout(resolve, options * 1000));
|
|
130
|
+
return;
|
|
84
131
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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() });
|
|
88
150
|
}
|
|
89
151
|
}
|
|
90
152
|
async tabs() {
|
|
91
|
-
return
|
|
153
|
+
return sendCommand('tabs', { op: 'list' });
|
|
92
154
|
}
|
|
93
155
|
async closeTab(index) {
|
|
94
|
-
await
|
|
156
|
+
await sendCommand('tabs', { op: 'close', ...(index !== undefined ? { index } : {}) });
|
|
95
157
|
}
|
|
96
158
|
async newTab() {
|
|
97
|
-
await
|
|
159
|
+
await sendCommand('tabs', { op: 'new' });
|
|
98
160
|
}
|
|
99
161
|
async selectTab(index) {
|
|
100
|
-
await
|
|
162
|
+
await sendCommand('tabs', { op: 'select', index });
|
|
101
163
|
}
|
|
102
164
|
async networkRequests(includeStatic = false) {
|
|
103
|
-
|
|
165
|
+
const code = `
|
|
166
|
+
(() => {
|
|
167
|
+
const entries = performance.getEntriesByType('resource');
|
|
168
|
+
return entries
|
|
169
|
+
${includeStatic ? '' : '.filter(e => !["img", "font", "css", "script"].some(t => e.initiatorType === t))'}
|
|
170
|
+
.map(e => ({
|
|
171
|
+
url: e.name,
|
|
172
|
+
type: e.initiatorType,
|
|
173
|
+
duration: Math.round(e.duration),
|
|
174
|
+
size: e.transferSize || 0,
|
|
175
|
+
}));
|
|
176
|
+
})()
|
|
177
|
+
`;
|
|
178
|
+
return sendCommand('exec', { code, ...this._tabOpt() });
|
|
104
179
|
}
|
|
105
|
-
|
|
106
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Console messages are not available in lightweight daemon mode.
|
|
182
|
+
* Would require CDP Runtime.consoleAPICalled event listener.
|
|
183
|
+
* @returns Always returns empty array.
|
|
184
|
+
*/
|
|
185
|
+
async consoleMessages(_level = 'info') {
|
|
186
|
+
return [];
|
|
107
187
|
}
|
|
108
|
-
|
|
109
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Capture a screenshot via CDP Page.captureScreenshot.
|
|
190
|
+
* @param options.format - 'png' (default) or 'jpeg'
|
|
191
|
+
* @param options.quality - JPEG quality 0-100
|
|
192
|
+
* @param options.fullPage - capture full scrollable page
|
|
193
|
+
* @param options.path - save to file path (returns base64 if omitted)
|
|
194
|
+
*/
|
|
195
|
+
async screenshot(options = {}) {
|
|
196
|
+
const base64 = await sendCommand('screenshot', {
|
|
197
|
+
format: options.format,
|
|
198
|
+
quality: options.quality,
|
|
199
|
+
fullPage: options.fullPage,
|
|
200
|
+
...this._tabOpt(),
|
|
201
|
+
});
|
|
202
|
+
if (options.path) {
|
|
203
|
+
const fs = await import('node:fs');
|
|
204
|
+
const path = await import('node:path');
|
|
205
|
+
const dir = path.dirname(options.path);
|
|
206
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
207
|
+
fs.writeFileSync(options.path, Buffer.from(base64, 'base64'));
|
|
208
|
+
}
|
|
209
|
+
return base64;
|
|
210
|
+
}
|
|
211
|
+
async scroll(direction = 'down', amount = 500) {
|
|
212
|
+
const dx = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
|
|
213
|
+
const dy = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
|
|
214
|
+
await sendCommand('exec', {
|
|
215
|
+
code: `window.scrollBy(${dx}, ${dy})`,
|
|
216
|
+
...this._tabOpt(),
|
|
217
|
+
});
|
|
110
218
|
}
|
|
111
219
|
async autoScroll(options = {}) {
|
|
112
220
|
const times = options.times ?? 3;
|
|
113
221
|
const delayMs = options.delayMs ?? 2000;
|
|
114
|
-
const
|
|
115
|
-
async () => {
|
|
116
|
-
|
|
117
|
-
const maxWaitMs = ${delayMs};
|
|
118
|
-
for (let i = 0; i < maxTimes; i++) {
|
|
222
|
+
const code = `
|
|
223
|
+
(async () => {
|
|
224
|
+
for (let i = 0; i < ${times}; i++) {
|
|
119
225
|
const lastHeight = document.body.scrollHeight;
|
|
120
226
|
window.scrollTo(0, lastHeight);
|
|
121
227
|
await new Promise(resolve => {
|
|
@@ -124,28 +230,53 @@ export class Page {
|
|
|
124
230
|
if (document.body.scrollHeight > lastHeight) {
|
|
125
231
|
clearTimeout(timeoutId);
|
|
126
232
|
observer.disconnect();
|
|
127
|
-
setTimeout(resolve, 100);
|
|
233
|
+
setTimeout(resolve, 100);
|
|
128
234
|
}
|
|
129
235
|
});
|
|
130
236
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
131
|
-
timeoutId = setTimeout(() => {
|
|
132
|
-
observer.disconnect();
|
|
133
|
-
resolve(null);
|
|
134
|
-
}, maxWaitMs);
|
|
237
|
+
timeoutId = setTimeout(() => { observer.disconnect(); resolve(null); }, ${delayMs});
|
|
135
238
|
});
|
|
136
239
|
}
|
|
137
|
-
}
|
|
240
|
+
})()
|
|
138
241
|
`;
|
|
139
|
-
await this.
|
|
242
|
+
await sendCommand('exec', { code, ...this._tabOpt() });
|
|
140
243
|
}
|
|
141
244
|
async installInterceptor(pattern) {
|
|
245
|
+
const { generateInterceptorJs } = await import('../interceptor.js');
|
|
246
|
+
// Must use evaluate() so wrapForEval() converts the arrow function into an IIFE;
|
|
247
|
+
// sendCommand('exec') sends the code as-is, and CDP never executes a bare arrow.
|
|
142
248
|
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
|
|
143
249
|
arrayName: '__opencli_xhr',
|
|
144
250
|
patchGuard: '__opencli_interceptor_patched',
|
|
145
251
|
}));
|
|
146
252
|
}
|
|
147
253
|
async getInterceptedRequests() {
|
|
254
|
+
const { generateReadInterceptedJs } = await import('../interceptor.js');
|
|
255
|
+
// Same as installInterceptor: must go through evaluate() for IIFE wrapping
|
|
148
256
|
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
|
149
257
|
return result || [];
|
|
150
258
|
}
|
|
151
259
|
}
|
|
260
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
261
|
+
/**
|
|
262
|
+
* Wrap JS code for CDP Runtime.evaluate:
|
|
263
|
+
* - Already an IIFE `(...)()` → send as-is
|
|
264
|
+
* - Arrow/function literal → wrap as IIFE `(code)()`
|
|
265
|
+
* - `new Promise(...)` or raw expression → send as-is (expression)
|
|
266
|
+
*/
|
|
267
|
+
function wrapForEval(js) {
|
|
268
|
+
const code = js.trim();
|
|
269
|
+
if (!code)
|
|
270
|
+
return 'undefined';
|
|
271
|
+
// Already an IIFE: `(async () => { ... })()` or `(function() {...})()`
|
|
272
|
+
if (/^\([\s\S]*\)\s*\(.*\)\s*$/.test(code))
|
|
273
|
+
return code;
|
|
274
|
+
// Arrow function: `() => ...` or `async () => ...`
|
|
275
|
+
if (/^(async\s+)?(\([^)]*\)|[A-Za-z_]\w*)\s*=>/.test(code))
|
|
276
|
+
return `(${code})()`;
|
|
277
|
+
// Function declaration: `function ...` or `async function ...`
|
|
278
|
+
if (/^(async\s+)?function[\s(]/.test(code))
|
|
279
|
+
return `(${code})()`;
|
|
280
|
+
// Everything else: bare expression, `new Promise(...)`, etc. → evaluate directly
|
|
281
|
+
return code;
|
|
282
|
+
}
|
package/dist/browser.test.js
CHANGED
|
@@ -1,20 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import * as path from 'node:path';
|
|
4
|
-
import { PlaywrightMCP, __test__ } from './browser/index.js';
|
|
5
|
-
afterEach(() => {
|
|
6
|
-
__test__.resetMcpServerPathCache();
|
|
7
|
-
__test__.setMcpDiscoveryTestHooks();
|
|
8
|
-
delete process.env.OPENCLI_MCP_SERVER_PATH;
|
|
9
|
-
});
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { BrowserBridge, __test__ } from './browser/index.js';
|
|
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,237 +27,30 @@ 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
|
-
describe('
|
|
34
|
+
describe('BrowserBridge state', () => {
|
|
256
35
|
it('transitions to closed after close()', async () => {
|
|
257
|
-
const mcp = new
|
|
36
|
+
const mcp = new BrowserBridge();
|
|
258
37
|
expect(mcp.state).toBe('idle');
|
|
259
38
|
await mcp.close();
|
|
260
39
|
expect(mcp.state).toBe('closed');
|
|
261
40
|
});
|
|
262
41
|
it('rejects connect() after the session has been closed', async () => {
|
|
263
|
-
const mcp = new
|
|
42
|
+
const mcp = new BrowserBridge();
|
|
264
43
|
await mcp.close();
|
|
265
|
-
await expect(mcp.connect()).rejects.toThrow('
|
|
44
|
+
await expect(mcp.connect()).rejects.toThrow('Session is closed');
|
|
266
45
|
});
|
|
267
46
|
it('rejects connect() while already connecting', async () => {
|
|
268
|
-
const mcp = new
|
|
47
|
+
const mcp = new BrowserBridge();
|
|
269
48
|
mcp._state = 'connecting';
|
|
270
|
-
await expect(mcp.connect()).rejects.toThrow('
|
|
49
|
+
await expect(mcp.connect()).rejects.toThrow('Already connecting');
|
|
271
50
|
});
|
|
272
51
|
it('rejects connect() while closing', async () => {
|
|
273
|
-
const mcp = new
|
|
52
|
+
const mcp = new BrowserBridge();
|
|
274
53
|
mcp._state = 'closing';
|
|
275
|
-
await expect(mcp.connect()).rejects.toThrow('
|
|
54
|
+
await expect(mcp.connect()).rejects.toThrow('Session is closing');
|
|
276
55
|
});
|
|
277
56
|
});
|