@jackwener/opencli 0.5.1 → 0.6.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/README.md +3 -2
- package/README.zh-CN.md +4 -3
- package/SKILL.md +7 -4
- package/dist/browser.d.ts +7 -3
- package/dist/browser.js +25 -92
- package/dist/browser.test.js +18 -1
- package/dist/cascade.d.ts +1 -1
- package/dist/cascade.js +42 -75
- package/dist/cli-manifest.json +80 -0
- package/dist/clis/coupang/add-to-cart.d.ts +1 -0
- package/dist/clis/coupang/add-to-cart.js +141 -0
- package/dist/clis/coupang/search.d.ts +1 -0
- package/dist/clis/coupang/search.js +453 -0
- package/dist/constants.d.ts +13 -0
- package/dist/constants.js +30 -0
- package/dist/coupang.d.ts +24 -0
- package/dist/coupang.js +262 -0
- package/dist/coupang.test.d.ts +1 -0
- package/dist/coupang.test.js +62 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +226 -25
- package/dist/doctor.test.js +13 -6
- package/dist/engine.js +3 -3
- package/dist/engine.test.d.ts +4 -0
- package/dist/engine.test.js +67 -0
- package/dist/explore.js +1 -15
- package/dist/interceptor.d.ts +42 -0
- package/dist/interceptor.js +138 -0
- package/dist/main.js +8 -4
- package/dist/output.js +0 -5
- package/dist/pipeline/steps/intercept.js +4 -54
- package/dist/pipeline/steps/tap.js +11 -51
- package/dist/registry.d.ts +3 -1
- package/dist/registry.test.d.ts +4 -0
- package/dist/registry.test.js +90 -0
- package/dist/runtime.d.ts +15 -1
- package/dist/runtime.js +11 -6
- package/dist/setup.d.ts +4 -0
- package/dist/setup.js +145 -0
- package/dist/synthesize.js +5 -5
- package/dist/tui.d.ts +22 -0
- package/dist/tui.js +139 -0
- package/dist/validate.js +21 -0
- package/dist/verify.d.ts +7 -0
- package/dist/verify.js +7 -1
- package/dist/version.d.ts +4 -0
- package/dist/version.js +16 -0
- package/package.json +1 -1
- package/src/browser.test.ts +20 -1
- package/src/browser.ts +25 -87
- package/src/cascade.ts +47 -75
- package/src/clis/coupang/add-to-cart.ts +149 -0
- package/src/clis/coupang/search.ts +466 -0
- package/src/constants.ts +35 -0
- package/src/coupang.test.ts +78 -0
- package/src/coupang.ts +302 -0
- package/src/doctor.test.ts +15 -6
- package/src/doctor.ts +221 -25
- package/src/engine.test.ts +77 -0
- package/src/engine.ts +5 -5
- package/src/explore.ts +2 -15
- package/src/interceptor.ts +153 -0
- package/src/main.ts +9 -5
- package/src/output.ts +0 -4
- package/src/pipeline/executor.ts +15 -15
- package/src/pipeline/steps/intercept.ts +4 -55
- package/src/pipeline/steps/tap.ts +12 -51
- package/src/registry.test.ts +106 -0
- package/src/registry.ts +4 -1
- package/src/runtime.ts +22 -8
- package/src/setup.ts +169 -0
- package/src/synthesize.ts +5 -5
- package/src/tui.ts +171 -0
- package/src/validate.ts +22 -0
- package/src/verify.ts +10 -1
- package/src/version.ts +18 -0
package/dist/synthesize.js
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
import * as fs from 'node:fs';
|
|
6
6
|
import * as path from 'node:path';
|
|
7
7
|
import yaml from 'js-yaml';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const SEARCH_PARAM_NAMES =
|
|
11
|
-
const LIMIT_PARAM_NAMES =
|
|
12
|
-
const PAGE_PARAM_NAMES =
|
|
8
|
+
import { VOLATILE_PARAMS, SEARCH_PARAMS, LIMIT_PARAMS, PAGINATION_PARAMS } from './constants.js';
|
|
9
|
+
/** Renamed aliases for backward compatibility with local references */
|
|
10
|
+
const SEARCH_PARAM_NAMES = SEARCH_PARAMS;
|
|
11
|
+
const LIMIT_PARAM_NAMES = LIMIT_PARAMS;
|
|
12
|
+
const PAGE_PARAM_NAMES = PAGINATION_PARAMS;
|
|
13
13
|
export function synthesizeFromExplore(target, opts = {}) {
|
|
14
14
|
const exploreDir = resolveExploreDir(target);
|
|
15
15
|
const bundle = loadExploreBundle(exploreDir);
|
package/dist/tui.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface CheckboxItem {
|
|
2
|
+
label: string;
|
|
3
|
+
value: string;
|
|
4
|
+
checked: boolean;
|
|
5
|
+
/** Optional status to display after the label */
|
|
6
|
+
status?: string;
|
|
7
|
+
statusColor?: 'green' | 'yellow' | 'red' | 'dim';
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Interactive multi-select checkbox prompt.
|
|
11
|
+
*
|
|
12
|
+
* Controls:
|
|
13
|
+
* ↑/↓ or j/k — navigate
|
|
14
|
+
* Space — toggle selection
|
|
15
|
+
* a — toggle all
|
|
16
|
+
* Enter — confirm
|
|
17
|
+
* q/Esc — cancel (returns empty)
|
|
18
|
+
*/
|
|
19
|
+
export declare function checkboxPrompt(items: CheckboxItem[], opts?: {
|
|
20
|
+
title?: string;
|
|
21
|
+
hint?: string;
|
|
22
|
+
}): Promise<string[]>;
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tui.ts — Zero-dependency interactive TUI components
|
|
3
|
+
*
|
|
4
|
+
* Uses raw stdin mode + ANSI escape codes for interactive prompts.
|
|
5
|
+
*/
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
/**
|
|
8
|
+
* Interactive multi-select checkbox prompt.
|
|
9
|
+
*
|
|
10
|
+
* Controls:
|
|
11
|
+
* ↑/↓ or j/k — navigate
|
|
12
|
+
* Space — toggle selection
|
|
13
|
+
* a — toggle all
|
|
14
|
+
* Enter — confirm
|
|
15
|
+
* q/Esc — cancel (returns empty)
|
|
16
|
+
*/
|
|
17
|
+
export async function checkboxPrompt(items, opts = {}) {
|
|
18
|
+
if (items.length === 0)
|
|
19
|
+
return [];
|
|
20
|
+
const { stdin, stdout } = process;
|
|
21
|
+
if (!stdin.isTTY) {
|
|
22
|
+
// Non-interactive: return all checked items
|
|
23
|
+
return items.filter(i => i.checked).map(i => i.value);
|
|
24
|
+
}
|
|
25
|
+
let cursor = 0;
|
|
26
|
+
const state = items.map(i => ({ ...i }));
|
|
27
|
+
function colorStatus(status, color) {
|
|
28
|
+
if (!status)
|
|
29
|
+
return '';
|
|
30
|
+
switch (color) {
|
|
31
|
+
case 'green': return chalk.green(status);
|
|
32
|
+
case 'yellow': return chalk.yellow(status);
|
|
33
|
+
case 'red': return chalk.red(status);
|
|
34
|
+
case 'dim': return chalk.dim(status);
|
|
35
|
+
default: return chalk.dim(status);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function render() {
|
|
39
|
+
// Move cursor to start and clear
|
|
40
|
+
let out = '';
|
|
41
|
+
if (opts.title) {
|
|
42
|
+
out += `\n${chalk.bold(opts.title)}\n\n`;
|
|
43
|
+
}
|
|
44
|
+
for (let i = 0; i < state.length; i++) {
|
|
45
|
+
const item = state[i];
|
|
46
|
+
const pointer = i === cursor ? chalk.cyan('❯') : ' ';
|
|
47
|
+
const checkbox = item.checked ? chalk.green('◉') : chalk.dim('○');
|
|
48
|
+
const label = i === cursor ? chalk.bold(item.label) : item.label;
|
|
49
|
+
const status = colorStatus(item.status, item.statusColor);
|
|
50
|
+
out += ` ${pointer} ${checkbox} ${label}${status ? ` ${status}` : ''}\n`;
|
|
51
|
+
}
|
|
52
|
+
out += `\n ${chalk.dim('↑↓ navigate · Space toggle · a all · Enter confirm · q cancel')}\n`;
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const wasRaw = stdin.isRaw;
|
|
57
|
+
stdin.setRawMode(true);
|
|
58
|
+
stdin.resume();
|
|
59
|
+
stdout.write('\x1b[?25l'); // Hide cursor
|
|
60
|
+
let firstDraw = true;
|
|
61
|
+
function draw() {
|
|
62
|
+
// Clear previous render (skip on first draw)
|
|
63
|
+
if (!firstDraw) {
|
|
64
|
+
const lines = render().split('\n').length;
|
|
65
|
+
stdout.write(`\x1b[${lines}A\x1b[J`);
|
|
66
|
+
}
|
|
67
|
+
firstDraw = false;
|
|
68
|
+
stdout.write(render());
|
|
69
|
+
}
|
|
70
|
+
function cleanup() {
|
|
71
|
+
stdin.setRawMode(wasRaw ?? false);
|
|
72
|
+
stdin.pause();
|
|
73
|
+
stdin.removeListener('data', onData);
|
|
74
|
+
// Clear the TUI and restore cursor
|
|
75
|
+
const lines = render().split('\n').length;
|
|
76
|
+
stdout.write(`\x1b[${lines}A\x1b[J`);
|
|
77
|
+
stdout.write('\x1b[?25h'); // Show cursor
|
|
78
|
+
}
|
|
79
|
+
function onData(data) {
|
|
80
|
+
const key = data.toString();
|
|
81
|
+
// Arrow up / k
|
|
82
|
+
if (key === '\x1b[A' || key === 'k') {
|
|
83
|
+
cursor = (cursor - 1 + state.length) % state.length;
|
|
84
|
+
draw();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Arrow down / j
|
|
88
|
+
if (key === '\x1b[B' || key === 'j') {
|
|
89
|
+
cursor = (cursor + 1) % state.length;
|
|
90
|
+
draw();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Space — toggle
|
|
94
|
+
if (key === ' ') {
|
|
95
|
+
state[cursor].checked = !state[cursor].checked;
|
|
96
|
+
draw();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Tab — toggle and move down
|
|
100
|
+
if (key === '\t') {
|
|
101
|
+
state[cursor].checked = !state[cursor].checked;
|
|
102
|
+
cursor = (cursor + 1) % state.length;
|
|
103
|
+
draw();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// 'a' — toggle all
|
|
107
|
+
if (key === 'a') {
|
|
108
|
+
const allChecked = state.every(i => i.checked);
|
|
109
|
+
for (const item of state)
|
|
110
|
+
item.checked = !allChecked;
|
|
111
|
+
draw();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Enter — confirm
|
|
115
|
+
if (key === '\r' || key === '\n') {
|
|
116
|
+
cleanup();
|
|
117
|
+
const selected = state.filter(i => i.checked).map(i => i.value);
|
|
118
|
+
// Show summary
|
|
119
|
+
stdout.write(` ${chalk.green('✓')} ${chalk.bold(`${selected.length} file(s) selected`)}\n\n`);
|
|
120
|
+
resolve(selected);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// q / Esc — cancel
|
|
124
|
+
if (key === 'q' || key === '\x1b') {
|
|
125
|
+
cleanup();
|
|
126
|
+
stdout.write(` ${chalk.yellow('✗')} ${chalk.dim('Cancelled')}\n\n`);
|
|
127
|
+
resolve([]);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Ctrl+C — exit process
|
|
131
|
+
if (key === '\x03') {
|
|
132
|
+
cleanup();
|
|
133
|
+
process.exit(130);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
stdin.on('data', onData);
|
|
137
|
+
draw();
|
|
138
|
+
});
|
|
139
|
+
}
|
package/dist/validate.js
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import yaml from 'js-yaml';
|
|
5
|
+
/** All recognized pipeline step names */
|
|
6
|
+
const KNOWN_STEP_NAMES = new Set([
|
|
7
|
+
'navigate', 'click', 'type', 'wait', 'press', 'snapshot', 'scroll',
|
|
8
|
+
'fetch', 'evaluate',
|
|
9
|
+
'select', 'map', 'filter', 'sort', 'limit',
|
|
10
|
+
'intercept', 'tap',
|
|
11
|
+
]);
|
|
5
12
|
export function validateClisWithTarget(dirs, target) {
|
|
6
13
|
const results = [];
|
|
7
14
|
let errors = 0;
|
|
@@ -52,6 +59,20 @@ function validateYamlFile(filePath) {
|
|
|
52
59
|
errors.push('"columns" must be an array');
|
|
53
60
|
if (def.args && typeof def.args !== 'object')
|
|
54
61
|
errors.push('"args" must be an object');
|
|
62
|
+
// Validate pipeline step names (catch typos like 'navaigate')
|
|
63
|
+
if (Array.isArray(def.pipeline)) {
|
|
64
|
+
for (let i = 0; i < def.pipeline.length; i++) {
|
|
65
|
+
const step = def.pipeline[i];
|
|
66
|
+
if (step && typeof step === 'object') {
|
|
67
|
+
const stepKeys = Object.keys(step);
|
|
68
|
+
for (const key of stepKeys) {
|
|
69
|
+
if (!KNOWN_STEP_NAMES.has(key)) {
|
|
70
|
+
warnings.push(`Pipeline step ${i}: unknown step name "${key}" (did you mean one of: ${[...KNOWN_STEP_NAMES].join(', ')}?)`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
55
76
|
}
|
|
56
77
|
catch (e) {
|
|
57
78
|
errors.push(`YAML parse error: ${e.message}`);
|
package/dist/verify.d.ts
CHANGED
|
@@ -1,2 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verification: runs validation and optional smoke test.
|
|
3
|
+
*
|
|
4
|
+
* The smoke test is intentionally kept as a stub — full browser-based
|
|
5
|
+
* smoke testing requires a running browser session and is better suited
|
|
6
|
+
* to the `opencli test` command or CI pipelines.
|
|
7
|
+
*/
|
|
1
8
|
export declare function verifyClis(opts: any): Promise<any>;
|
|
2
9
|
export declare function renderVerifyReport(report: any): string;
|
package/dist/verify.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Verification: runs validation and optional smoke test.
|
|
3
|
+
*
|
|
4
|
+
* The smoke test is intentionally kept as a stub — full browser-based
|
|
5
|
+
* smoke testing requires a running browser session and is better suited
|
|
6
|
+
* to the `opencli test` command or CI pipelines.
|
|
7
|
+
*/
|
|
2
8
|
import { validateClisWithTarget, renderValidationReport } from './validate.js';
|
|
3
9
|
export async function verifyClis(opts) {
|
|
4
10
|
const report = validateClisWithTarget([opts.builtinClis, opts.userClis], opts.target);
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for package version.
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const pkgJsonPath = path.resolve(__dirname, '..', 'package.json');
|
|
9
|
+
export const PKG_VERSION = (() => {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).version;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return '0.0.0';
|
|
15
|
+
}
|
|
16
|
+
})();
|
package/package.json
CHANGED
package/src/browser.test.ts
CHANGED
|
@@ -49,8 +49,27 @@ describe('browser helpers', () => {
|
|
|
49
49
|
expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
it('builds Playwright MCP args with kebab-case executable path', () => {
|
|
53
|
+
expect(__test__.buildMcpArgs({
|
|
54
|
+
mcpPath: '/tmp/cli.js',
|
|
55
|
+
executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
|
56
|
+
})).toEqual([
|
|
57
|
+
'/tmp/cli.js',
|
|
58
|
+
'--extension',
|
|
59
|
+
'--executable-path',
|
|
60
|
+
'/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
expect(__test__.buildMcpArgs({
|
|
64
|
+
mcpPath: '/tmp/cli.js',
|
|
65
|
+
})).toEqual([
|
|
66
|
+
'/tmp/cli.js',
|
|
67
|
+
'--extension',
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
52
71
|
it('times out slow promises', async () => {
|
|
53
|
-
await expect(__test__.
|
|
72
|
+
await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
|
|
54
73
|
});
|
|
55
74
|
});
|
|
56
75
|
|
package/src/browser.ts
CHANGED
|
@@ -10,10 +10,10 @@ import * as fs from 'node:fs';
|
|
|
10
10
|
import * as os from 'node:os';
|
|
11
11
|
import * as path from 'node:path';
|
|
12
12
|
import { formatSnapshot } from './snapshotFormatter.js';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
import { PKG_VERSION } from './version.js';
|
|
14
|
+
import { normalizeEvaluateSource } from './pipeline/template.js';
|
|
15
|
+
import { generateInterceptorJs, generateReadInterceptedJs } from './interceptor.js';
|
|
16
|
+
import { withTimeoutMs } from './runtime.js';
|
|
17
17
|
|
|
18
18
|
const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
|
|
19
19
|
const STDERR_BUFFER_LIMIT = 16 * 1024;
|
|
@@ -158,23 +158,10 @@ export class Page implements IPage {
|
|
|
158
158
|
|
|
159
159
|
async evaluate(js: string): Promise<any> {
|
|
160
160
|
// Normalize IIFE format to function format expected by MCP browser_evaluate
|
|
161
|
-
const normalized =
|
|
161
|
+
const normalized = normalizeEvaluateSource(js);
|
|
162
162
|
return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
private normalizeEval(source: string): string {
|
|
166
|
-
const s = source.trim();
|
|
167
|
-
if (!s) return '() => undefined';
|
|
168
|
-
// IIFE: (async () => {...})() → wrap as () => (...)
|
|
169
|
-
if (s.startsWith('(') && s.endsWith(')()')) return `() => (${s})`;
|
|
170
|
-
// Already a function/arrow
|
|
171
|
-
if (/^(async\s+)?\([^)]*\)\s*=>/.test(s)) return s;
|
|
172
|
-
if (/^(async\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=>/.test(s)) return s;
|
|
173
|
-
if (s.startsWith('function ') || s.startsWith('async function ')) return s;
|
|
174
|
-
// Raw expression → wrap
|
|
175
|
-
return `() => (${s})`;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
165
|
async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
|
|
179
166
|
const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
|
|
180
167
|
if (opts.raw) return raw;
|
|
@@ -263,57 +250,15 @@ export class Page implements IPage {
|
|
|
263
250
|
}
|
|
264
251
|
|
|
265
252
|
async installInterceptor(pattern: string): Promise<void> {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (!window.__opencli_patterns.includes('${pattern}')) {
|
|
271
|
-
window.__opencli_patterns.push('${pattern}');
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (!window.__patched_xhr) {
|
|
275
|
-
const checkMatch = (url) => window.__opencli_patterns.some(p => url.includes(p));
|
|
276
|
-
|
|
277
|
-
const XHR = XMLHttpRequest.prototype;
|
|
278
|
-
const open = XHR.open;
|
|
279
|
-
const send = XHR.send;
|
|
280
|
-
XHR.open = function(method, url) {
|
|
281
|
-
this._url = url;
|
|
282
|
-
return open.call(this, method, url, ...Array.prototype.slice.call(arguments, 2));
|
|
283
|
-
};
|
|
284
|
-
XHR.send = function() {
|
|
285
|
-
this.addEventListener('load', function() {
|
|
286
|
-
if (checkMatch(this._url)) {
|
|
287
|
-
try { window.__opencli_xhr.push({url: this._url, data: JSON.parse(this.responseText)}); } catch(e){}
|
|
288
|
-
}
|
|
289
|
-
});
|
|
290
|
-
return send.apply(this, arguments);
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
const origFetch = window.fetch;
|
|
294
|
-
window.fetch = async function(...args) {
|
|
295
|
-
let u = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
|
|
296
|
-
const res = await origFetch.apply(this, args);
|
|
297
|
-
setTimeout(async () => {
|
|
298
|
-
try {
|
|
299
|
-
if (checkMatch(u)) {
|
|
300
|
-
const clone = res.clone();
|
|
301
|
-
const j = await clone.json();
|
|
302
|
-
window.__opencli_xhr.push({url: u, data: j});
|
|
303
|
-
}
|
|
304
|
-
} catch(e) {}
|
|
305
|
-
}, 0);
|
|
306
|
-
return res;
|
|
307
|
-
};
|
|
308
|
-
window.__patched_xhr = true;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
`;
|
|
312
|
-
await this.evaluate(js);
|
|
253
|
+
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
|
|
254
|
+
arrayName: '__opencli_xhr',
|
|
255
|
+
patchGuard: '__opencli_interceptor_patched',
|
|
256
|
+
}));
|
|
313
257
|
}
|
|
314
258
|
|
|
315
259
|
async getInterceptedRequests(): Promise<any[]> {
|
|
316
|
-
|
|
260
|
+
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
|
261
|
+
return result || [];
|
|
317
262
|
}
|
|
318
263
|
}
|
|
319
264
|
|
|
@@ -442,13 +387,13 @@ export class PlaywrightMCP {
|
|
|
442
387
|
}));
|
|
443
388
|
}, timeout * 1000);
|
|
444
389
|
|
|
445
|
-
const mcpArgs
|
|
390
|
+
const mcpArgs = buildMcpArgs({
|
|
391
|
+
mcpPath,
|
|
392
|
+
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
|
|
393
|
+
});
|
|
446
394
|
if (process.env.OPENCLI_VERBOSE) {
|
|
447
395
|
console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
|
|
448
396
|
}
|
|
449
|
-
if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) {
|
|
450
|
-
mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH);
|
|
451
|
-
}
|
|
452
397
|
debugLog(`Spawning node ${mcpArgs.join(' ')}`);
|
|
453
398
|
|
|
454
399
|
this._proc = spawn('node', mcpArgs, {
|
|
@@ -530,7 +475,7 @@ export class PlaywrightMCP {
|
|
|
530
475
|
|
|
531
476
|
// Use tabs as a readiness probe and for tab cleanup bookkeeping.
|
|
532
477
|
debugLog('Fetching initial tabs count...');
|
|
533
|
-
|
|
478
|
+
withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
|
|
534
479
|
debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
|
|
535
480
|
this._initialTabIdentities = extractTabIdentities(tabs);
|
|
536
481
|
settleSuccess(page);
|
|
@@ -555,7 +500,7 @@ export class PlaywrightMCP {
|
|
|
555
500
|
// Extension mode opens bridge/session tabs that we can clean up best-effort.
|
|
556
501
|
if (this._page && this._proc && !this._proc.killed) {
|
|
557
502
|
try {
|
|
558
|
-
const tabs = await
|
|
503
|
+
const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
|
|
559
504
|
const tabEntries = extractTabEntries(tabs);
|
|
560
505
|
const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
|
|
561
506
|
for (const index of tabsToClose) {
|
|
@@ -664,20 +609,12 @@ function appendLimited(current: string, chunk: string, limit: number): string {
|
|
|
664
609
|
return next.slice(-limit);
|
|
665
610
|
}
|
|
666
611
|
|
|
667
|
-
function
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
resolve(value);
|
|
674
|
-
},
|
|
675
|
-
(error) => {
|
|
676
|
-
clearTimeout(timer);
|
|
677
|
-
reject(error);
|
|
678
|
-
},
|
|
679
|
-
);
|
|
680
|
-
});
|
|
612
|
+
function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] {
|
|
613
|
+
const args = [input.mcpPath, '--extension'];
|
|
614
|
+
if (input.executablePath) {
|
|
615
|
+
args.push('--executable-path', input.executablePath);
|
|
616
|
+
}
|
|
617
|
+
return args;
|
|
681
618
|
}
|
|
682
619
|
|
|
683
620
|
export const __test__ = {
|
|
@@ -685,7 +622,8 @@ export const __test__ = {
|
|
|
685
622
|
extractTabEntries,
|
|
686
623
|
diffTabIndexes,
|
|
687
624
|
appendLimited,
|
|
688
|
-
|
|
625
|
+
buildMcpArgs,
|
|
626
|
+
withTimeoutMs,
|
|
689
627
|
};
|
|
690
628
|
|
|
691
629
|
function findMcpServerPath(): string | null {
|
package/src/cascade.ts
CHANGED
|
@@ -37,6 +37,49 @@ interface CascadeResult {
|
|
|
37
37
|
confidence: number;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Build the JavaScript source for a fetch probe.
|
|
42
|
+
* Shared logic for PUBLIC, COOKIE, and HEADER strategies.
|
|
43
|
+
*/
|
|
44
|
+
function buildFetchProbeJs(url: string, opts: {
|
|
45
|
+
credentials?: boolean;
|
|
46
|
+
extractCsrf?: boolean;
|
|
47
|
+
}): string {
|
|
48
|
+
const credentialsLine = opts.credentials ? `credentials: 'include',` : '';
|
|
49
|
+
const headerSetup = opts.extractCsrf
|
|
50
|
+
? `
|
|
51
|
+
const cookies = document.cookie.split(';').map(c => c.trim());
|
|
52
|
+
const csrf = cookies.find(c => c.startsWith('ct0=') || c.startsWith('csrf_token=') || c.startsWith('_csrf='))?.split('=').slice(1).join('=');
|
|
53
|
+
const headers = {};
|
|
54
|
+
if (csrf) { headers['X-Csrf-Token'] = csrf; headers['X-XSRF-Token'] = csrf; }
|
|
55
|
+
`
|
|
56
|
+
: 'const headers = {};';
|
|
57
|
+
|
|
58
|
+
return `
|
|
59
|
+
async () => {
|
|
60
|
+
try {
|
|
61
|
+
${headerSetup}
|
|
62
|
+
const resp = await fetch(${JSON.stringify(url)}, {
|
|
63
|
+
${credentialsLine}
|
|
64
|
+
headers
|
|
65
|
+
});
|
|
66
|
+
const status = resp.status;
|
|
67
|
+
if (!resp.ok) return { status, ok: false };
|
|
68
|
+
const text = await resp.text();
|
|
69
|
+
let hasData = false;
|
|
70
|
+
try {
|
|
71
|
+
const json = JSON.parse(text);
|
|
72
|
+
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
|
|
73
|
+
typeof json === 'object' && Object.keys(json).length > 0);
|
|
74
|
+
// Check for API-level error codes (common in Chinese sites)
|
|
75
|
+
if (json.code !== undefined && json.code !== 0) hasData = false;
|
|
76
|
+
} catch {}
|
|
77
|
+
return { status, ok: true, hasData, preview: text.slice(0, 200) };
|
|
78
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
79
|
+
}
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
|
|
40
83
|
/**
|
|
41
84
|
* Probe an endpoint with a specific strategy.
|
|
42
85
|
* Returns whether the probe succeeded and basic response info.
|
|
@@ -45,32 +88,14 @@ export async function probeEndpoint(
|
|
|
45
88
|
page: IPage,
|
|
46
89
|
url: string,
|
|
47
90
|
strategy: Strategy,
|
|
48
|
-
|
|
91
|
+
_opts: { timeout?: number } = {},
|
|
49
92
|
): Promise<ProbeResult> {
|
|
50
93
|
const result: ProbeResult = { strategy, success: false };
|
|
51
94
|
|
|
52
95
|
try {
|
|
53
96
|
switch (strategy) {
|
|
54
97
|
case Strategy.PUBLIC: {
|
|
55
|
-
|
|
56
|
-
const js = `
|
|
57
|
-
async () => {
|
|
58
|
-
try {
|
|
59
|
-
const resp = await fetch(${JSON.stringify(url)});
|
|
60
|
-
const status = resp.status;
|
|
61
|
-
if (!resp.ok) return { status, ok: false };
|
|
62
|
-
const text = await resp.text();
|
|
63
|
-
let hasData = false;
|
|
64
|
-
try {
|
|
65
|
-
const json = JSON.parse(text);
|
|
66
|
-
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
|
|
67
|
-
typeof json === 'object' && Object.keys(json).length > 0);
|
|
68
|
-
} catch {}
|
|
69
|
-
return { status, ok: true, hasData, preview: text.slice(0, 200) };
|
|
70
|
-
} catch (e) { return { ok: false, error: e.message }; }
|
|
71
|
-
}
|
|
72
|
-
`;
|
|
73
|
-
const resp = await page.evaluate(js);
|
|
98
|
+
const resp = await page.evaluate(buildFetchProbeJs(url, {}));
|
|
74
99
|
result.statusCode = resp?.status;
|
|
75
100
|
result.success = resp?.ok && resp?.hasData;
|
|
76
101
|
result.hasData = resp?.hasData;
|
|
@@ -79,27 +104,7 @@ export async function probeEndpoint(
|
|
|
79
104
|
}
|
|
80
105
|
|
|
81
106
|
case Strategy.COOKIE: {
|
|
82
|
-
|
|
83
|
-
const js = `
|
|
84
|
-
async () => {
|
|
85
|
-
try {
|
|
86
|
-
const resp = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
|
|
87
|
-
const status = resp.status;
|
|
88
|
-
if (!resp.ok) return { status, ok: false };
|
|
89
|
-
const text = await resp.text();
|
|
90
|
-
let hasData = false;
|
|
91
|
-
try {
|
|
92
|
-
const json = JSON.parse(text);
|
|
93
|
-
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
|
|
94
|
-
typeof json === 'object' && Object.keys(json).length > 0);
|
|
95
|
-
// Check for API-level error codes (common in Chinese sites)
|
|
96
|
-
if (json.code !== undefined && json.code !== 0) hasData = false;
|
|
97
|
-
} catch {}
|
|
98
|
-
return { status, ok: true, hasData, preview: text.slice(0, 200) };
|
|
99
|
-
} catch (e) { return { ok: false, error: e.message }; }
|
|
100
|
-
}
|
|
101
|
-
`;
|
|
102
|
-
const resp = await page.evaluate(js);
|
|
107
|
+
const resp = await page.evaluate(buildFetchProbeJs(url, { credentials: true }));
|
|
103
108
|
result.statusCode = resp?.status;
|
|
104
109
|
result.success = resp?.ok && resp?.hasData;
|
|
105
110
|
result.hasData = resp?.hasData;
|
|
@@ -108,39 +113,7 @@ export async function probeEndpoint(
|
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
case Strategy.HEADER: {
|
|
111
|
-
|
|
112
|
-
const js = `
|
|
113
|
-
async () => {
|
|
114
|
-
try {
|
|
115
|
-
// Try to extract CSRF tokens from cookies
|
|
116
|
-
const cookies = document.cookie.split(';').map(c => c.trim());
|
|
117
|
-
const csrf = cookies.find(c => c.startsWith('ct0=') || c.startsWith('csrf_token=') || c.startsWith('_csrf='))?.split('=').slice(1).join('=');
|
|
118
|
-
|
|
119
|
-
const headers = {};
|
|
120
|
-
if (csrf) {
|
|
121
|
-
headers['X-Csrf-Token'] = csrf;
|
|
122
|
-
headers['X-XSRF-Token'] = csrf;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const resp = await fetch(${JSON.stringify(url)}, {
|
|
126
|
-
credentials: 'include',
|
|
127
|
-
headers
|
|
128
|
-
});
|
|
129
|
-
const status = resp.status;
|
|
130
|
-
if (!resp.ok) return { status, ok: false };
|
|
131
|
-
const text = await resp.text();
|
|
132
|
-
let hasData = false;
|
|
133
|
-
try {
|
|
134
|
-
const json = JSON.parse(text);
|
|
135
|
-
hasData = !!json && (Array.isArray(json) ? json.length > 0 :
|
|
136
|
-
typeof json === 'object' && Object.keys(json).length > 0);
|
|
137
|
-
if (json.code !== undefined && json.code !== 0) hasData = false;
|
|
138
|
-
} catch {}
|
|
139
|
-
return { status, ok: true, hasData, preview: text.slice(0, 200) };
|
|
140
|
-
} catch (e) { return { ok: false, error: e.message }; }
|
|
141
|
-
}
|
|
142
|
-
`;
|
|
143
|
-
const resp = await page.evaluate(js);
|
|
116
|
+
const resp = await page.evaluate(buildFetchProbeJs(url, { credentials: true, extractCsrf: true }));
|
|
144
117
|
result.statusCode = resp?.status;
|
|
145
118
|
result.success = resp?.ok && resp?.hasData;
|
|
146
119
|
result.hasData = resp?.hasData;
|
|
@@ -151,7 +124,6 @@ export async function probeEndpoint(
|
|
|
151
124
|
case Strategy.INTERCEPT:
|
|
152
125
|
case Strategy.UI:
|
|
153
126
|
// These require specific implementation per-site
|
|
154
|
-
// Mark as needing manual implementation
|
|
155
127
|
result.success = false;
|
|
156
128
|
result.error = `Strategy ${strategy} requires site-specific implementation`;
|
|
157
129
|
break;
|