@jackwener/opencli 0.5.1 → 0.5.2
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 +1 -1
- package/README.zh-CN.md +1 -1
- 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/constants.d.ts +13 -0
- package/dist/constants.js +30 -0
- 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 +1 -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/synthesize.js +5 -5
- 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/constants.ts +35 -0
- 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 +1 -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/synthesize.ts +5 -5
- package/src/validate.ts +22 -0
- package/src/verify.ts +10 -1
- package/src/version.ts +18 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared XHR/Fetch interceptor JavaScript generators.
|
|
3
|
+
*
|
|
4
|
+
* Provides a single source of truth for monkey-patching browser
|
|
5
|
+
* fetch() and XMLHttpRequest to capture API responses matching
|
|
6
|
+
* a URL pattern. Used by:
|
|
7
|
+
* - Page.installInterceptor() (browser.ts)
|
|
8
|
+
* - stepIntercept (pipeline/steps/intercept.ts)
|
|
9
|
+
* - stepTap (pipeline/steps/tap.ts)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate JavaScript source that installs a fetch/XHR interceptor.
|
|
14
|
+
* Captured responses are pushed to `window.__opencli_intercepted`.
|
|
15
|
+
*
|
|
16
|
+
* @param patternExpr - JS expression resolving to a URL substring to match (e.g. a JSON.stringify'd string)
|
|
17
|
+
* @param opts.arrayName - Global array name for captured data (default: '__opencli_intercepted')
|
|
18
|
+
* @param opts.patchGuard - Global boolean name to prevent double-patching (default: '__opencli_interceptor_patched')
|
|
19
|
+
*/
|
|
20
|
+
export function generateInterceptorJs(
|
|
21
|
+
patternExpr: string,
|
|
22
|
+
opts: { arrayName?: string; patchGuard?: string } = {},
|
|
23
|
+
): string {
|
|
24
|
+
const arr = opts.arrayName ?? '__opencli_intercepted';
|
|
25
|
+
const guard = opts.patchGuard ?? '__opencli_interceptor_patched';
|
|
26
|
+
|
|
27
|
+
return `
|
|
28
|
+
() => {
|
|
29
|
+
window.${arr} = window.${arr} || [];
|
|
30
|
+
const __pattern = ${patternExpr};
|
|
31
|
+
|
|
32
|
+
if (!window.${guard}) {
|
|
33
|
+
const __checkMatch = (url) => __pattern && url.includes(__pattern);
|
|
34
|
+
|
|
35
|
+
// ── Patch fetch ──
|
|
36
|
+
const __origFetch = window.fetch;
|
|
37
|
+
window.fetch = async function(...args) {
|
|
38
|
+
const reqUrl = typeof args[0] === 'string' ? args[0]
|
|
39
|
+
: (args[0] && args[0].url) || '';
|
|
40
|
+
const response = await __origFetch.apply(this, args);
|
|
41
|
+
if (__checkMatch(reqUrl)) {
|
|
42
|
+
try {
|
|
43
|
+
const clone = response.clone();
|
|
44
|
+
const json = await clone.json();
|
|
45
|
+
window.${arr}.push(json);
|
|
46
|
+
} catch(e) {}
|
|
47
|
+
}
|
|
48
|
+
return response;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ── Patch XMLHttpRequest ──
|
|
52
|
+
const __XHR = XMLHttpRequest.prototype;
|
|
53
|
+
const __origOpen = __XHR.open;
|
|
54
|
+
const __origSend = __XHR.send;
|
|
55
|
+
__XHR.open = function(method, url) {
|
|
56
|
+
this.__opencli_url = String(url);
|
|
57
|
+
return __origOpen.apply(this, arguments);
|
|
58
|
+
};
|
|
59
|
+
__XHR.send = function() {
|
|
60
|
+
if (__checkMatch(this.__opencli_url)) {
|
|
61
|
+
this.addEventListener('load', function() {
|
|
62
|
+
try {
|
|
63
|
+
window.${arr}.push(JSON.parse(this.responseText));
|
|
64
|
+
} catch(e) {}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return __origSend.apply(this, arguments);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
window.${guard} = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Generate JavaScript source to read and clear intercepted data.
|
|
78
|
+
*/
|
|
79
|
+
export function generateReadInterceptedJs(arrayName: string = '__opencli_intercepted'): string {
|
|
80
|
+
return `
|
|
81
|
+
() => {
|
|
82
|
+
const data = window.${arrayName} || [];
|
|
83
|
+
window.${arrayName} = [];
|
|
84
|
+
return data;
|
|
85
|
+
}
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Generate a self-contained tap interceptor for store-action bridge.
|
|
91
|
+
* Unlike the global interceptor, this one:
|
|
92
|
+
* - Installs temporarily, restores originals in finally block
|
|
93
|
+
* - Resolves a promise on first capture (for immediate await)
|
|
94
|
+
* - Returns captured data directly
|
|
95
|
+
*/
|
|
96
|
+
export function generateTapInterceptorJs(patternExpr: string): {
|
|
97
|
+
setupVar: string;
|
|
98
|
+
capturedVar: string;
|
|
99
|
+
promiseVar: string;
|
|
100
|
+
resolveVar: string;
|
|
101
|
+
fetchPatch: string;
|
|
102
|
+
xhrPatch: string;
|
|
103
|
+
restorePatch: string;
|
|
104
|
+
} {
|
|
105
|
+
return {
|
|
106
|
+
setupVar: `
|
|
107
|
+
let captured = null;
|
|
108
|
+
let captureResolve;
|
|
109
|
+
const capturePromise = new Promise(r => { captureResolve = r; });
|
|
110
|
+
const capturePattern = ${patternExpr};
|
|
111
|
+
`,
|
|
112
|
+
capturedVar: 'captured',
|
|
113
|
+
promiseVar: 'capturePromise',
|
|
114
|
+
resolveVar: 'captureResolve',
|
|
115
|
+
fetchPatch: `
|
|
116
|
+
const origFetch = window.fetch;
|
|
117
|
+
window.fetch = async function(...fetchArgs) {
|
|
118
|
+
const resp = await origFetch.apply(this, fetchArgs);
|
|
119
|
+
try {
|
|
120
|
+
const url = typeof fetchArgs[0] === 'string' ? fetchArgs[0]
|
|
121
|
+
: fetchArgs[0] instanceof Request ? fetchArgs[0].url : String(fetchArgs[0]);
|
|
122
|
+
if (capturePattern && url.includes(capturePattern) && !captured) {
|
|
123
|
+
try { captured = await resp.clone().json(); captureResolve(); } catch {}
|
|
124
|
+
}
|
|
125
|
+
} catch {}
|
|
126
|
+
return resp;
|
|
127
|
+
};
|
|
128
|
+
`,
|
|
129
|
+
xhrPatch: `
|
|
130
|
+
const origXhrOpen = XMLHttpRequest.prototype.open;
|
|
131
|
+
const origXhrSend = XMLHttpRequest.prototype.send;
|
|
132
|
+
XMLHttpRequest.prototype.open = function(method, url) {
|
|
133
|
+
this.__tapUrl = String(url);
|
|
134
|
+
return origXhrOpen.apply(this, arguments);
|
|
135
|
+
};
|
|
136
|
+
XMLHttpRequest.prototype.send = function(body) {
|
|
137
|
+
if (capturePattern && this.__tapUrl?.includes(capturePattern)) {
|
|
138
|
+
this.addEventListener('load', function() {
|
|
139
|
+
if (!captured) {
|
|
140
|
+
try { captured = JSON.parse(this.responseText); captureResolve(); } catch {}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return origXhrSend.apply(this, arguments);
|
|
145
|
+
};
|
|
146
|
+
`,
|
|
147
|
+
restorePatch: `
|
|
148
|
+
window.fetch = origFetch;
|
|
149
|
+
XMLHttpRequest.prototype.open = origXhrOpen;
|
|
150
|
+
XMLHttpRequest.prototype.send = origXhrSend;
|
|
151
|
+
`,
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* opencli — Make any website your CLI. AI-powered.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import * as fs from 'node:fs';
|
|
7
6
|
import * as os from 'node:os';
|
|
8
7
|
import * as path from 'node:path';
|
|
9
8
|
import { fileURLToPath } from 'node:url';
|
|
@@ -14,16 +13,13 @@ import { type CliCommand, fullName, getRegistry, strategyLabel } from './registr
|
|
|
14
13
|
import { render as renderOutput } from './output.js';
|
|
15
14
|
import { PlaywrightMCP } from './browser.js';
|
|
16
15
|
import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js';
|
|
16
|
+
import { PKG_VERSION } from './version.js';
|
|
17
17
|
|
|
18
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
19
19
|
const __dirname = path.dirname(__filename);
|
|
20
20
|
const BUILTIN_CLIS = path.resolve(__dirname, 'clis');
|
|
21
21
|
const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
|
|
22
22
|
|
|
23
|
-
// Read version from package.json (single source of truth)
|
|
24
|
-
const pkgJsonPath = path.resolve(__dirname, '..', 'package.json');
|
|
25
|
-
const PKG_VERSION = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).version ?? '0.0.0';
|
|
26
|
-
|
|
27
23
|
await discoverClis(BUILTIN_CLIS, USER_CLIS);
|
|
28
24
|
|
|
29
25
|
const program = new Command();
|
package/src/output.ts
CHANGED
|
@@ -40,10 +40,6 @@ function renderTable(data: any, opts: RenderOptions): void {
|
|
|
40
40
|
style: { head: [], border: [] },
|
|
41
41
|
wordWrap: true,
|
|
42
42
|
wrapOnWordBoundary: true,
|
|
43
|
-
colWidths: columns.map((_c, i) => {
|
|
44
|
-
if (i === 0) return 6;
|
|
45
|
-
return null as any;
|
|
46
|
-
}).filter(() => true),
|
|
47
43
|
});
|
|
48
44
|
|
|
49
45
|
for (const row of rows) {
|
package/src/pipeline/executor.ts
CHANGED
|
@@ -15,26 +15,26 @@ export interface PipelineContext {
|
|
|
15
15
|
debug?: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/** Step handler
|
|
18
|
+
/** Step handler: all steps conform to (page, params, data, args) => Promise<any> */
|
|
19
19
|
type StepHandler = (page: IPage | null, params: any, data: any, args: Record<string, any>) => Promise<any>;
|
|
20
20
|
|
|
21
21
|
/** Registry of all available step handlers */
|
|
22
22
|
const STEP_HANDLERS: Record<string, StepHandler> = {
|
|
23
|
-
navigate: stepNavigate
|
|
23
|
+
navigate: stepNavigate,
|
|
24
24
|
fetch: stepFetch,
|
|
25
|
-
select: stepSelect
|
|
26
|
-
evaluate: stepEvaluate
|
|
27
|
-
snapshot: stepSnapshot
|
|
28
|
-
click: stepClick
|
|
29
|
-
type: stepType
|
|
30
|
-
wait: stepWait
|
|
31
|
-
press: stepPress
|
|
32
|
-
map: stepMap
|
|
33
|
-
filter: stepFilter
|
|
34
|
-
sort: stepSort
|
|
35
|
-
limit: stepLimit
|
|
36
|
-
intercept: stepIntercept
|
|
37
|
-
tap: stepTap
|
|
25
|
+
select: stepSelect,
|
|
26
|
+
evaluate: stepEvaluate,
|
|
27
|
+
snapshot: stepSnapshot,
|
|
28
|
+
click: stepClick,
|
|
29
|
+
type: stepType,
|
|
30
|
+
wait: stepWait,
|
|
31
|
+
press: stepPress,
|
|
32
|
+
map: stepMap,
|
|
33
|
+
filter: stepFilter,
|
|
34
|
+
sort: stepSort,
|
|
35
|
+
limit: stepLimit,
|
|
36
|
+
intercept: stepIntercept,
|
|
37
|
+
tap: stepTap,
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
export async function executePipeline(
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { IPage } from '../../types.js';
|
|
6
6
|
import { render } from '../template.js';
|
|
7
|
+
import { generateInterceptorJs, generateReadInterceptedJs } from '../../interceptor.js';
|
|
7
8
|
|
|
8
9
|
export async function stepIntercept(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
9
10
|
const cfg = typeof params === 'object' ? params : {};
|
|
@@ -15,52 +16,7 @@ export async function stepIntercept(page: IPage, params: any, data: any, args: R
|
|
|
15
16
|
if (!capturePattern) return data;
|
|
16
17
|
|
|
17
18
|
// Step 1: Inject fetch/XHR interceptor BEFORE trigger
|
|
18
|
-
await page.evaluate(
|
|
19
|
-
() => {
|
|
20
|
-
window.__opencli_intercepted = window.__opencli_intercepted || [];
|
|
21
|
-
const pattern = ${JSON.stringify(capturePattern)};
|
|
22
|
-
|
|
23
|
-
if (!window.__opencli_fetch_patched) {
|
|
24
|
-
const origFetch = window.fetch;
|
|
25
|
-
window.fetch = async function(...args) {
|
|
26
|
-
const reqUrl = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
|
|
27
|
-
const response = await origFetch.apply(this, args);
|
|
28
|
-
setTimeout(async () => {
|
|
29
|
-
try {
|
|
30
|
-
if (reqUrl.includes(pattern)) {
|
|
31
|
-
const clone = response.clone();
|
|
32
|
-
const json = await clone.json();
|
|
33
|
-
window.__opencli_intercepted.push(json);
|
|
34
|
-
}
|
|
35
|
-
} catch(e) {}
|
|
36
|
-
}, 0);
|
|
37
|
-
return response;
|
|
38
|
-
};
|
|
39
|
-
window.__opencli_fetch_patched = true;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (!window.__opencli_xhr_patched) {
|
|
43
|
-
const XHR = XMLHttpRequest.prototype;
|
|
44
|
-
const open = XHR.open;
|
|
45
|
-
const send = XHR.send;
|
|
46
|
-
XHR.open = function(method, url, ...args) {
|
|
47
|
-
this._reqUrl = url;
|
|
48
|
-
return open.call(this, method, url, ...args);
|
|
49
|
-
};
|
|
50
|
-
XHR.send = function(...args) {
|
|
51
|
-
this.addEventListener('load', function() {
|
|
52
|
-
try {
|
|
53
|
-
if (this._reqUrl && this._reqUrl.includes(pattern)) {
|
|
54
|
-
window.__opencli_intercepted.push(JSON.parse(this.responseText));
|
|
55
|
-
}
|
|
56
|
-
} catch(e) {}
|
|
57
|
-
});
|
|
58
|
-
return send.apply(this, args);
|
|
59
|
-
};
|
|
60
|
-
window.__opencli_xhr_patched = true;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
`);
|
|
19
|
+
await page.evaluate(generateInterceptorJs(JSON.stringify(capturePattern)));
|
|
64
20
|
|
|
65
21
|
// Step 2: Execute the trigger action
|
|
66
22
|
if (trigger.startsWith('navigate:')) {
|
|
@@ -81,16 +37,9 @@ export async function stepIntercept(page: IPage, params: any, data: any, args: R
|
|
|
81
37
|
await page.wait(Math.min(timeout, 3));
|
|
82
38
|
|
|
83
39
|
// Step 4: Retrieve captured data
|
|
84
|
-
const matchingResponses = await page.evaluate(
|
|
85
|
-
() => {
|
|
86
|
-
const data = window.__opencli_intercepted || [];
|
|
87
|
-
window.__opencli_intercepted = []; // clear after reading
|
|
88
|
-
return data;
|
|
89
|
-
}
|
|
90
|
-
`);
|
|
91
|
-
|
|
40
|
+
const matchingResponses = await page.evaluate(generateReadInterceptedJs());
|
|
92
41
|
|
|
93
|
-
// Step
|
|
42
|
+
// Step 5: Select from response if specified
|
|
94
43
|
let result = matchingResponses.length === 1 ? matchingResponses[0] :
|
|
95
44
|
matchingResponses.length > 1 ? matchingResponses : data;
|
|
96
45
|
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import type { IPage } from '../../types.js';
|
|
13
13
|
import { render } from '../template.js';
|
|
14
|
+
import { generateTapInterceptorJs } from '../../interceptor.js';
|
|
14
15
|
|
|
15
16
|
export async function stepTap(page: IPage, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
16
17
|
const cfg = typeof params === 'object' ? params : {};
|
|
@@ -38,53 +39,15 @@ export async function stepTap(page: IPage, params: any, data: any, args: Record<
|
|
|
38
39
|
? `store[${JSON.stringify(actionName)}](${actionArgsRendered.join(', ')})`
|
|
39
40
|
: `store[${JSON.stringify(actionName)}]()`;
|
|
40
41
|
|
|
42
|
+
// Use shared interceptor generator for fetch/XHR patching
|
|
43
|
+
const tap = generateTapInterceptorJs(JSON.stringify(capturePattern));
|
|
44
|
+
|
|
41
45
|
const js = `
|
|
42
46
|
async () => {
|
|
43
47
|
// ── 1. Setup capture proxy (fetch + XHR dual interception) ──
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const capturePattern = ${JSON.stringify(capturePattern)};
|
|
48
|
-
|
|
49
|
-
// Intercept fetch API
|
|
50
|
-
const origFetch = window.fetch;
|
|
51
|
-
window.fetch = async function(...fetchArgs) {
|
|
52
|
-
const resp = await origFetch.apply(this, fetchArgs);
|
|
53
|
-
try {
|
|
54
|
-
const url = typeof fetchArgs[0] === 'string' ? fetchArgs[0]
|
|
55
|
-
: fetchArgs[0] instanceof Request ? fetchArgs[0].url : String(fetchArgs[0]);
|
|
56
|
-
if (capturePattern && url.includes(capturePattern) && !captured) {
|
|
57
|
-
try { captured = await resp.clone().json(); captureResolve(); } catch {}
|
|
58
|
-
}
|
|
59
|
-
} catch {}
|
|
60
|
-
return resp;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
// Intercept XMLHttpRequest
|
|
64
|
-
const origXhrOpen = XMLHttpRequest.prototype.open;
|
|
65
|
-
const origXhrSend = XMLHttpRequest.prototype.send;
|
|
66
|
-
XMLHttpRequest.prototype.open = function(method, url) {
|
|
67
|
-
this.__tapUrl = String(url);
|
|
68
|
-
return origXhrOpen.apply(this, arguments);
|
|
69
|
-
};
|
|
70
|
-
XMLHttpRequest.prototype.send = function(body) {
|
|
71
|
-
if (capturePattern && this.__tapUrl?.includes(capturePattern)) {
|
|
72
|
-
const xhr = this;
|
|
73
|
-
const origHandler = xhr.onreadystatechange;
|
|
74
|
-
xhr.onreadystatechange = function() {
|
|
75
|
-
if (xhr.readyState === 4 && !captured) {
|
|
76
|
-
try { captured = JSON.parse(xhr.responseText); captureResolve(); } catch {}
|
|
77
|
-
}
|
|
78
|
-
if (origHandler) origHandler.apply(this, arguments);
|
|
79
|
-
};
|
|
80
|
-
const origOnload = xhr.onload;
|
|
81
|
-
xhr.onload = function() {
|
|
82
|
-
if (!captured) { try { captured = JSON.parse(xhr.responseText); captureResolve(); } catch {} }
|
|
83
|
-
if (origOnload) origOnload.apply(this, arguments);
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
return origXhrSend.apply(this, arguments);
|
|
87
|
-
};
|
|
48
|
+
${tap.setupVar}
|
|
49
|
+
${tap.fetchPatch}
|
|
50
|
+
${tap.xhrPatch}
|
|
88
51
|
|
|
89
52
|
try {
|
|
90
53
|
// ── 2. Find store ──
|
|
@@ -119,19 +82,17 @@ export async function stepTap(page: IPage, params: any, data: any, args: Record<
|
|
|
119
82
|
await ${actionCall};
|
|
120
83
|
|
|
121
84
|
// ── 4. Wait for network response ──
|
|
122
|
-
if (
|
|
85
|
+
if (!${tap.capturedVar}) {
|
|
123
86
|
const timeoutPromise = new Promise(r => setTimeout(r, ${timeout} * 1000));
|
|
124
|
-
await Promise.race([
|
|
87
|
+
await Promise.race([${tap.promiseVar}, timeoutPromise]);
|
|
125
88
|
}
|
|
126
89
|
} finally {
|
|
127
90
|
// ── 5. Always restore originals ──
|
|
128
|
-
|
|
129
|
-
XMLHttpRequest.prototype.open = origXhrOpen;
|
|
130
|
-
XMLHttpRequest.prototype.send = origXhrSend;
|
|
91
|
+
${tap.restorePatch}
|
|
131
92
|
}
|
|
132
93
|
|
|
133
|
-
if (
|
|
134
|
-
return
|
|
94
|
+
if (!${tap.capturedVar}) return { error: 'No matching response captured for pattern: ' + capturePattern };
|
|
95
|
+
return ${tap.capturedVar}${selectChain} ?? ${tap.capturedVar};
|
|
135
96
|
}
|
|
136
97
|
`;
|
|
137
98
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for registry.ts: Strategy enum, cli() registration, helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
6
|
+
import { cli, getRegistry, fullName, strategyLabel, registerCommand, Strategy, type CliCommand } from './registry.js';
|
|
7
|
+
|
|
8
|
+
describe('cli() registration', () => {
|
|
9
|
+
it('registers a command and returns it', () => {
|
|
10
|
+
const cmd = cli({
|
|
11
|
+
site: 'test-registry',
|
|
12
|
+
name: 'hello',
|
|
13
|
+
description: 'A test command',
|
|
14
|
+
strategy: Strategy.PUBLIC,
|
|
15
|
+
browser: false,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(cmd.site).toBe('test-registry');
|
|
19
|
+
expect(cmd.name).toBe('hello');
|
|
20
|
+
expect(cmd.strategy).toBe(Strategy.PUBLIC);
|
|
21
|
+
expect(cmd.browser).toBe(false);
|
|
22
|
+
expect(cmd.args).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('puts registered command in the registry', () => {
|
|
26
|
+
cli({
|
|
27
|
+
site: 'test-registry',
|
|
28
|
+
name: 'registered',
|
|
29
|
+
description: 'test',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const registry = getRegistry();
|
|
33
|
+
expect(registry.has('test-registry/registered')).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('defaults strategy to COOKIE when browser is true', () => {
|
|
37
|
+
const cmd = cli({
|
|
38
|
+
site: 'test-registry',
|
|
39
|
+
name: 'default-strategy',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(cmd.strategy).toBe(Strategy.COOKIE);
|
|
43
|
+
expect(cmd.browser).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('defaults strategy to PUBLIC when browser is false', () => {
|
|
47
|
+
const cmd = cli({
|
|
48
|
+
site: 'test-registry',
|
|
49
|
+
name: 'no-browser',
|
|
50
|
+
browser: false,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(cmd.strategy).toBe(Strategy.PUBLIC);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('overwrites existing command on re-registration', () => {
|
|
57
|
+
cli({ site: 'test-registry', name: 'overwrite', description: 'v1' });
|
|
58
|
+
cli({ site: 'test-registry', name: 'overwrite', description: 'v2' });
|
|
59
|
+
|
|
60
|
+
const reg = getRegistry();
|
|
61
|
+
expect(reg.get('test-registry/overwrite')?.description).toBe('v2');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('fullName', () => {
|
|
66
|
+
it('returns site/name', () => {
|
|
67
|
+
const cmd: CliCommand = {
|
|
68
|
+
site: 'bilibili', name: 'hot', description: '', args: [],
|
|
69
|
+
};
|
|
70
|
+
expect(fullName(cmd)).toBe('bilibili/hot');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('strategyLabel', () => {
|
|
75
|
+
it('returns strategy string', () => {
|
|
76
|
+
const cmd: CliCommand = {
|
|
77
|
+
site: 'test', name: 'test', description: '', args: [],
|
|
78
|
+
strategy: Strategy.INTERCEPT,
|
|
79
|
+
};
|
|
80
|
+
expect(strategyLabel(cmd)).toBe('intercept');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns public when no strategy set', () => {
|
|
84
|
+
const cmd: CliCommand = {
|
|
85
|
+
site: 'test', name: 'test', description: '', args: [],
|
|
86
|
+
};
|
|
87
|
+
expect(strategyLabel(cmd)).toBe('public');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('registerCommand', () => {
|
|
92
|
+
it('registers a pre-built command', () => {
|
|
93
|
+
const cmd: CliCommand = {
|
|
94
|
+
site: 'test-registry',
|
|
95
|
+
name: 'direct-reg',
|
|
96
|
+
description: 'directly registered',
|
|
97
|
+
args: [],
|
|
98
|
+
strategy: Strategy.HEADER,
|
|
99
|
+
browser: true,
|
|
100
|
+
};
|
|
101
|
+
registerCommand(cmd);
|
|
102
|
+
|
|
103
|
+
const reg = getRegistry();
|
|
104
|
+
expect(reg.get('test-registry/direct-reg')?.strategy).toBe(Strategy.HEADER);
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/registry.ts
CHANGED
|
@@ -34,7 +34,10 @@ export interface CliCommand {
|
|
|
34
34
|
pipeline?: any[];
|
|
35
35
|
timeoutSeconds?: number;
|
|
36
36
|
source?: string;
|
|
37
|
-
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Internal extension for lazy-loaded TS modules (not exposed in public API) */
|
|
40
|
+
export interface InternalCliCommand extends CliCommand {
|
|
38
41
|
_lazy?: boolean;
|
|
39
42
|
_modulePath?: string;
|
|
40
43
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -9,23 +9,37 @@ export const DEFAULT_BROWSER_COMMAND_TIMEOUT = parseInt(process.env.OPENCLI_BROW
|
|
|
9
9
|
export const DEFAULT_BROWSER_EXPLORE_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_EXPLORE_TIMEOUT ?? '120', 10);
|
|
10
10
|
export const DEFAULT_BROWSER_SMOKE_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_SMOKE_TIMEOUT ?? '60', 10);
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Timeout with seconds unit. Used for high-level command timeouts.
|
|
14
|
+
*/
|
|
12
15
|
export async function runWithTimeout<T>(
|
|
13
16
|
promise: Promise<T>,
|
|
14
17
|
opts: { timeout: number; label?: string },
|
|
15
18
|
): Promise<T> {
|
|
16
|
-
return
|
|
17
|
-
|
|
18
|
-
reject(new Error(`${opts.label ?? 'Operation'} timed out after ${opts.timeout}s`));
|
|
19
|
-
}, opts.timeout * 1000);
|
|
19
|
+
return withTimeoutMs(promise, opts.timeout * 1000, `${opts.label ?? 'Operation'} timed out after ${opts.timeout}s`);
|
|
20
|
+
}
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Timeout with milliseconds unit. Used for low-level internal timeouts.
|
|
24
|
+
*/
|
|
25
|
+
export function withTimeoutMs<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
26
|
+
return new Promise<T>((resolve, reject) => {
|
|
27
|
+
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
28
|
+
promise.then(
|
|
29
|
+
(value) => { clearTimeout(timer); resolve(value); },
|
|
30
|
+
(error) => { clearTimeout(timer); reject(error); },
|
|
31
|
+
);
|
|
24
32
|
});
|
|
25
33
|
}
|
|
26
34
|
|
|
35
|
+
/** Interface for browser factory (PlaywrightMCP or test mocks) */
|
|
36
|
+
export interface IBrowserFactory {
|
|
37
|
+
connect(opts?: { timeout?: number }): Promise<IPage>;
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
export async function browserSession<T>(
|
|
28
|
-
BrowserFactory: new () =>
|
|
42
|
+
BrowserFactory: new () => IBrowserFactory,
|
|
29
43
|
fn: (page: IPage) => Promise<T>,
|
|
30
44
|
): Promise<T> {
|
|
31
45
|
const mcp = new BrowserFactory();
|
package/src/synthesize.ts
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
import * as fs from 'node:fs';
|
|
7
7
|
import * as path from 'node:path';
|
|
8
8
|
import yaml from 'js-yaml';
|
|
9
|
+
import { VOLATILE_PARAMS, SEARCH_PARAMS, LIMIT_PARAMS, PAGINATION_PARAMS } from './constants.js';
|
|
9
10
|
|
|
10
|
-
/**
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const PAGE_PARAM_NAMES = new Set(['pn', 'page', 'page_num', 'offset', 'cursor']);
|
|
11
|
+
/** Renamed aliases for backward compatibility with local references */
|
|
12
|
+
const SEARCH_PARAM_NAMES = SEARCH_PARAMS;
|
|
13
|
+
const LIMIT_PARAM_NAMES = LIMIT_PARAMS;
|
|
14
|
+
const PAGE_PARAM_NAMES = PAGINATION_PARAMS;
|
|
15
15
|
|
|
16
16
|
export function synthesizeFromExplore(
|
|
17
17
|
target: string,
|
package/src/validate.ts
CHANGED
|
@@ -3,6 +3,14 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import yaml from 'js-yaml';
|
|
5
5
|
|
|
6
|
+
/** All recognized pipeline step names */
|
|
7
|
+
const KNOWN_STEP_NAMES = new Set([
|
|
8
|
+
'navigate', 'click', 'type', 'wait', 'press', 'snapshot', 'scroll',
|
|
9
|
+
'fetch', 'evaluate',
|
|
10
|
+
'select', 'map', 'filter', 'sort', 'limit',
|
|
11
|
+
'intercept', 'tap',
|
|
12
|
+
]);
|
|
13
|
+
|
|
6
14
|
export function validateClisWithTarget(dirs: string[], target?: string): any {
|
|
7
15
|
const results: any[] = [];
|
|
8
16
|
let errors = 0; let warnings = 0; let files = 0;
|
|
@@ -38,6 +46,20 @@ function validateYamlFile(filePath: string): any {
|
|
|
38
46
|
if (def.pipeline && !Array.isArray(def.pipeline)) errors.push('"pipeline" must be an array');
|
|
39
47
|
if (def.columns && !Array.isArray(def.columns)) errors.push('"columns" must be an array');
|
|
40
48
|
if (def.args && typeof def.args !== 'object') errors.push('"args" must be an object');
|
|
49
|
+
// Validate pipeline step names (catch typos like 'navaigate')
|
|
50
|
+
if (Array.isArray(def.pipeline)) {
|
|
51
|
+
for (let i = 0; i < def.pipeline.length; i++) {
|
|
52
|
+
const step = def.pipeline[i];
|
|
53
|
+
if (step && typeof step === 'object') {
|
|
54
|
+
const stepKeys = Object.keys(step);
|
|
55
|
+
for (const key of stepKeys) {
|
|
56
|
+
if (!KNOWN_STEP_NAMES.has(key)) {
|
|
57
|
+
warnings.push(`Pipeline step ${i}: unknown step name "${key}" (did you mean one of: ${[...KNOWN_STEP_NAMES].join(', ')}?)`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
41
63
|
} catch (e: any) { errors.push(`YAML parse error: ${e.message}`); }
|
|
42
64
|
return { path: filePath, errors, warnings };
|
|
43
65
|
}
|
package/src/verify.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
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
|
+
*/
|
|
8
|
+
|
|
2
9
|
import { validateClisWithTarget, renderValidationReport } from './validate.js';
|
|
10
|
+
|
|
3
11
|
export async function verifyClis(opts: any): Promise<any> {
|
|
4
12
|
const report = validateClisWithTarget([opts.builtinClis, opts.userClis], opts.target);
|
|
5
13
|
return { ok: report.ok, validation: report, smoke: null };
|
|
6
14
|
}
|
|
15
|
+
|
|
7
16
|
export function renderVerifyReport(report: any): string {
|
|
8
17
|
return renderValidationReport(report.validation);
|
|
9
18
|
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for package version.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from 'node:fs';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const pkgJsonPath = path.resolve(__dirname, '..', 'package.json');
|
|
11
|
+
|
|
12
|
+
export const PKG_VERSION: string = (() => {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).version;
|
|
15
|
+
} catch {
|
|
16
|
+
return '0.0.0';
|
|
17
|
+
}
|
|
18
|
+
})();
|