@jackwener/opencli 0.7.6 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/cross-project-adapter-migration/SKILL.md +249 -0
- package/.agents/workflows/cross-project-adapter-migration.md +54 -0
- package/dist/browser/discover.d.ts +8 -0
- package/dist/browser/discover.js +83 -0
- package/dist/browser/errors.d.ts +21 -0
- package/dist/browser/errors.js +54 -0
- package/dist/browser/index.d.ts +22 -0
- package/dist/browser/index.js +22 -0
- package/dist/browser/mcp.d.ts +33 -0
- package/dist/browser/mcp.js +304 -0
- package/dist/browser/page.d.ts +41 -0
- package/dist/browser/page.js +142 -0
- package/dist/browser/tabs.d.ts +13 -0
- package/dist/browser/tabs.js +70 -0
- package/dist/browser.test.js +1 -1
- package/dist/completion.js +2 -2
- package/dist/doctor.js +7 -7
- package/dist/engine.js +6 -4
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +42 -0
- package/dist/logger.d.ts +22 -0
- package/dist/logger.js +47 -0
- package/dist/main.js +8 -2
- package/dist/pipeline/executor.js +8 -8
- package/dist/pipeline/steps/browser.d.ts +7 -7
- package/dist/pipeline/steps/intercept.d.ts +1 -1
- package/dist/pipeline/steps/tap.d.ts +1 -1
- package/dist/setup.js +1 -1
- package/package.json +3 -3
- package/scripts/clean-yaml.cjs +19 -0
- package/scripts/copy-yaml.cjs +21 -0
- package/scripts/postinstall.js +30 -9
- package/src/bilibili.ts +1 -1
- package/src/browser/discover.ts +90 -0
- package/src/browser/errors.ts +89 -0
- package/src/browser/index.ts +26 -0
- package/src/browser/mcp.ts +305 -0
- package/src/browser/page.ts +152 -0
- package/src/browser/tabs.ts +76 -0
- package/src/browser.test.ts +1 -1
- package/src/completion.ts +2 -2
- package/src/doctor.ts +13 -1
- package/src/engine.ts +9 -4
- package/src/errors.ts +48 -0
- package/src/logger.ts +57 -0
- package/src/main.ts +10 -3
- package/src/pipeline/executor.ts +8 -7
- package/src/pipeline/steps/browser.ts +18 -18
- package/src/pipeline/steps/intercept.ts +8 -8
- package/src/pipeline/steps/tap.ts +2 -2
- package/src/setup.ts +1 -1
- package/tsconfig.json +1 -2
- package/dist/browser.d.ts +0 -105
- package/dist/browser.js +0 -644
- package/src/browser.ts +0 -698
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright MCP process manager.
|
|
3
|
+
* Handles lifecycle management, JSON-RPC communication, and browser session orchestration.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
7
|
+
import type { IPage } from '../types.js';
|
|
8
|
+
import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from '../runtime.js';
|
|
9
|
+
import { PKG_VERSION } from '../version.js';
|
|
10
|
+
import { Page } from './page.js';
|
|
11
|
+
import { getTokenFingerprint, formatBrowserConnectError, inferConnectFailureKind } from './errors.js';
|
|
12
|
+
import { findMcpServerPath, buildMcpArgs } from './discover.js';
|
|
13
|
+
import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
|
14
|
+
|
|
15
|
+
const STDERR_BUFFER_LIMIT = 16 * 1024;
|
|
16
|
+
const INITIAL_TABS_TIMEOUT_MS = 1500;
|
|
17
|
+
const TAB_CLEANUP_TIMEOUT_MS = 2000;
|
|
18
|
+
|
|
19
|
+
export type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
|
|
20
|
+
|
|
21
|
+
// JSON-RPC helpers
|
|
22
|
+
let _nextId = 1;
|
|
23
|
+
export function createJsonRpcRequest(method: string, params: Record<string, unknown> = {}): { id: number; message: string } {
|
|
24
|
+
const id = _nextId++;
|
|
25
|
+
return {
|
|
26
|
+
id,
|
|
27
|
+
message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Playwright MCP process manager.
|
|
33
|
+
*/
|
|
34
|
+
export class PlaywrightMCP {
|
|
35
|
+
private static _activeInsts: Set<PlaywrightMCP> = new Set();
|
|
36
|
+
private static _cleanupRegistered = false;
|
|
37
|
+
|
|
38
|
+
private static _registerGlobalCleanup() {
|
|
39
|
+
if (this._cleanupRegistered) return;
|
|
40
|
+
this._cleanupRegistered = true;
|
|
41
|
+
const cleanup = () => {
|
|
42
|
+
for (const inst of this._activeInsts) {
|
|
43
|
+
if (inst._proc && !inst._proc.killed) {
|
|
44
|
+
try { inst._proc.kill('SIGKILL'); } catch {}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
process.on('exit', cleanup);
|
|
49
|
+
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
|
50
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private _proc: ChildProcess | null = null;
|
|
54
|
+
private _buffer = '';
|
|
55
|
+
private _pending = new Map<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
|
|
56
|
+
private _initialTabIdentities: string[] = [];
|
|
57
|
+
private _closingPromise: Promise<void> | null = null;
|
|
58
|
+
private _state: PlaywrightMCPState = 'idle';
|
|
59
|
+
|
|
60
|
+
private _page: Page | null = null;
|
|
61
|
+
|
|
62
|
+
get state(): PlaywrightMCPState {
|
|
63
|
+
return this._state;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private _sendRequest(method: string, params: Record<string, unknown> = {}): Promise<any> {
|
|
67
|
+
return new Promise<any>((resolve, reject) => {
|
|
68
|
+
if (!this._proc?.stdin?.writable) {
|
|
69
|
+
reject(new Error('Playwright MCP process is not writable'));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const { id, message } = createJsonRpcRequest(method, params);
|
|
73
|
+
this._pending.set(id, { resolve, reject });
|
|
74
|
+
this._proc.stdin.write(message, (err) => {
|
|
75
|
+
if (!err) return;
|
|
76
|
+
this._pending.delete(id);
|
|
77
|
+
reject(err);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private _rejectPendingRequests(error: Error): void {
|
|
83
|
+
const pending = [...this._pending.values()];
|
|
84
|
+
this._pending.clear();
|
|
85
|
+
for (const waiter of pending) waiter.reject(error);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private _resetAfterFailedConnect(): void {
|
|
89
|
+
const proc = this._proc;
|
|
90
|
+
this._page = null;
|
|
91
|
+
this._proc = null;
|
|
92
|
+
this._buffer = '';
|
|
93
|
+
this._initialTabIdentities = [];
|
|
94
|
+
this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
|
|
95
|
+
PlaywrightMCP._activeInsts.delete(this);
|
|
96
|
+
if (proc && !proc.killed) {
|
|
97
|
+
try { proc.kill('SIGKILL'); } catch {}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async connect(opts: { timeout?: number } = {}): Promise<IPage> {
|
|
102
|
+
if (this._state === 'connected' && this._page) return this._page;
|
|
103
|
+
if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting');
|
|
104
|
+
if (this._state === 'closing') throw new Error('Playwright MCP is closing');
|
|
105
|
+
if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
|
|
106
|
+
|
|
107
|
+
const mcpPath = findMcpServerPath();
|
|
108
|
+
if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp');
|
|
109
|
+
|
|
110
|
+
PlaywrightMCP._registerGlobalCleanup();
|
|
111
|
+
PlaywrightMCP._activeInsts.add(this);
|
|
112
|
+
this._state = 'connecting';
|
|
113
|
+
const timeout = opts.timeout ?? DEFAULT_BROWSER_CONNECT_TIMEOUT;
|
|
114
|
+
|
|
115
|
+
return new Promise<Page>((resolve, reject) => {
|
|
116
|
+
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
|
|
117
|
+
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
|
|
118
|
+
const useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
|
119
|
+
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
|
120
|
+
const tokenFingerprint = getTokenFingerprint(extensionToken);
|
|
121
|
+
let stderrBuffer = '';
|
|
122
|
+
let settled = false;
|
|
123
|
+
|
|
124
|
+
const settleError = (kind: Parameters<typeof formatBrowserConnectError>[0]['kind'], extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
|
|
125
|
+
if (settled) return;
|
|
126
|
+
settled = true;
|
|
127
|
+
this._state = 'idle';
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
this._resetAfterFailedConnect();
|
|
130
|
+
reject(formatBrowserConnectError({
|
|
131
|
+
kind,
|
|
132
|
+
timeout,
|
|
133
|
+
hasExtensionToken: !!extensionToken,
|
|
134
|
+
tokenFingerprint,
|
|
135
|
+
stderr: stderrBuffer,
|
|
136
|
+
exitCode: extra.exitCode,
|
|
137
|
+
rawMessage: extra.rawMessage,
|
|
138
|
+
}));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const settleSuccess = (pageToResolve: Page) => {
|
|
142
|
+
if (settled) return;
|
|
143
|
+
settled = true;
|
|
144
|
+
this._state = 'connected';
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
resolve(pageToResolve);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
debugLog('Connection timed out');
|
|
151
|
+
settleError(inferConnectFailureKind({
|
|
152
|
+
hasExtensionToken: !!extensionToken,
|
|
153
|
+
stderr: stderrBuffer,
|
|
154
|
+
}));
|
|
155
|
+
}, timeout * 1000);
|
|
156
|
+
|
|
157
|
+
const mcpArgs = buildMcpArgs({
|
|
158
|
+
mcpPath,
|
|
159
|
+
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
|
|
160
|
+
});
|
|
161
|
+
if (process.env.OPENCLI_VERBOSE) {
|
|
162
|
+
console.error(`[opencli] Mode: ${useExtension ? 'extension' : 'standalone'}`);
|
|
163
|
+
if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
|
|
164
|
+
}
|
|
165
|
+
debugLog(`Spawning node ${mcpArgs.join(' ')}`);
|
|
166
|
+
|
|
167
|
+
this._proc = spawn('node', mcpArgs, {
|
|
168
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
169
|
+
env: { ...process.env },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Increase max listeners to avoid warnings
|
|
173
|
+
this._proc.setMaxListeners(20);
|
|
174
|
+
if (this._proc.stdout) this._proc.stdout.setMaxListeners(20);
|
|
175
|
+
|
|
176
|
+
const page = new Page((method, params = {}) => this._sendRequest(method, params));
|
|
177
|
+
this._page = page;
|
|
178
|
+
|
|
179
|
+
this._proc.stdout?.on('data', (chunk: Buffer) => {
|
|
180
|
+
this._buffer += chunk.toString();
|
|
181
|
+
const lines = this._buffer.split('\n');
|
|
182
|
+
this._buffer = lines.pop() ?? '';
|
|
183
|
+
for (const line of lines) {
|
|
184
|
+
if (!line.trim()) continue;
|
|
185
|
+
debugLog(`RECV: ${line}`);
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(line);
|
|
188
|
+
if (typeof parsed?.id === 'number') {
|
|
189
|
+
const waiter = this._pending.get(parsed.id);
|
|
190
|
+
if (waiter) {
|
|
191
|
+
this._pending.delete(parsed.id);
|
|
192
|
+
waiter.resolve(parsed);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch (e) {
|
|
196
|
+
debugLog(`Parse error: ${e}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
this._proc.stderr?.on('data', (chunk: Buffer) => {
|
|
202
|
+
const text = chunk.toString();
|
|
203
|
+
stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
|
|
204
|
+
debugLog(`STDERR: ${text}`);
|
|
205
|
+
});
|
|
206
|
+
this._proc.on('error', (err) => {
|
|
207
|
+
debugLog(`Subprocess error: ${err.message}`);
|
|
208
|
+
this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
|
|
209
|
+
settleError('process-exit', { rawMessage: err.message });
|
|
210
|
+
});
|
|
211
|
+
this._proc.on('close', (code) => {
|
|
212
|
+
debugLog(`Subprocess closed with code ${code}`);
|
|
213
|
+
this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
|
|
214
|
+
if (!settled) {
|
|
215
|
+
settleError(inferConnectFailureKind({
|
|
216
|
+
hasExtensionToken: !!extensionToken,
|
|
217
|
+
stderr: stderrBuffer,
|
|
218
|
+
exited: true,
|
|
219
|
+
}), { exitCode: code });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Initialize: send initialize request
|
|
224
|
+
debugLog('Waiting for initialize response...');
|
|
225
|
+
this._sendRequest('initialize', {
|
|
226
|
+
protocolVersion: '2024-11-05',
|
|
227
|
+
capabilities: {},
|
|
228
|
+
clientInfo: { name: 'opencli', version: PKG_VERSION },
|
|
229
|
+
}).then((resp: any) => {
|
|
230
|
+
debugLog('Got initialize response');
|
|
231
|
+
if (resp.error) {
|
|
232
|
+
settleError(inferConnectFailureKind({
|
|
233
|
+
hasExtensionToken: !!extensionToken,
|
|
234
|
+
stderr: stderrBuffer,
|
|
235
|
+
rawMessage: `MCP init failed: ${resp.error.message}`,
|
|
236
|
+
}), { rawMessage: resp.error.message });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
|
|
241
|
+
debugLog(`SEND: ${initializedMsg.trim()}`);
|
|
242
|
+
this._proc?.stdin?.write(initializedMsg);
|
|
243
|
+
|
|
244
|
+
// Use tabs as a readiness probe and for tab cleanup bookkeeping.
|
|
245
|
+
debugLog('Fetching initial tabs count...');
|
|
246
|
+
withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
|
|
247
|
+
debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
|
|
248
|
+
this._initialTabIdentities = extractTabIdentities(tabs);
|
|
249
|
+
settleSuccess(page);
|
|
250
|
+
}).catch((err: Error) => {
|
|
251
|
+
debugLog(`Tabs fetch error: ${err.message}`);
|
|
252
|
+
settleSuccess(page);
|
|
253
|
+
});
|
|
254
|
+
}).catch((err: Error) => {
|
|
255
|
+
debugLog(`Init promise rejected: ${err.message}`);
|
|
256
|
+
settleError('mcp-init', { rawMessage: err.message });
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async close(): Promise<void> {
|
|
263
|
+
if (this._closingPromise) return this._closingPromise;
|
|
264
|
+
if (this._state === 'closed') return;
|
|
265
|
+
this._state = 'closing';
|
|
266
|
+
this._closingPromise = (async () => {
|
|
267
|
+
try {
|
|
268
|
+
// Extension mode opens bridge/session tabs that we can clean up best-effort.
|
|
269
|
+
if (this._page && this._proc && !this._proc.killed) {
|
|
270
|
+
try {
|
|
271
|
+
const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
|
|
272
|
+
const tabEntries = extractTabEntries(tabs);
|
|
273
|
+
const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
|
|
274
|
+
for (const index of tabsToClose) {
|
|
275
|
+
try { await this._page.closeTab(index); } catch {}
|
|
276
|
+
}
|
|
277
|
+
} catch {}
|
|
278
|
+
}
|
|
279
|
+
if (this._proc && !this._proc.killed) {
|
|
280
|
+
this._proc.kill('SIGTERM');
|
|
281
|
+
const exited = await new Promise<boolean>((res) => {
|
|
282
|
+
let done = false;
|
|
283
|
+
const finish = (value: boolean) => {
|
|
284
|
+
if (done) return;
|
|
285
|
+
done = true;
|
|
286
|
+
res(value);
|
|
287
|
+
};
|
|
288
|
+
this._proc?.once('exit', () => finish(true));
|
|
289
|
+
setTimeout(() => finish(false), 3000);
|
|
290
|
+
});
|
|
291
|
+
if (!exited && this._proc && !this._proc.killed) {
|
|
292
|
+
try { this._proc.kill('SIGKILL'); } catch {}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
} finally {
|
|
296
|
+
this._rejectPendingRequests(new Error('Playwright MCP session closed'));
|
|
297
|
+
this._page = null;
|
|
298
|
+
this._proc = null;
|
|
299
|
+
this._state = 'closed';
|
|
300
|
+
PlaywrightMCP._activeInsts.delete(this);
|
|
301
|
+
}
|
|
302
|
+
})();
|
|
303
|
+
return this._closingPromise;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { formatSnapshot } from '../snapshotFormatter.js';
|
|
6
|
+
import { normalizeEvaluateSource } from '../pipeline/template.js';
|
|
7
|
+
import { generateInterceptorJs, generateReadInterceptedJs } from '../interceptor.js';
|
|
8
|
+
import type { IPage } from '../types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
|
|
12
|
+
*/
|
|
13
|
+
export class Page implements IPage {
|
|
14
|
+
constructor(private _request: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>) {}
|
|
15
|
+
|
|
16
|
+
async call(method: string, params: Record<string, unknown> = {}): Promise<any> {
|
|
17
|
+
const resp = await this._request(method, params);
|
|
18
|
+
if (resp.error) throw new Error(`page.${method}: ${(resp.error as any).message ?? JSON.stringify(resp.error)}`);
|
|
19
|
+
// Extract text content from MCP result
|
|
20
|
+
const result = resp.result as any;
|
|
21
|
+
if (result?.content) {
|
|
22
|
+
const textParts = result.content.filter((c: any) => c.type === 'text');
|
|
23
|
+
if (textParts.length === 1) {
|
|
24
|
+
let text = textParts[0].text;
|
|
25
|
+
// MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
|
|
26
|
+
// Strip the "### Ran Playwright code" suffix to get clean JSON
|
|
27
|
+
const codeMarker = text.indexOf('### Ran Playwright code');
|
|
28
|
+
if (codeMarker !== -1) {
|
|
29
|
+
text = text.slice(0, codeMarker).trim();
|
|
30
|
+
}
|
|
31
|
+
// Also handle "### Result\n[JSON]" format (some MCP versions)
|
|
32
|
+
const resultMarker = text.indexOf('### Result\n');
|
|
33
|
+
if (resultMarker !== -1) {
|
|
34
|
+
text = text.slice(resultMarker + '### Result\n'.length).trim();
|
|
35
|
+
}
|
|
36
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- High-level methods ---
|
|
43
|
+
|
|
44
|
+
async goto(url: string): Promise<void> {
|
|
45
|
+
await this.call('tools/call', { name: 'browser_navigate', arguments: { url } });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async evaluate(js: string): Promise<any> {
|
|
49
|
+
// Normalize IIFE format to function format expected by MCP browser_evaluate
|
|
50
|
+
const normalized = normalizeEvaluateSource(js);
|
|
51
|
+
return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
|
|
55
|
+
const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
|
|
56
|
+
if (opts.raw) return raw;
|
|
57
|
+
if (typeof raw === 'string') return formatSnapshot(raw, opts);
|
|
58
|
+
return raw;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async click(ref: string): Promise<void> {
|
|
62
|
+
await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async typeText(ref: string, text: string): Promise<void> {
|
|
66
|
+
await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async pressKey(key: string): Promise<void> {
|
|
70
|
+
await this.call('tools/call', { name: 'browser_press_key', arguments: { key } });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise<void> {
|
|
74
|
+
if (typeof options === 'number') {
|
|
75
|
+
await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } });
|
|
76
|
+
} else {
|
|
77
|
+
// Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling
|
|
78
|
+
await this.call('tools/call', { name: 'browser_wait_for', arguments: options });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async tabs(): Promise<any> {
|
|
83
|
+
return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async closeTab(index?: number): Promise<void> {
|
|
87
|
+
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async newTab(): Promise<void> {
|
|
91
|
+
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async selectTab(index: number): Promise<void> {
|
|
95
|
+
await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async networkRequests(includeStatic: boolean = false): Promise<any> {
|
|
99
|
+
return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async consoleMessages(level: string = 'info'): Promise<any> {
|
|
103
|
+
return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async scroll(direction: string = 'down', _amount: number = 500): Promise<void> {
|
|
107
|
+
await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise<void> {
|
|
111
|
+
const times = options.times ?? 3;
|
|
112
|
+
const delayMs = options.delayMs ?? 2000;
|
|
113
|
+
const js = `
|
|
114
|
+
async () => {
|
|
115
|
+
const maxTimes = ${times};
|
|
116
|
+
const maxWaitMs = ${delayMs};
|
|
117
|
+
for (let i = 0; i < maxTimes; i++) {
|
|
118
|
+
const lastHeight = document.body.scrollHeight;
|
|
119
|
+
window.scrollTo(0, lastHeight);
|
|
120
|
+
await new Promise(resolve => {
|
|
121
|
+
let timeoutId;
|
|
122
|
+
const observer = new MutationObserver(() => {
|
|
123
|
+
if (document.body.scrollHeight > lastHeight) {
|
|
124
|
+
clearTimeout(timeoutId);
|
|
125
|
+
observer.disconnect();
|
|
126
|
+
setTimeout(resolve, 100); // Small debounce for rendering
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
130
|
+
timeoutId = setTimeout(() => {
|
|
131
|
+
observer.disconnect();
|
|
132
|
+
resolve(null);
|
|
133
|
+
}, maxWaitMs);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
`;
|
|
138
|
+
await this.evaluate(js);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async installInterceptor(pattern: string): Promise<void> {
|
|
142
|
+
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
|
|
143
|
+
arrayName: '__opencli_xhr',
|
|
144
|
+
patchGuard: '__opencli_interceptor_patched',
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async getInterceptedRequests(): Promise<any[]> {
|
|
149
|
+
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
|
150
|
+
return result || [];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser tab management helpers: extract, diff, and cleanup tab state.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function extractTabEntries(raw: unknown): Array<{ index: number; identity: string }> {
|
|
6
|
+
if (Array.isArray(raw)) {
|
|
7
|
+
return raw.map((tab: Record<string, unknown>, index: number) => ({
|
|
8
|
+
index,
|
|
9
|
+
identity: [
|
|
10
|
+
tab?.id ?? '',
|
|
11
|
+
tab?.url ?? '',
|
|
12
|
+
tab?.title ?? '',
|
|
13
|
+
tab?.name ?? '',
|
|
14
|
+
].join('|'),
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (typeof raw === 'string') {
|
|
19
|
+
return raw
|
|
20
|
+
.split('\n')
|
|
21
|
+
.map(line => line.trim())
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map(line => {
|
|
24
|
+
// Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)"
|
|
25
|
+
const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/);
|
|
26
|
+
if (mcpMatch) {
|
|
27
|
+
return {
|
|
28
|
+
index: parseInt(mcpMatch[1], 10),
|
|
29
|
+
identity: mcpMatch[2].trim() || `tab-${mcpMatch[1]}`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// Legacy format: "Tab 0 ..."
|
|
33
|
+
const legacyMatch = line.match(/Tab\s+(\d+)\s*(.*)$/);
|
|
34
|
+
if (legacyMatch) {
|
|
35
|
+
return {
|
|
36
|
+
index: parseInt(legacyMatch[1], 10),
|
|
37
|
+
identity: legacyMatch[2].trim() || `tab-${legacyMatch[1]}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
})
|
|
42
|
+
.filter((entry): entry is { index: number; identity: string } => entry !== null);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function extractTabIdentities(raw: unknown): string[] {
|
|
49
|
+
return extractTabEntries(raw).map(tab => tab.identity);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] {
|
|
53
|
+
if (initialIdentities.length === 0 || currentTabs.length === 0) return [];
|
|
54
|
+
const remaining = new Map<string, number>();
|
|
55
|
+
for (const identity of initialIdentities) {
|
|
56
|
+
remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const tabsToClose: number[] = [];
|
|
60
|
+
for (const tab of currentTabs) {
|
|
61
|
+
const count = remaining.get(tab.identity) ?? 0;
|
|
62
|
+
if (count > 0) {
|
|
63
|
+
remaining.set(tab.identity, count - 1);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
tabsToClose.push(tab.index);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return tabsToClose.sort((a, b) => b - a);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function appendLimited(current: string, chunk: string, limit: number): string {
|
|
73
|
+
const next = current + chunk;
|
|
74
|
+
if (next.length <= limit) return next;
|
|
75
|
+
return next.slice(-limit);
|
|
76
|
+
}
|
package/src/browser.test.ts
CHANGED
package/src/completion.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { getRegistry } from './registry.js';
|
|
10
|
+
import { CliError } from './errors.js';
|
|
10
11
|
|
|
11
12
|
// ── Dynamic completion logic ───────────────────────────────────────────────
|
|
12
13
|
|
|
@@ -123,7 +124,6 @@ export function printCompletionScript(shell: string): void {
|
|
|
123
124
|
process.stdout.write(fishCompletionScript());
|
|
124
125
|
break;
|
|
125
126
|
default:
|
|
126
|
-
|
|
127
|
-
process.exitCode = 1;
|
|
127
|
+
throw new CliError('UNSUPPORTED_SHELL', `Unsupported shell: ${shell}. Supported: bash, zsh, fish`);
|
|
128
128
|
}
|
|
129
129
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
6
6
|
import { stdin as input, stdout as output } from 'node:process';
|
|
7
7
|
import chalk from 'chalk';
|
|
8
8
|
import type { IPage } from './types.js';
|
|
9
|
-
import { PlaywrightMCP, getTokenFingerprint } from './browser.js';
|
|
9
|
+
import { PlaywrightMCP, getTokenFingerprint } from './browser/index.js';
|
|
10
10
|
import { browserSession } from './runtime.js';
|
|
11
11
|
|
|
12
12
|
const PLAYWRIGHT_SERVER_NAME = 'playwright';
|
|
@@ -325,6 +325,8 @@ export function discoverExtensionToken(): string | null {
|
|
|
325
325
|
if (platform === 'darwin') {
|
|
326
326
|
bases.push(
|
|
327
327
|
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
|
|
328
|
+
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev'),
|
|
329
|
+
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta'),
|
|
328
330
|
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
|
|
329
331
|
path.join(home, 'Library', 'Application Support', 'Chromium'),
|
|
330
332
|
path.join(home, 'Library', 'Application Support', 'Microsoft Edge'),
|
|
@@ -332,6 +334,8 @@ export function discoverExtensionToken(): string | null {
|
|
|
332
334
|
} else if (platform === 'linux') {
|
|
333
335
|
bases.push(
|
|
334
336
|
path.join(home, '.config', 'google-chrome'),
|
|
337
|
+
path.join(home, '.config', 'google-chrome-unstable'),
|
|
338
|
+
path.join(home, '.config', 'google-chrome-beta'),
|
|
335
339
|
path.join(home, '.config', 'chromium'),
|
|
336
340
|
path.join(home, '.config', 'microsoft-edge'),
|
|
337
341
|
);
|
|
@@ -339,6 +343,8 @@ export function discoverExtensionToken(): string | null {
|
|
|
339
343
|
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
340
344
|
bases.push(
|
|
341
345
|
path.join(appData, 'Google', 'Chrome', 'User Data'),
|
|
346
|
+
path.join(appData, 'Google', 'Chrome Dev', 'User Data'),
|
|
347
|
+
path.join(appData, 'Google', 'Chrome Beta', 'User Data'),
|
|
342
348
|
path.join(appData, 'Microsoft', 'Edge', 'User Data'),
|
|
343
349
|
);
|
|
344
350
|
}
|
|
@@ -451,6 +457,8 @@ export function checkExtensionInstalled(): { installed: boolean; browsers: strin
|
|
|
451
457
|
if (platform === 'darwin') {
|
|
452
458
|
browserDirs.push(
|
|
453
459
|
{ name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') },
|
|
460
|
+
{ name: 'Chrome Dev', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev') },
|
|
461
|
+
{ name: 'Chrome Beta', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta') },
|
|
454
462
|
{ name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') },
|
|
455
463
|
{ name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') },
|
|
456
464
|
{ name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') },
|
|
@@ -458,6 +466,8 @@ export function checkExtensionInstalled(): { installed: boolean; browsers: strin
|
|
|
458
466
|
} else if (platform === 'linux') {
|
|
459
467
|
browserDirs.push(
|
|
460
468
|
{ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') },
|
|
469
|
+
{ name: 'Chrome Dev', base: path.join(home, '.config', 'google-chrome-unstable') },
|
|
470
|
+
{ name: 'Chrome Beta', base: path.join(home, '.config', 'google-chrome-beta') },
|
|
461
471
|
{ name: 'Chromium', base: path.join(home, '.config', 'chromium') },
|
|
462
472
|
{ name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') },
|
|
463
473
|
);
|
|
@@ -465,6 +475,8 @@ export function checkExtensionInstalled(): { installed: boolean; browsers: strin
|
|
|
465
475
|
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
466
476
|
browserDirs.push(
|
|
467
477
|
{ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') },
|
|
478
|
+
{ name: 'Chrome Dev', base: path.join(appData, 'Google', 'Chrome Dev', 'User Data') },
|
|
479
|
+
{ name: 'Chrome Beta', base: path.join(appData, 'Google', 'Chrome Beta', 'User Data') },
|
|
468
480
|
{ name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') },
|
|
469
481
|
);
|
|
470
482
|
}
|
package/src/engine.ts
CHANGED
|
@@ -14,6 +14,8 @@ import yaml from 'js-yaml';
|
|
|
14
14
|
import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
|
|
15
15
|
import type { IPage } from './types.js';
|
|
16
16
|
import { executePipeline } from './pipeline.js';
|
|
17
|
+
import { log } from './logger.js';
|
|
18
|
+
import { AdapterLoadError } from './errors.js';
|
|
17
19
|
|
|
18
20
|
/** Set of TS module paths that have been loaded */
|
|
19
21
|
const _loadedModules = new Set<string>();
|
|
@@ -84,7 +86,7 @@ function loadFromManifest(manifestPath: string, clisDir: string): void {
|
|
|
84
86
|
}
|
|
85
87
|
}
|
|
86
88
|
} catch (err: any) {
|
|
87
|
-
|
|
89
|
+
log.warn(`Failed to load manifest ${manifestPath}: ${err.message}`);
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
@@ -107,7 +109,7 @@ async function discoverClisFromFs(dir: string): Promise<void> {
|
|
|
107
109
|
) {
|
|
108
110
|
promises.push(
|
|
109
111
|
import(`file://${filePath}`).catch((err: any) => {
|
|
110
|
-
|
|
112
|
+
log.warn(`Failed to load module ${filePath}: ${err.message}`);
|
|
111
113
|
})
|
|
112
114
|
);
|
|
113
115
|
}
|
|
@@ -158,7 +160,7 @@ function registerYamlCli(filePath: string, defaultSite: string): void {
|
|
|
158
160
|
|
|
159
161
|
registerCommand(cmd);
|
|
160
162
|
} catch (err: any) {
|
|
161
|
-
|
|
163
|
+
log.warn(`Failed to load ${filePath}: ${err.message}`);
|
|
162
164
|
}
|
|
163
165
|
}
|
|
164
166
|
|
|
@@ -180,7 +182,10 @@ export async function executeCommand(
|
|
|
180
182
|
await import(`file://${modulePath}`);
|
|
181
183
|
_loadedModules.add(modulePath);
|
|
182
184
|
} catch (err: any) {
|
|
183
|
-
throw new
|
|
185
|
+
throw new AdapterLoadError(
|
|
186
|
+
`Failed to load adapter module ${modulePath}: ${err.message}`,
|
|
187
|
+
'Check that the adapter file exists and has no syntax errors.',
|
|
188
|
+
);
|
|
184
189
|
}
|
|
185
190
|
}
|
|
186
191
|
// After loading, the module's cli() call will have updated the registry
|