@jackwener/opencli 0.7.10 → 0.8.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/.github/workflows/pkg-pr-new.yml +30 -0
- package/CDP.md +103 -0
- package/CDP.zh-CN.md +103 -0
- package/README.md +5 -0
- package/README.zh-CN.md +5 -0
- package/dist/browser/discover.d.ts +30 -0
- package/dist/browser/discover.js +128 -14
- package/dist/browser/errors.d.ts +2 -1
- package/dist/browser/errors.js +13 -0
- package/dist/browser/index.d.ts +6 -1
- package/dist/browser/index.js +6 -1
- package/dist/browser/mcp.js +14 -8
- package/dist/browser/page.js +11 -2
- package/dist/browser.test.js +135 -1
- package/dist/cli-manifest.json +163 -0
- package/dist/clis/barchart/flow.d.ts +1 -0
- package/dist/clis/barchart/flow.js +115 -0
- package/dist/clis/barchart/greeks.d.ts +1 -0
- package/dist/clis/barchart/greeks.js +119 -0
- package/dist/clis/barchart/options.d.ts +1 -0
- package/dist/clis/barchart/options.js +106 -0
- package/dist/clis/barchart/quote.d.ts +1 -0
- package/dist/clis/barchart/quote.js +133 -0
- package/dist/doctor.js +8 -0
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +59 -1
- package/dist/main.js +2 -15
- package/dist/pipeline/executor.js +2 -24
- package/dist/pipeline/registry.d.ts +19 -0
- package/dist/pipeline/registry.js +41 -0
- package/package.json +1 -1
- package/src/browser/discover.ts +149 -14
- package/src/browser/errors.ts +17 -1
- package/src/browser/index.ts +6 -1
- package/src/browser/mcp.ts +14 -7
- package/src/browser/page.ts +21 -2
- package/src/browser.test.ts +140 -1
- package/src/clis/barchart/flow.ts +120 -0
- package/src/clis/barchart/greeks.ts +123 -0
- package/src/clis/barchart/options.ts +110 -0
- package/src/clis/barchart/quote.ts +137 -0
- package/src/doctor.ts +9 -0
- package/src/engine.ts +58 -1
- package/src/main.ts +6 -11
- package/src/pipeline/executor.ts +2 -28
- package/src/pipeline/registry.ts +60 -0
- package/vitest.config.ts +7 -0
package/src/browser/discover.ts
CHANGED
|
@@ -9,19 +9,33 @@ import * as os from 'node:os';
|
|
|
9
9
|
import * as path from 'node:path';
|
|
10
10
|
|
|
11
11
|
let _cachedMcpServerPath: string | null | undefined;
|
|
12
|
+
let _existsSync = fs.existsSync;
|
|
13
|
+
let _execSync = execSync;
|
|
14
|
+
|
|
15
|
+
export function resetMcpServerPathCache(): void {
|
|
16
|
+
_cachedMcpServerPath = undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setMcpDiscoveryTestHooks(input?: {
|
|
20
|
+
existsSync?: typeof fs.existsSync;
|
|
21
|
+
execSync?: typeof execSync;
|
|
22
|
+
}): void {
|
|
23
|
+
_existsSync = input?.existsSync ?? fs.existsSync;
|
|
24
|
+
_execSync = input?.execSync ?? execSync;
|
|
25
|
+
}
|
|
12
26
|
|
|
13
27
|
export function findMcpServerPath(): string | null {
|
|
14
28
|
if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
|
|
15
29
|
|
|
16
30
|
const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
|
|
17
|
-
if (envMcp &&
|
|
31
|
+
if (envMcp && _existsSync(envMcp)) {
|
|
18
32
|
_cachedMcpServerPath = envMcp;
|
|
19
33
|
return _cachedMcpServerPath;
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
// Check local node_modules first (@playwright/mcp is the modern package)
|
|
23
37
|
const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
|
|
24
|
-
if (
|
|
38
|
+
if (_existsSync(localMcp)) {
|
|
25
39
|
_cachedMcpServerPath = localMcp;
|
|
26
40
|
return _cachedMcpServerPath;
|
|
27
41
|
}
|
|
@@ -29,11 +43,33 @@ export function findMcpServerPath(): string | null {
|
|
|
29
43
|
// Check project-relative path
|
|
30
44
|
const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
|
|
31
45
|
const projectMcp = path.resolve(__dirname2, '..', '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
|
|
32
|
-
if (
|
|
46
|
+
if (_existsSync(projectMcp)) {
|
|
33
47
|
_cachedMcpServerPath = projectMcp;
|
|
34
48
|
return _cachedMcpServerPath;
|
|
35
49
|
}
|
|
36
50
|
|
|
51
|
+
// Check global npm/yarn locations derived from current Node runtime.
|
|
52
|
+
const nodePrefix = path.resolve(path.dirname(process.execPath), '..');
|
|
53
|
+
const globalNodeModules = path.join(nodePrefix, 'lib', 'node_modules');
|
|
54
|
+
const globalMcp = path.join(globalNodeModules, '@playwright', 'mcp', 'cli.js');
|
|
55
|
+
if (_existsSync(globalMcp)) {
|
|
56
|
+
_cachedMcpServerPath = globalMcp;
|
|
57
|
+
return _cachedMcpServerPath;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check npm global root directly.
|
|
61
|
+
try {
|
|
62
|
+
const npmRootGlobal = _execSync('npm root -g 2>/dev/null', {
|
|
63
|
+
encoding: 'utf-8',
|
|
64
|
+
timeout: 5000,
|
|
65
|
+
}).trim();
|
|
66
|
+
const npmGlobalMcp = path.join(npmRootGlobal, '@playwright', 'mcp', 'cli.js');
|
|
67
|
+
if (npmRootGlobal && _existsSync(npmGlobalMcp)) {
|
|
68
|
+
_cachedMcpServerPath = npmGlobalMcp;
|
|
69
|
+
return _cachedMcpServerPath;
|
|
70
|
+
}
|
|
71
|
+
} catch {}
|
|
72
|
+
|
|
37
73
|
// Check common locations
|
|
38
74
|
const candidates = [
|
|
39
75
|
path.join(os.homedir(), '.npm', '_npx'),
|
|
@@ -43,8 +79,8 @@ export function findMcpServerPath(): string | null {
|
|
|
43
79
|
|
|
44
80
|
// Try npx resolution (legacy package name)
|
|
45
81
|
try {
|
|
46
|
-
const result =
|
|
47
|
-
if (result &&
|
|
82
|
+
const result = _execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
|
|
83
|
+
if (result && _existsSync(result)) {
|
|
48
84
|
_cachedMcpServerPath = result;
|
|
49
85
|
return _cachedMcpServerPath;
|
|
50
86
|
}
|
|
@@ -52,8 +88,8 @@ export function findMcpServerPath(): string | null {
|
|
|
52
88
|
|
|
53
89
|
// Try which
|
|
54
90
|
try {
|
|
55
|
-
const result =
|
|
56
|
-
if (result &&
|
|
91
|
+
const result = _execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
92
|
+
if (result && _existsSync(result)) {
|
|
57
93
|
_cachedMcpServerPath = result;
|
|
58
94
|
return _cachedMcpServerPath;
|
|
59
95
|
}
|
|
@@ -61,9 +97,9 @@ export function findMcpServerPath(): string | null {
|
|
|
61
97
|
|
|
62
98
|
// Search in common npx cache
|
|
63
99
|
for (const base of candidates) {
|
|
64
|
-
if (!
|
|
100
|
+
if (!_existsSync(base)) continue;
|
|
65
101
|
try {
|
|
66
|
-
const found =
|
|
102
|
+
const found = _execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
67
103
|
if (found) {
|
|
68
104
|
_cachedMcpServerPath = found;
|
|
69
105
|
return _cachedMcpServerPath;
|
|
@@ -75,16 +111,115 @@ export function findMcpServerPath(): string | null {
|
|
|
75
111
|
return _cachedMcpServerPath;
|
|
76
112
|
}
|
|
77
113
|
|
|
78
|
-
|
|
79
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Chrome 144+ auto-discovery: read DevToolsActivePort file to get CDP endpoint.
|
|
116
|
+
*
|
|
117
|
+
* Starting with Chrome 144, users can enable remote debugging from
|
|
118
|
+
* chrome://inspect#remote-debugging without any command-line flags.
|
|
119
|
+
* Chrome writes the active port and browser GUID to a DevToolsActivePort file
|
|
120
|
+
* in the user data directory, which we read to construct the WebSocket endpoint.
|
|
121
|
+
*/
|
|
122
|
+
export function discoverChromeEndpoint(): string | null {
|
|
123
|
+
const candidates: string[] = [];
|
|
124
|
+
|
|
125
|
+
// User-specified Chrome data dir takes highest priority
|
|
126
|
+
if (process.env.CHROME_USER_DATA_DIR) {
|
|
127
|
+
candidates.push(path.join(process.env.CHROME_USER_DATA_DIR, 'DevToolsActivePort'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Standard Chrome/Edge user data dirs per platform
|
|
131
|
+
if (process.platform === 'win32') {
|
|
132
|
+
const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
|
|
133
|
+
candidates.push(path.join(localAppData, 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'));
|
|
134
|
+
candidates.push(path.join(localAppData, 'Microsoft', 'Edge', 'User Data', 'DevToolsActivePort'));
|
|
135
|
+
} else if (process.platform === 'darwin') {
|
|
136
|
+
candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'));
|
|
137
|
+
candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Microsoft Edge', 'DevToolsActivePort'));
|
|
138
|
+
} else {
|
|
139
|
+
candidates.push(path.join(os.homedir(), '.config', 'google-chrome', 'DevToolsActivePort'));
|
|
140
|
+
candidates.push(path.join(os.homedir(), '.config', 'chromium', 'DevToolsActivePort'));
|
|
141
|
+
candidates.push(path.join(os.homedir(), '.config', 'microsoft-edge', 'DevToolsActivePort'));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const filePath of candidates) {
|
|
145
|
+
try {
|
|
146
|
+
const content = fs.readFileSync(filePath, 'utf-8').trim();
|
|
147
|
+
const lines = content.split('\n');
|
|
148
|
+
if (lines.length >= 2) {
|
|
149
|
+
const port = parseInt(lines[0], 10);
|
|
150
|
+
const browserPath = lines[1]; // e.g. /devtools/browser/<GUID>
|
|
151
|
+
if (port > 0 && browserPath.startsWith('/devtools/browser/')) {
|
|
152
|
+
return `ws://127.0.0.1:${port}${browserPath}`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function resolveCdpEndpoint(): { endpoint?: string; requestedCdp: boolean } {
|
|
161
|
+
const envVal = process.env.OPENCLI_CDP_ENDPOINT;
|
|
162
|
+
if (envVal === '1' || envVal?.toLowerCase() === 'true') {
|
|
163
|
+
const autoDiscovered = discoverChromeEndpoint();
|
|
164
|
+
return { endpoint: autoDiscovered ?? envVal, requestedCdp: true };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (envVal) {
|
|
168
|
+
return { endpoint: envVal, requestedCdp: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Fallback to auto-discovery if not explicitly set
|
|
172
|
+
const autoDiscovered = discoverChromeEndpoint();
|
|
173
|
+
if (autoDiscovered) {
|
|
174
|
+
return { endpoint: autoDiscovered, requestedCdp: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { requestedCdp: false };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildRuntimeArgs(input?: { executablePath?: string | null; cdpEndpoint?: string }): string[] {
|
|
181
|
+
const args: string[] = [];
|
|
182
|
+
|
|
183
|
+
// Priority 1: CDP endpoint (remote Chrome debugging or local Auto-Discovery)
|
|
184
|
+
if (input?.cdpEndpoint) {
|
|
185
|
+
args.push('--cdp-endpoint', input.cdpEndpoint);
|
|
186
|
+
return args;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Priority 2: Extension mode (local Chrome with MCP Bridge extension)
|
|
80
190
|
if (!process.env.CI) {
|
|
81
|
-
// Local: always connect to user's running Chrome via MCP Bridge extension
|
|
82
191
|
args.push('--extension');
|
|
83
192
|
}
|
|
84
|
-
|
|
193
|
+
|
|
194
|
+
// CI/standalone mode: @playwright/mcp launches its own browser (headed by default).
|
|
85
195
|
// xvfb provides a virtual display for headed mode in GitHub Actions.
|
|
86
|
-
if (input
|
|
196
|
+
if (input?.executablePath) {
|
|
87
197
|
args.push('--executable-path', input.executablePath);
|
|
88
198
|
}
|
|
89
199
|
return args;
|
|
90
200
|
}
|
|
201
|
+
|
|
202
|
+
export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null; cdpEndpoint?: string }): string[] {
|
|
203
|
+
return [input.mcpPath, ...buildRuntimeArgs(input)];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function buildMcpLaunchSpec(input: { mcpPath?: string | null; executablePath?: string | null; cdpEndpoint?: string }): {
|
|
207
|
+
command: string;
|
|
208
|
+
args: string[];
|
|
209
|
+
usedNpxFallback: boolean;
|
|
210
|
+
} {
|
|
211
|
+
const runtimeArgs = buildRuntimeArgs(input);
|
|
212
|
+
if (input.mcpPath) {
|
|
213
|
+
return {
|
|
214
|
+
command: 'node',
|
|
215
|
+
args: [input.mcpPath, ...runtimeArgs],
|
|
216
|
+
usedNpxFallback: false,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
command: 'npx',
|
|
222
|
+
args: ['-y', '@playwright/mcp@latest', ...runtimeArgs],
|
|
223
|
+
usedNpxFallback: true,
|
|
224
|
+
};
|
|
225
|
+
}
|
package/src/browser/errors.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
|
|
7
|
-
export type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown';
|
|
7
|
+
export type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'cdp-connection-failed' | 'unknown';
|
|
8
8
|
|
|
9
9
|
export type ConnectFailureInput = {
|
|
10
10
|
kind: ConnectFailureKind;
|
|
@@ -26,6 +26,15 @@ export function formatBrowserConnectError(input: ConnectFailureInput): Error {
|
|
|
26
26
|
const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
|
|
27
27
|
const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
|
|
28
28
|
|
|
29
|
+
if (input.kind === 'cdp-connection-failed') {
|
|
30
|
+
return new Error(
|
|
31
|
+
`Failed to connect to remote Chrome via CDP endpoint.\n\n` +
|
|
32
|
+
`Check if Chrome is running with remote debugging enabled (--remote-debugging-port=9222) or DevToolsActivePort is available under chrome://inspect#remote-debugging.\n` +
|
|
33
|
+
`If you specified OPENCLI_CDP_ENDPOINT=1, auto-discovery might have failed.` +
|
|
34
|
+
suffix,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
if (input.kind === 'missing-token') {
|
|
30
39
|
return new Error(
|
|
31
40
|
'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
|
|
@@ -74,9 +83,16 @@ export function inferConnectFailureKind(args: {
|
|
|
74
83
|
stderr: string;
|
|
75
84
|
rawMessage?: string;
|
|
76
85
|
exited?: boolean;
|
|
86
|
+
isCdpMode?: boolean;
|
|
77
87
|
}): ConnectFailureKind {
|
|
78
88
|
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
|
|
79
89
|
|
|
90
|
+
if (args.isCdpMode) {
|
|
91
|
+
if (args.rawMessage?.startsWith('MCP init failed:')) return 'mcp-init';
|
|
92
|
+
if (args.exited) return 'cdp-connection-failed';
|
|
93
|
+
return 'cdp-connection-failed';
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
if (!args.hasExtensionToken)
|
|
81
97
|
return 'missing-token';
|
|
82
98
|
if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
|
package/src/browser/index.ts
CHANGED
|
@@ -9,11 +9,12 @@ export { Page } from './page.js';
|
|
|
9
9
|
export { PlaywrightMCP } from './mcp.js';
|
|
10
10
|
export { getTokenFingerprint, formatBrowserConnectError } from './errors.js';
|
|
11
11
|
export type { ConnectFailureKind, ConnectFailureInput } from './errors.js';
|
|
12
|
+
export { resolveCdpEndpoint } from './discover.js';
|
|
12
13
|
|
|
13
14
|
// Test-only helpers — exposed for unit tests
|
|
14
15
|
import { createJsonRpcRequest } from './mcp.js';
|
|
15
16
|
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
|
16
|
-
import { buildMcpArgs } from './discover.js';
|
|
17
|
+
import { buildMcpArgs, buildMcpLaunchSpec, findMcpServerPath, resetMcpServerPathCache, setMcpDiscoveryTestHooks } from './discover.js';
|
|
17
18
|
import { withTimeoutMs } from '../runtime.js';
|
|
18
19
|
|
|
19
20
|
export const __test__ = {
|
|
@@ -22,5 +23,9 @@ export const __test__ = {
|
|
|
22
23
|
diffTabIndexes,
|
|
23
24
|
appendLimited,
|
|
24
25
|
buildMcpArgs,
|
|
26
|
+
buildMcpLaunchSpec,
|
|
27
|
+
findMcpServerPath,
|
|
28
|
+
resetMcpServerPathCache,
|
|
29
|
+
setMcpDiscoveryTestHooks,
|
|
25
30
|
withTimeoutMs,
|
|
26
31
|
};
|
package/src/browser/mcp.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from '../runtime.js';
|
|
|
9
9
|
import { PKG_VERSION } from '../version.js';
|
|
10
10
|
import { Page } from './page.js';
|
|
11
11
|
import { getTokenFingerprint, formatBrowserConnectError, inferConnectFailureKind } from './errors.js';
|
|
12
|
-
import { findMcpServerPath,
|
|
12
|
+
import { findMcpServerPath, buildMcpLaunchSpec, resolveCdpEndpoint } from './discover.js';
|
|
13
13
|
import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
|
14
14
|
|
|
15
15
|
const STDERR_BUFFER_LIMIT = 16 * 1024;
|
|
@@ -105,7 +105,6 @@ export class PlaywrightMCP {
|
|
|
105
105
|
if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
|
|
106
106
|
|
|
107
107
|
const mcpPath = findMcpServerPath();
|
|
108
|
-
if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp');
|
|
109
108
|
|
|
110
109
|
PlaywrightMCP._registerGlobalCleanup();
|
|
111
110
|
PlaywrightMCP._activeInsts.add(this);
|
|
@@ -115,7 +114,8 @@ export class PlaywrightMCP {
|
|
|
115
114
|
return new Promise<Page>((resolve, reject) => {
|
|
116
115
|
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
|
|
117
116
|
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
|
|
118
|
-
const
|
|
117
|
+
const { endpoint: cdpEndpoint, requestedCdp } = resolveCdpEndpoint();
|
|
118
|
+
const useExtension = !requestedCdp;
|
|
119
119
|
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
|
120
120
|
const tokenFingerprint = getTokenFingerprint(extensionToken);
|
|
121
121
|
let stderrBuffer = '';
|
|
@@ -151,20 +151,25 @@ export class PlaywrightMCP {
|
|
|
151
151
|
settleError(inferConnectFailureKind({
|
|
152
152
|
hasExtensionToken: !!extensionToken,
|
|
153
153
|
stderr: stderrBuffer,
|
|
154
|
+
isCdpMode: requestedCdp,
|
|
154
155
|
}));
|
|
155
156
|
}, timeout * 1000);
|
|
156
157
|
|
|
157
|
-
const
|
|
158
|
+
const launchSpec = buildMcpLaunchSpec({
|
|
158
159
|
mcpPath,
|
|
159
160
|
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
|
|
161
|
+
cdpEndpoint,
|
|
160
162
|
});
|
|
161
163
|
if (process.env.OPENCLI_VERBOSE) {
|
|
162
|
-
console.error(`[opencli] Mode: ${useExtension ? 'extension' : 'standalone'}`);
|
|
164
|
+
console.error(`[opencli] Mode: ${requestedCdp ? 'CDP' : useExtension ? 'extension' : 'standalone'}`);
|
|
163
165
|
if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
|
|
166
|
+
if (launchSpec.usedNpxFallback) {
|
|
167
|
+
console.error('[opencli] Playwright MCP not found locally; bootstrapping via npx @playwright/mcp@latest');
|
|
168
|
+
}
|
|
164
169
|
}
|
|
165
|
-
debugLog(`Spawning
|
|
170
|
+
debugLog(`Spawning ${launchSpec.command} ${launchSpec.args.join(' ')}`);
|
|
166
171
|
|
|
167
|
-
this._proc = spawn(
|
|
172
|
+
this._proc = spawn(launchSpec.command, launchSpec.args, {
|
|
168
173
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
169
174
|
env: { ...process.env },
|
|
170
175
|
});
|
|
@@ -216,6 +221,7 @@ export class PlaywrightMCP {
|
|
|
216
221
|
hasExtensionToken: !!extensionToken,
|
|
217
222
|
stderr: stderrBuffer,
|
|
218
223
|
exited: true,
|
|
224
|
+
isCdpMode: requestedCdp,
|
|
219
225
|
}), { exitCode: code });
|
|
220
226
|
}
|
|
221
227
|
});
|
|
@@ -233,6 +239,7 @@ export class PlaywrightMCP {
|
|
|
233
239
|
hasExtensionToken: !!extensionToken,
|
|
234
240
|
stderr: stderrBuffer,
|
|
235
241
|
rawMessage: `MCP init failed: ${resp.error.message}`,
|
|
242
|
+
isCdpMode: requestedCdp,
|
|
236
243
|
}), { rawMessage: resp.error.message });
|
|
237
244
|
return;
|
|
238
245
|
}
|
package/src/browser/page.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { formatSnapshot } from '../snapshotFormatter.js';
|
|
|
6
6
|
import { normalizeEvaluateSource } from '../pipeline/template.js';
|
|
7
7
|
import { generateInterceptorJs, generateReadInterceptedJs } from '../interceptor.js';
|
|
8
8
|
import type { IPage } from '../types.js';
|
|
9
|
+
import { BrowserConnectError } from '../errors.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
|
|
@@ -18,10 +19,28 @@ export class Page implements IPage {
|
|
|
18
19
|
if (resp.error) throw new Error(`page.${method}: ${(resp.error as any).message ?? JSON.stringify(resp.error)}`);
|
|
19
20
|
// Extract text content from MCP result
|
|
20
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
|
+
|
|
21
31
|
if (result?.content) {
|
|
22
32
|
const textParts = result.content.filter((c: any) => c.type === 'text');
|
|
23
|
-
if (textParts.length
|
|
24
|
-
let text = textParts[
|
|
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
|
+
}
|
|
43
|
+
|
|
25
44
|
// MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```"
|
|
26
45
|
// Strip the "### Ran Playwright code" suffix to get clean JSON
|
|
27
46
|
const codeMarker = text.indexOf('### Ran Playwright code');
|
package/src/browser.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { afterEach, describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { PlaywrightMCP, __test__ } from './browser/index.js';
|
|
3
3
|
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
__test__.resetMcpServerPathCache();
|
|
6
|
+
__test__.setMcpDiscoveryTestHooks();
|
|
7
|
+
delete process.env.OPENCLI_MCP_SERVER_PATH;
|
|
8
|
+
});
|
|
9
|
+
|
|
4
10
|
describe('browser helpers', () => {
|
|
5
11
|
it('creates JSON-RPC requests with unique ids', () => {
|
|
6
12
|
const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' });
|
|
@@ -106,9 +112,142 @@ describe('browser helpers', () => {
|
|
|
106
112
|
}
|
|
107
113
|
});
|
|
108
114
|
|
|
115
|
+
it('builds a direct node launch spec when a local MCP path is available', () => {
|
|
116
|
+
const savedCI = process.env.CI;
|
|
117
|
+
delete process.env.CI;
|
|
118
|
+
try {
|
|
119
|
+
expect(__test__.buildMcpLaunchSpec({
|
|
120
|
+
mcpPath: '/tmp/cli.js',
|
|
121
|
+
executablePath: '/usr/bin/google-chrome',
|
|
122
|
+
})).toEqual({
|
|
123
|
+
command: 'node',
|
|
124
|
+
args: ['/tmp/cli.js', '--extension', '--executable-path', '/usr/bin/google-chrome'],
|
|
125
|
+
usedNpxFallback: false,
|
|
126
|
+
});
|
|
127
|
+
} finally {
|
|
128
|
+
if (savedCI !== undefined) {
|
|
129
|
+
process.env.CI = savedCI;
|
|
130
|
+
} else {
|
|
131
|
+
delete process.env.CI;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('falls back to npx bootstrap when no MCP path is available', () => {
|
|
137
|
+
const savedCI = process.env.CI;
|
|
138
|
+
delete process.env.CI;
|
|
139
|
+
try {
|
|
140
|
+
expect(__test__.buildMcpLaunchSpec({
|
|
141
|
+
mcpPath: null,
|
|
142
|
+
})).toEqual({
|
|
143
|
+
command: 'npx',
|
|
144
|
+
args: ['-y', '@playwright/mcp@latest', '--extension'],
|
|
145
|
+
usedNpxFallback: true,
|
|
146
|
+
});
|
|
147
|
+
} finally {
|
|
148
|
+
if (savedCI !== undefined) {
|
|
149
|
+
process.env.CI = savedCI;
|
|
150
|
+
} else {
|
|
151
|
+
delete process.env.CI;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
109
156
|
it('times out slow promises', async () => {
|
|
110
157
|
await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
|
|
111
158
|
});
|
|
159
|
+
|
|
160
|
+
it('prefers OPENCLI_MCP_SERVER_PATH over discovered locations', () => {
|
|
161
|
+
process.env.OPENCLI_MCP_SERVER_PATH = '/env/mcp/cli.js';
|
|
162
|
+
const existsSync = vi.fn((candidate: any) => candidate === '/env/mcp/cli.js');
|
|
163
|
+
const execSync = vi.fn();
|
|
164
|
+
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
|
|
165
|
+
|
|
166
|
+
expect(__test__.findMcpServerPath()).toBe('/env/mcp/cli.js');
|
|
167
|
+
expect(execSync).not.toHaveBeenCalled();
|
|
168
|
+
expect(existsSync).toHaveBeenCalledWith('/env/mcp/cli.js');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('discovers global @playwright/mcp from the current Node runtime prefix', () => {
|
|
172
|
+
const originalExecPath = process.execPath;
|
|
173
|
+
const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
|
|
174
|
+
const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js';
|
|
175
|
+
Object.defineProperty(process, 'execPath', {
|
|
176
|
+
value: runtimeExecPath,
|
|
177
|
+
configurable: true,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const existsSync = vi.fn((candidate: any) => candidate === runtimeGlobalMcp);
|
|
181
|
+
const execSync = vi.fn();
|
|
182
|
+
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
expect(__test__.findMcpServerPath()).toBe(runtimeGlobalMcp);
|
|
186
|
+
expect(execSync).not.toHaveBeenCalled();
|
|
187
|
+
expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp);
|
|
188
|
+
} finally {
|
|
189
|
+
Object.defineProperty(process, 'execPath', {
|
|
190
|
+
value: originalExecPath,
|
|
191
|
+
configurable: true,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('falls back to npm root -g when runtime prefix lookup misses', () => {
|
|
197
|
+
const originalExecPath = process.execPath;
|
|
198
|
+
const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
|
|
199
|
+
const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js';
|
|
200
|
+
const npmRootGlobal = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules';
|
|
201
|
+
const npmGlobalMcp = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules/@playwright/mcp/cli.js';
|
|
202
|
+
Object.defineProperty(process, 'execPath', {
|
|
203
|
+
value: runtimeExecPath,
|
|
204
|
+
configurable: true,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const existsSync = vi.fn((candidate: any) => candidate === npmGlobalMcp);
|
|
208
|
+
const execSync = vi.fn((command: string) => {
|
|
209
|
+
if (String(command).includes('npm root -g')) return `${npmRootGlobal}\n` as any;
|
|
210
|
+
throw new Error(`unexpected command: ${String(command)}`);
|
|
211
|
+
});
|
|
212
|
+
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
expect(__test__.findMcpServerPath()).toBe(npmGlobalMcp);
|
|
216
|
+
expect(execSync).toHaveBeenCalledOnce();
|
|
217
|
+
expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp);
|
|
218
|
+
expect(existsSync).toHaveBeenCalledWith(npmGlobalMcp);
|
|
219
|
+
} finally {
|
|
220
|
+
Object.defineProperty(process, 'execPath', {
|
|
221
|
+
value: originalExecPath,
|
|
222
|
+
configurable: true,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('returns null when new global discovery paths are unavailable', () => {
|
|
228
|
+
const originalExecPath = process.execPath;
|
|
229
|
+
const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node';
|
|
230
|
+
Object.defineProperty(process, 'execPath', {
|
|
231
|
+
value: runtimeExecPath,
|
|
232
|
+
configurable: true,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const existsSync = vi.fn(() => false);
|
|
236
|
+
const execSync = vi.fn((command: string) => {
|
|
237
|
+
if (String(command).includes('npm root -g')) return '/missing/global/node_modules\n' as any;
|
|
238
|
+
throw new Error(`missing command: ${String(command)}`);
|
|
239
|
+
});
|
|
240
|
+
__test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any });
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
expect(__test__.findMcpServerPath()).toBeNull();
|
|
244
|
+
} finally {
|
|
245
|
+
Object.defineProperty(process, 'execPath', {
|
|
246
|
+
value: originalExecPath,
|
|
247
|
+
configurable: true,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
});
|
|
112
251
|
});
|
|
113
252
|
|
|
114
253
|
describe('PlaywrightMCP state', () => {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barchart unusual options activity (options flow).
|
|
3
|
+
* Shows high volume/OI ratio trades that may indicate institutional activity.
|
|
4
|
+
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
|
|
5
|
+
*/
|
|
6
|
+
import { cli, Strategy } from '../../registry.js';
|
|
7
|
+
|
|
8
|
+
cli({
|
|
9
|
+
site: 'barchart',
|
|
10
|
+
name: 'flow',
|
|
11
|
+
description: 'Barchart unusual options activity / options flow',
|
|
12
|
+
domain: 'www.barchart.com',
|
|
13
|
+
strategy: Strategy.COOKIE,
|
|
14
|
+
args: [
|
|
15
|
+
{ name: 'type', type: 'str', default: 'all', help: 'Filter: all, call, or put', choices: ['all', 'call', 'put'] },
|
|
16
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
|
|
17
|
+
],
|
|
18
|
+
columns: [
|
|
19
|
+
'symbol', 'type', 'strike', 'expiration', 'last',
|
|
20
|
+
'volume', 'openInterest', 'volOiRatio', 'iv',
|
|
21
|
+
],
|
|
22
|
+
func: async (page, kwargs) => {
|
|
23
|
+
const optionType = kwargs.type || 'all';
|
|
24
|
+
const limit = kwargs.limit ?? 20;
|
|
25
|
+
|
|
26
|
+
await page.goto('https://www.barchart.com/options/unusual-activity/stocks');
|
|
27
|
+
await page.wait(5);
|
|
28
|
+
|
|
29
|
+
const data = await page.evaluate(`
|
|
30
|
+
(async () => {
|
|
31
|
+
const limit = ${limit};
|
|
32
|
+
const typeFilter = '${optionType}'.toLowerCase();
|
|
33
|
+
|
|
34
|
+
// Wait for CSRF token to appear (Angular may inject it after initial render)
|
|
35
|
+
let csrf = '';
|
|
36
|
+
for (let i = 0; i < 10; i++) {
|
|
37
|
+
csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
38
|
+
if (csrf) break;
|
|
39
|
+
await new Promise(r => setTimeout(r, 500));
|
|
40
|
+
}
|
|
41
|
+
if (!csrf) return { error: 'no-csrf' };
|
|
42
|
+
|
|
43
|
+
const headers = { 'X-CSRF-TOKEN': csrf };
|
|
44
|
+
const fields = [
|
|
45
|
+
'baseSymbol','strikePrice','expirationDate','optionType',
|
|
46
|
+
'lastPrice','volume','openInterest','volumeOpenInterestRatio','volatility',
|
|
47
|
+
].join(',');
|
|
48
|
+
|
|
49
|
+
// Fetch extra rows when filtering by type since server-side filter doesn't work
|
|
50
|
+
const fetchLimit = typeFilter !== 'all' ? limit * 3 : limit;
|
|
51
|
+
|
|
52
|
+
// Try unusual_activity first, fall back to mostActive (unusual_activity is
|
|
53
|
+
// empty outside market hours)
|
|
54
|
+
const lists = [
|
|
55
|
+
'options.unusual_activity.stocks.us',
|
|
56
|
+
'options.mostActive.us',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
for (const list of lists) {
|
|
60
|
+
try {
|
|
61
|
+
const url = '/proxies/core-api/v1/options/get?list=' + list
|
|
62
|
+
+ '&fields=' + fields
|
|
63
|
+
+ '&orderBy=volumeOpenInterestRatio&orderDir=desc'
|
|
64
|
+
+ '&raw=1&limit=' + fetchLimit;
|
|
65
|
+
|
|
66
|
+
const resp = await fetch(url, { credentials: 'include', headers });
|
|
67
|
+
if (!resp.ok) continue;
|
|
68
|
+
const d = await resp.json();
|
|
69
|
+
let items = d?.data || [];
|
|
70
|
+
if (items.length === 0) continue;
|
|
71
|
+
|
|
72
|
+
// Apply client-side type filter
|
|
73
|
+
if (typeFilter !== 'all') {
|
|
74
|
+
items = items.filter(i => {
|
|
75
|
+
const t = ((i.raw || i).optionType || '').toLowerCase();
|
|
76
|
+
return t === typeFilter;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return items.slice(0, limit).map(i => {
|
|
80
|
+
const r = i.raw || i;
|
|
81
|
+
return {
|
|
82
|
+
symbol: r.baseSymbol || r.symbol,
|
|
83
|
+
type: r.optionType,
|
|
84
|
+
strike: r.strikePrice,
|
|
85
|
+
expiration: r.expirationDate,
|
|
86
|
+
last: r.lastPrice,
|
|
87
|
+
volume: r.volume,
|
|
88
|
+
openInterest: r.openInterest,
|
|
89
|
+
volOiRatio: r.volumeOpenInterestRatio,
|
|
90
|
+
iv: r.volatility,
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
} catch(e) {}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return [];
|
|
97
|
+
})()
|
|
98
|
+
`);
|
|
99
|
+
|
|
100
|
+
if (!data) return [];
|
|
101
|
+
|
|
102
|
+
if (data.error === 'no-csrf') {
|
|
103
|
+
throw new Error('Could not extract CSRF token from barchart.com. Make sure you are logged in.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!Array.isArray(data)) return [];
|
|
107
|
+
|
|
108
|
+
return data.slice(0, limit).map(r => ({
|
|
109
|
+
symbol: r.symbol || '',
|
|
110
|
+
type: r.type || '',
|
|
111
|
+
strike: r.strike,
|
|
112
|
+
expiration: r.expiration ?? null,
|
|
113
|
+
last: r.last != null ? Number(Number(r.last).toFixed(2)) : null,
|
|
114
|
+
volume: r.volume,
|
|
115
|
+
openInterest: r.openInterest,
|
|
116
|
+
volOiRatio: r.volOiRatio != null ? Number(Number(r.volOiRatio).toFixed(2)) : null,
|
|
117
|
+
iv: r.iv != null ? Number(Number(r.iv).toFixed(2)) + '%' : null,
|
|
118
|
+
}));
|
|
119
|
+
},
|
|
120
|
+
});
|