@jackwener/opencli 1.7.2 → 1.7.4
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 +18 -15
- package/README.zh-CN.md +31 -15
- package/cli-manifest.json +1265 -101
- package/clis/barchart/flow.js +1 -1
- package/clis/barchart/greeks.js +2 -2
- package/clis/barchart/options.js +2 -2
- package/clis/barchart/quote.js +1 -1
- package/clis/bilibili/favorite.js +18 -13
- package/clis/bilibili/feed.js +202 -48
- package/clis/binance/depth.js +3 -4
- package/clis/boss/utils.js +2 -2
- package/clis/chatgpt/image.js +97 -0
- package/clis/chatgpt/utils.js +297 -0
- package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/ax.js +6 -3
- package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
- package/clis/discord-app/delete.js +114 -0
- package/clis/douban/search.js +1 -0
- package/clis/douban/search.test.js +11 -0
- package/clis/douban/subject.js +20 -93
- package/clis/douban/subject.test.js +11 -0
- package/clis/douban/utils.js +279 -10
- package/clis/douban/utils.test.js +296 -1
- package/clis/doubao/utils.js +319 -130
- package/clis/doubao/utils.test.js +241 -2
- package/clis/eastmoney/hot-rank.js +50 -0
- package/clis/eastmoney/hot-rank.test.js +59 -0
- package/clis/grok/image.test.ts +107 -0
- package/clis/grok/image.ts +356 -0
- package/clis/ke/chengjiao.js +77 -0
- package/clis/ke/ershoufang.js +100 -0
- package/clis/ke/utils.js +104 -0
- package/clis/ke/xiaoqu.js +77 -0
- package/clis/ke/zufang.js +94 -0
- package/clis/maimai/search-talents.js +172 -0
- package/clis/mubu/doc.js +40 -0
- package/clis/mubu/docs.js +43 -0
- package/clis/mubu/notes.js +244 -0
- package/clis/mubu/recent.js +27 -0
- package/clis/mubu/search.js +62 -0
- package/clis/mubu/utils.js +304 -0
- package/clis/reuters/search.js +1 -1
- package/clis/tdx/hot-rank.js +47 -0
- package/clis/tdx/hot-rank.test.js +59 -0
- package/clis/ths/hot-rank.js +49 -0
- package/clis/ths/hot-rank.test.js +64 -0
- package/clis/twitter/bookmarks.js +2 -1
- package/clis/uiverse/_shared.js +368 -0
- package/clis/uiverse/_shared.test.js +55 -0
- package/clis/uiverse/code.js +47 -0
- package/clis/uiverse/preview.js +71 -0
- package/clis/xiaohongshu/comments.js +20 -8
- package/clis/xiaohongshu/comments.test.js +69 -12
- package/clis/xiaohongshu/creator-note-detail.js +2 -0
- package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
- package/clis/xiaohongshu/creator-notes-summary.js +4 -0
- package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
- package/clis/xiaohongshu/creator-notes.js +1 -0
- package/clis/xiaohongshu/creator-profile.js +1 -0
- package/clis/xiaohongshu/creator-stats.js +1 -0
- package/clis/xiaohongshu/download.js +18 -7
- package/clis/xiaohongshu/download.test.js +42 -0
- package/clis/xiaohongshu/navigation.test.js +34 -0
- package/clis/xiaohongshu/note-helpers.js +46 -12
- package/clis/xiaohongshu/note.js +17 -10
- package/clis/xiaohongshu/note.test.js +66 -11
- package/clis/xiaohongshu/publish.js +1 -0
- package/clis/xiaohongshu/search.js +1 -0
- package/clis/xiaohongshu/user.js +1 -0
- package/clis/xiaoyuzhou/auth.js +303 -0
- package/clis/xiaoyuzhou/auth.test.js +124 -0
- package/clis/xiaoyuzhou/download.js +49 -0
- package/clis/xiaoyuzhou/download.test.js +125 -0
- package/clis/xiaoyuzhou/transcript.js +76 -0
- package/clis/xiaoyuzhou/transcript.test.js +195 -0
- package/clis/yahoo-finance/quote.js +1 -1
- package/clis/youtube/feed.js +120 -0
- package/clis/youtube/history.js +118 -0
- package/clis/youtube/like.js +62 -0
- package/clis/youtube/playlist.js +97 -0
- package/clis/youtube/subscribe.js +71 -0
- package/clis/youtube/subscriptions.js +57 -0
- package/clis/youtube/unlike.js +62 -0
- package/clis/youtube/unsubscribe.js +71 -0
- package/clis/youtube/utils.js +122 -0
- package/clis/youtube/utils.test.js +32 -1
- package/clis/youtube/watch-later.js +76 -0
- package/dist/src/browser/base-page.d.ts +9 -0
- package/dist/src/browser/base-page.js +44 -5
- package/dist/src/browser/bridge.d.ts +2 -0
- package/dist/src/browser/bridge.js +51 -14
- package/dist/src/browser/cdp.js +11 -2
- package/dist/src/browser/daemon-client.d.ts +2 -0
- package/dist/src/browser/dom-snapshot.js +13 -1
- package/dist/src/browser/page.d.ts +4 -1
- package/dist/src/browser/page.js +48 -8
- package/dist/src/browser/page.test.js +61 -1
- package/dist/src/browser/target-errors.d.ts +23 -0
- package/dist/src/browser/target-errors.js +29 -0
- package/dist/src/browser/target-errors.test.d.ts +1 -0
- package/dist/src/browser/target-errors.test.js +61 -0
- package/dist/src/browser/target-resolver.d.ts +57 -0
- package/dist/src/browser/target-resolver.js +298 -0
- package/dist/src/browser/target-resolver.test.d.ts +1 -0
- package/dist/src/browser/target-resolver.test.js +43 -0
- package/dist/src/browser.test.js +38 -1
- package/dist/src/cli.js +45 -35
- package/dist/src/commands/daemon.d.ts +4 -2
- package/dist/src/commands/daemon.js +22 -2
- package/dist/src/commands/daemon.test.js +65 -2
- package/dist/src/daemon.js +7 -0
- package/dist/src/doctor.d.ts +2 -0
- package/dist/src/doctor.js +82 -10
- package/dist/src/doctor.test.js +28 -12
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/errors.d.ts +1 -0
- package/dist/src/errors.js +13 -0
- package/dist/src/execution.js +36 -9
- package/dist/src/execution.test.js +23 -0
- package/dist/src/external-clis.yaml +2 -2
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +3 -8
- package/dist/src/output.js +1 -5
- package/dist/src/output.test.js +0 -21
- package/dist/src/pipeline/steps/transform.js +1 -1
- package/dist/src/pipeline/template.d.ts +1 -0
- package/dist/src/pipeline/template.js +11 -3
- package/dist/src/pipeline/template.test.js +3 -0
- package/dist/src/pipeline/transform.test.js +14 -0
- package/dist/src/plugin.d.ts +7 -1
- package/dist/src/plugin.js +23 -1
- package/dist/src/plugin.test.js +15 -1
- package/dist/src/registry.js +3 -4
- package/dist/src/types.d.ts +3 -1
- package/dist/src/update-check.d.ts +14 -0
- package/dist/src/update-check.js +48 -3
- package/dist/src/update-check.test.d.ts +1 -0
- package/dist/src/update-check.test.js +31 -0
- package/package.json +1 -1
- package/scripts/fetch-adapters.js +35 -8
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified target resolver for browser actions.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the ad-hoc 4-strategy fallback in dom-helpers.ts with a
|
|
5
|
+
* principled resolution pipeline:
|
|
6
|
+
*
|
|
7
|
+
* 1. Input classification: numeric → ref path, CSS-like → CSS path
|
|
8
|
+
* 2. Ref path: lookup by data-opencli-ref, then verify fingerprint
|
|
9
|
+
* 3. CSS path: querySelectorAll + uniqueness check
|
|
10
|
+
* 4. Structured errors: stale_ref / ambiguous / not_found
|
|
11
|
+
*
|
|
12
|
+
* All JS is generated as strings for page.evaluate() — runs in the browser.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Generate JS that resolves a target to a single DOM element.
|
|
16
|
+
*
|
|
17
|
+
* Returns a JS expression that evaluates to:
|
|
18
|
+
* { ok: true, el: Element } — success (el is assigned to `__resolved`)
|
|
19
|
+
* { ok: false, code, message, hint, candidates } — structured error
|
|
20
|
+
*
|
|
21
|
+
* The resolved element is stored in `__resolved` for the caller to use.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveTargetJs(ref) {
|
|
24
|
+
const safeRef = JSON.stringify(ref);
|
|
25
|
+
return `
|
|
26
|
+
(() => {
|
|
27
|
+
const ref = ${safeRef};
|
|
28
|
+
const identity = window.__opencli_ref_identity || {};
|
|
29
|
+
|
|
30
|
+
// ── Classify input ──
|
|
31
|
+
const isNumeric = /^\\d+$/.test(ref);
|
|
32
|
+
const isCssLike = !isNumeric && /^[a-zA-Z#.\\[]/.test(ref);
|
|
33
|
+
|
|
34
|
+
if (isNumeric) {
|
|
35
|
+
// ── Ref path ──
|
|
36
|
+
let el = document.querySelector('[data-opencli-ref="' + ref + '"]');
|
|
37
|
+
if (!el) el = document.querySelector('[data-ref="' + ref + '"]');
|
|
38
|
+
|
|
39
|
+
if (!el) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
code: 'not_found',
|
|
43
|
+
message: 'ref=' + ref + ' not found in DOM',
|
|
44
|
+
hint: 'The element may have been removed. Re-run \`opencli browser state\` to get a fresh snapshot.',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Fingerprint verification (identity vector) ──
|
|
49
|
+
const fp = identity[ref];
|
|
50
|
+
if (fp) {
|
|
51
|
+
const tag = el.tagName.toLowerCase();
|
|
52
|
+
const text = (el.textContent || '').trim().slice(0, 30);
|
|
53
|
+
const role = el.getAttribute('role') || '';
|
|
54
|
+
const ariaLabel = el.getAttribute('aria-label') || '';
|
|
55
|
+
const id = el.id || '';
|
|
56
|
+
const testId = el.getAttribute('data-testid') || el.getAttribute('data-test') || '';
|
|
57
|
+
|
|
58
|
+
// Hard fail: tag must always match
|
|
59
|
+
const tagMatch = fp.tag === tag;
|
|
60
|
+
|
|
61
|
+
// Soft signals: each non-empty stored field that mismatches counts against
|
|
62
|
+
var mismatches = 0;
|
|
63
|
+
var checks = 0;
|
|
64
|
+
if (fp.id) { checks++; if (fp.id !== id) mismatches++; }
|
|
65
|
+
if (fp.testId) { checks++; if (fp.testId !== testId) mismatches++; }
|
|
66
|
+
if (fp.ariaLabel) { checks++; if (fp.ariaLabel !== ariaLabel) mismatches++; }
|
|
67
|
+
if (fp.role) { checks++; if (fp.role !== role) mismatches++; }
|
|
68
|
+
if (fp.text) {
|
|
69
|
+
checks++;
|
|
70
|
+
// Text: allow prefix match (page text can grow), but empty current text never matches
|
|
71
|
+
if (!text || (!text.startsWith(fp.text) && !fp.text.startsWith(text))) mismatches++;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Stale if tag changed, or if any uniquely identifying field (id/testId) changed,
|
|
75
|
+
// or if majority of soft signals mismatch
|
|
76
|
+
var isStale = !tagMatch;
|
|
77
|
+
if (!isStale && checks > 0) {
|
|
78
|
+
// id and testId are strong identifiers — any mismatch on these is decisive
|
|
79
|
+
if (fp.id && fp.id !== id) isStale = true;
|
|
80
|
+
else if (fp.testId && fp.testId !== testId) isStale = true;
|
|
81
|
+
// For remaining signals, stale if more than half mismatch
|
|
82
|
+
else if (mismatches > checks / 2) isStale = true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (isStale) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
code: 'stale_ref',
|
|
89
|
+
message: 'ref=' + ref + ' was <' + fp.tag + '>' + (fp.text ? '"' + fp.text + '"' : '')
|
|
90
|
+
+ ' but now points to <' + tag + '>' + (text ? '"' + text.slice(0, 30) + '"' : ''),
|
|
91
|
+
hint: 'The page has changed since the last snapshot. Re-run \`opencli browser state\` to refresh.',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
window.__resolved = el;
|
|
97
|
+
return { ok: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isCssLike) {
|
|
101
|
+
// ── CSS selector path ──
|
|
102
|
+
let matches;
|
|
103
|
+
try {
|
|
104
|
+
matches = document.querySelectorAll(ref);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
code: 'not_found',
|
|
109
|
+
message: 'Invalid CSS selector: ' + ref,
|
|
110
|
+
hint: 'Check the selector syntax. Use ref numbers from snapshot for reliable targeting.',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (matches.length === 0) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
code: 'not_found',
|
|
118
|
+
message: 'CSS selector "' + ref + '" matched 0 elements',
|
|
119
|
+
hint: 'The element may not exist or may be hidden. Re-run \`opencli browser state\` to check.',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (matches.length > 1) {
|
|
124
|
+
const candidates = [];
|
|
125
|
+
const limit = Math.min(matches.length, 5);
|
|
126
|
+
for (let i = 0; i < limit; i++) {
|
|
127
|
+
const m = matches[i];
|
|
128
|
+
const tag = m.tagName.toLowerCase();
|
|
129
|
+
const text = (m.textContent || '').trim().slice(0, 40);
|
|
130
|
+
const id = m.id ? '#' + m.id : '';
|
|
131
|
+
candidates.push('<' + tag + id + '>' + (text ? ' "' + text + '"' : ''));
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
code: 'ambiguous',
|
|
136
|
+
message: 'CSS selector "' + ref + '" matched ' + matches.length + ' elements',
|
|
137
|
+
hint: 'Use a more specific selector, or use ref numbers from \`opencli browser state\` snapshot.',
|
|
138
|
+
candidates: candidates,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
window.__resolved = matches[0];
|
|
143
|
+
return { ok: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Unrecognized input ──
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
code: 'not_found',
|
|
150
|
+
message: 'Cannot parse target: ' + ref,
|
|
151
|
+
hint: 'Use a numeric ref from snapshot (e.g. "12") or a CSS selector (e.g. "#submit").',
|
|
152
|
+
};
|
|
153
|
+
})()
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Generate JS for click that uses the unified resolver.
|
|
158
|
+
* Assumes resolveTargetJs has been called and __resolved is set.
|
|
159
|
+
*/
|
|
160
|
+
export function clickResolvedJs() {
|
|
161
|
+
return `
|
|
162
|
+
(() => {
|
|
163
|
+
const el = window.__resolved;
|
|
164
|
+
if (!el) throw new Error('No resolved element');
|
|
165
|
+
el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
166
|
+
const rect = el.getBoundingClientRect();
|
|
167
|
+
const x = Math.round(rect.left + rect.width / 2);
|
|
168
|
+
const y = Math.round(rect.top + rect.height / 2);
|
|
169
|
+
try {
|
|
170
|
+
el.click();
|
|
171
|
+
return { status: 'clicked', x, y, w: Math.round(rect.width), h: Math.round(rect.height) };
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return { status: 'js_failed', x, y, w: Math.round(rect.width), h: Math.round(rect.height), error: e.message };
|
|
174
|
+
}
|
|
175
|
+
})()
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Generate JS for type that uses the unified resolver.
|
|
180
|
+
*/
|
|
181
|
+
export function typeResolvedJs(text) {
|
|
182
|
+
const safeText = JSON.stringify(text);
|
|
183
|
+
return `
|
|
184
|
+
(() => {
|
|
185
|
+
const el = window.__resolved;
|
|
186
|
+
if (!el) throw new Error('No resolved element');
|
|
187
|
+
el.focus();
|
|
188
|
+
if (el.isContentEditable) {
|
|
189
|
+
const sel = window.getSelection();
|
|
190
|
+
const range = document.createRange();
|
|
191
|
+
range.selectNodeContents(el);
|
|
192
|
+
sel.removeAllRanges();
|
|
193
|
+
sel.addRange(range);
|
|
194
|
+
document.execCommand('delete', false);
|
|
195
|
+
document.execCommand('insertText', false, ${safeText});
|
|
196
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
197
|
+
} else {
|
|
198
|
+
const proto = el instanceof HTMLTextAreaElement
|
|
199
|
+
? HTMLTextAreaElement.prototype
|
|
200
|
+
: HTMLInputElement.prototype;
|
|
201
|
+
const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
202
|
+
if (nativeSetter) {
|
|
203
|
+
nativeSetter.call(el, ${safeText});
|
|
204
|
+
} else {
|
|
205
|
+
el.value = ${safeText};
|
|
206
|
+
}
|
|
207
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
208
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
209
|
+
}
|
|
210
|
+
return 'typed';
|
|
211
|
+
})()
|
|
212
|
+
`;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Generate JS for scrollTo that uses the unified resolver.
|
|
216
|
+
* Assumes resolveTargetJs has been called and __resolved is set.
|
|
217
|
+
*/
|
|
218
|
+
export function scrollResolvedJs() {
|
|
219
|
+
return `
|
|
220
|
+
(() => {
|
|
221
|
+
const el = window.__resolved;
|
|
222
|
+
if (!el) throw new Error('No resolved element');
|
|
223
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
|
|
224
|
+
return { scrolled: true, tag: el.tagName.toLowerCase(), text: (el.textContent || '').trim().slice(0, 80) };
|
|
225
|
+
})()
|
|
226
|
+
`;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Generate JS to get text content of resolved element.
|
|
230
|
+
*/
|
|
231
|
+
export function getTextResolvedJs() {
|
|
232
|
+
return `
|
|
233
|
+
(() => {
|
|
234
|
+
const el = window.__resolved;
|
|
235
|
+
if (!el) throw new Error('No resolved element');
|
|
236
|
+
return el.textContent?.trim() ?? null;
|
|
237
|
+
})()
|
|
238
|
+
`;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Generate JS to get value of resolved input/textarea element.
|
|
242
|
+
*/
|
|
243
|
+
export function getValueResolvedJs() {
|
|
244
|
+
return `
|
|
245
|
+
(() => {
|
|
246
|
+
const el = window.__resolved;
|
|
247
|
+
if (!el) throw new Error('No resolved element');
|
|
248
|
+
return el.value ?? null;
|
|
249
|
+
})()
|
|
250
|
+
`;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Generate JS to get all attributes of resolved element.
|
|
254
|
+
*/
|
|
255
|
+
export function getAttributesResolvedJs() {
|
|
256
|
+
return `
|
|
257
|
+
(() => {
|
|
258
|
+
const el = window.__resolved;
|
|
259
|
+
if (!el) throw new Error('No resolved element');
|
|
260
|
+
return JSON.stringify(Object.fromEntries([...el.attributes].map(a => [a.name, a.value])));
|
|
261
|
+
})()
|
|
262
|
+
`;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Generate JS to select an option on a resolved <select> element.
|
|
266
|
+
*/
|
|
267
|
+
export function selectResolvedJs(option) {
|
|
268
|
+
const safeOption = JSON.stringify(option);
|
|
269
|
+
return `
|
|
270
|
+
(() => {
|
|
271
|
+
const el = window.__resolved;
|
|
272
|
+
if (!el) throw new Error('No resolved element');
|
|
273
|
+
if (el.tagName !== 'SELECT') return { error: 'Not a <select>' };
|
|
274
|
+
const match = Array.from(el.options).find(o => o.text.trim() === ${safeOption} || o.value === ${safeOption});
|
|
275
|
+
if (!match) return { error: 'Option not found', available: Array.from(el.options).map(o => o.text.trim()) };
|
|
276
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
|
277
|
+
if (setter) setter.call(el, match.value); else el.value = match.value;
|
|
278
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
279
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
280
|
+
return { selected: match.text };
|
|
281
|
+
})()
|
|
282
|
+
`;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Generate JS to check if resolved element is an autocomplete/combobox field.
|
|
286
|
+
*/
|
|
287
|
+
export function isAutocompleteResolvedJs() {
|
|
288
|
+
return `
|
|
289
|
+
(() => {
|
|
290
|
+
const el = window.__resolved;
|
|
291
|
+
if (!el) return false;
|
|
292
|
+
const role = el.getAttribute('role');
|
|
293
|
+
const ac = el.getAttribute('aria-autocomplete');
|
|
294
|
+
const list = el.getAttribute('list');
|
|
295
|
+
return role === 'combobox' || ac === 'list' || ac === 'both' || !!list;
|
|
296
|
+
})()
|
|
297
|
+
`;
|
|
298
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolveTargetJs } from './target-resolver.js';
|
|
3
|
+
/**
|
|
4
|
+
* Tests for the target resolver JS generator.
|
|
5
|
+
*
|
|
6
|
+
* Since resolveTargetJs() produces JS strings for browser evaluate(),
|
|
7
|
+
* we test the generated JS by running it in a simulated DOM-like context
|
|
8
|
+
* and verifying the structure of the output.
|
|
9
|
+
*/
|
|
10
|
+
describe('resolveTargetJs', () => {
|
|
11
|
+
it('generates JS that returns structured resolution for numeric ref', () => {
|
|
12
|
+
const js = resolveTargetJs('12');
|
|
13
|
+
expect(js).toContain('data-opencli-ref');
|
|
14
|
+
expect(js).toContain('__opencli_ref_identity');
|
|
15
|
+
expect(js).toContain('"12"');
|
|
16
|
+
});
|
|
17
|
+
it('generates JS that handles CSS selector input', () => {
|
|
18
|
+
const js = resolveTargetJs('#submit-btn');
|
|
19
|
+
expect(js).toContain('querySelectorAll');
|
|
20
|
+
expect(js).toContain('"#submit-btn"');
|
|
21
|
+
});
|
|
22
|
+
it('generates JS with stale_ref detection for numeric refs', () => {
|
|
23
|
+
const js = resolveTargetJs('5');
|
|
24
|
+
expect(js).toContain('stale_ref');
|
|
25
|
+
expect(js).toContain('__opencli_ref_identity');
|
|
26
|
+
});
|
|
27
|
+
it('generates JS with ambiguity detection for CSS selectors', () => {
|
|
28
|
+
const js = resolveTargetJs('.btn');
|
|
29
|
+
expect(js).toContain('ambiguous');
|
|
30
|
+
expect(js).toContain('candidates');
|
|
31
|
+
});
|
|
32
|
+
it('generates JS that rejects unrecognized input', () => {
|
|
33
|
+
const js = resolveTargetJs('???');
|
|
34
|
+
expect(js).toContain('not_found');
|
|
35
|
+
expect(js).toContain('Cannot parse target');
|
|
36
|
+
});
|
|
37
|
+
it('escapes ref value safely', () => {
|
|
38
|
+
const js = resolveTargetJs('"; alert(1); "');
|
|
39
|
+
// JSON.stringify should handle escaping
|
|
40
|
+
expect(js).not.toContain('alert(1); "');
|
|
41
|
+
expect(js).toContain('\\"');
|
|
42
|
+
});
|
|
43
|
+
});
|
package/dist/src/browser.test.js
CHANGED
|
@@ -112,13 +112,15 @@ describe('BrowserBridge state', () => {
|
|
|
112
112
|
bridge._state = 'closing';
|
|
113
113
|
await expect(bridge.connect()).rejects.toThrow('Session is closing');
|
|
114
114
|
});
|
|
115
|
-
it('fails fast when daemon is running but extension is disconnected', async () => {
|
|
115
|
+
it('fails fast when daemon is running but extension is disconnected (same version)', async () => {
|
|
116
|
+
const { PKG_VERSION } = await import('./version.js');
|
|
116
117
|
vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
|
|
117
118
|
state: 'no-extension',
|
|
118
119
|
status: {
|
|
119
120
|
ok: true,
|
|
120
121
|
pid: 1,
|
|
121
122
|
uptime: 0,
|
|
123
|
+
daemonVersion: PKG_VERSION,
|
|
122
124
|
extensionConnected: false,
|
|
123
125
|
pending: 0,
|
|
124
126
|
memoryMB: 0,
|
|
@@ -128,6 +130,41 @@ describe('BrowserBridge state', () => {
|
|
|
128
130
|
const bridge = new BrowserBridge();
|
|
129
131
|
await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected');
|
|
130
132
|
});
|
|
133
|
+
it('attempts stale daemon replacement when daemonVersion is missing', async () => {
|
|
134
|
+
vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
|
|
135
|
+
state: 'no-extension',
|
|
136
|
+
status: {
|
|
137
|
+
ok: true,
|
|
138
|
+
pid: 1,
|
|
139
|
+
uptime: 0,
|
|
140
|
+
extensionConnected: false,
|
|
141
|
+
pending: 0,
|
|
142
|
+
memoryMB: 0,
|
|
143
|
+
port: 0,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false);
|
|
147
|
+
const bridge = new BrowserBridge();
|
|
148
|
+
await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
|
|
149
|
+
});
|
|
150
|
+
it('attempts stale daemon replacement when daemonVersion mismatches', async () => {
|
|
151
|
+
vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
|
|
152
|
+
state: 'no-extension',
|
|
153
|
+
status: {
|
|
154
|
+
ok: true,
|
|
155
|
+
pid: 1,
|
|
156
|
+
uptime: 0,
|
|
157
|
+
daemonVersion: '0.0.1',
|
|
158
|
+
extensionConnected: false,
|
|
159
|
+
pending: 0,
|
|
160
|
+
memoryMB: 0,
|
|
161
|
+
port: 0,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false);
|
|
165
|
+
const bridge = new BrowserBridge();
|
|
166
|
+
await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
|
|
167
|
+
});
|
|
131
168
|
});
|
|
132
169
|
describe('stealth anti-detection', () => {
|
|
133
170
|
it('generates non-empty JS string', () => {
|
package/dist/src/cli.js
CHANGED
|
@@ -18,8 +18,10 @@ import { PKG_VERSION } from './version.js';
|
|
|
18
18
|
import { printCompletionScript } from './completion.js';
|
|
19
19
|
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
|
|
20
20
|
import { registerAllCommands } from './commanderAdapter.js';
|
|
21
|
-
import { EXIT_CODES, getErrorMessage } from './errors.js';
|
|
22
|
-
import {
|
|
21
|
+
import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js';
|
|
22
|
+
import { TargetError } from './browser/target-errors.js';
|
|
23
|
+
import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs } from './browser/target-resolver.js';
|
|
24
|
+
import { daemonStatus, daemonStop } from './commands/daemon.js';
|
|
23
25
|
import { log } from './logger.js';
|
|
24
26
|
const CLI_FILE = fileURLToPath(import.meta.url);
|
|
25
27
|
/** Create a browser page for browser commands. Uses a dedicated browser workspace for session persistence. */
|
|
@@ -249,6 +251,13 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
249
251
|
const browser = program
|
|
250
252
|
.command('browser')
|
|
251
253
|
.description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
|
|
254
|
+
/** Resolve a ref/CSS target via the unified resolver, throwing TargetError on failure. */
|
|
255
|
+
async function resolveRef(page, ref) {
|
|
256
|
+
const resolution = await page.evaluate(resolveTargetJs(ref));
|
|
257
|
+
if (!resolution.ok) {
|
|
258
|
+
throw new TargetError(resolution);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
252
261
|
/** Wrap browser actions with error handling and optional --json output */
|
|
253
262
|
function browserAction(fn) {
|
|
254
263
|
return async (...args) => {
|
|
@@ -257,15 +266,28 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
257
266
|
await fn(page, ...args);
|
|
258
267
|
}
|
|
259
268
|
catch (err) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
269
|
+
if (err instanceof BrowserConnectError) {
|
|
270
|
+
log.error(err.message);
|
|
271
|
+
if (err.hint)
|
|
272
|
+
log.error(`Hint: ${err.hint}`);
|
|
263
273
|
}
|
|
264
|
-
else if (
|
|
265
|
-
log.error(`
|
|
274
|
+
else if (err instanceof TargetError) {
|
|
275
|
+
log.error(`[${err.code}] ${err.message}`);
|
|
276
|
+
if (err.hint)
|
|
277
|
+
log.error(`Hint: ${err.hint}`);
|
|
278
|
+
if (err.candidates?.length) {
|
|
279
|
+
log.error('Candidates:');
|
|
280
|
+
err.candidates.forEach((c, i) => log.error(` ${i + 1}. ${c}`));
|
|
281
|
+
}
|
|
266
282
|
}
|
|
267
283
|
else {
|
|
268
|
-
|
|
284
|
+
const msg = getErrorMessage(err);
|
|
285
|
+
if (msg.includes('attach failed') || msg.includes('chrome-extension://')) {
|
|
286
|
+
log.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
log.error(msg);
|
|
290
|
+
}
|
|
269
291
|
}
|
|
270
292
|
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
271
293
|
}
|
|
@@ -277,7 +299,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
277
299
|
browser.command('open').argument('<url>').description('Open URL in automation window')
|
|
278
300
|
.action(browserAction(async (page, url) => {
|
|
279
301
|
// Start session-level capture before navigation (catches initial requests)
|
|
280
|
-
const hasSessionCapture = await page.startNetworkCapture?.()
|
|
302
|
+
const hasSessionCapture = await page.startNetworkCapture?.() ?? false;
|
|
281
303
|
await page.goto(url);
|
|
282
304
|
await page.wait(2);
|
|
283
305
|
// Fallback: inject JS interceptor when session capture is unavailable
|
|
@@ -337,12 +359,14 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
337
359
|
}));
|
|
338
360
|
get.command('text').argument('<index>', 'Element index').description('Element text content')
|
|
339
361
|
.action(browserAction(async (page, index) => {
|
|
340
|
-
|
|
362
|
+
await resolveRef(page, String(index));
|
|
363
|
+
const text = await page.evaluate(getTextResolvedJs());
|
|
341
364
|
console.log(text ?? '(empty)');
|
|
342
365
|
}));
|
|
343
366
|
get.command('value').argument('<index>', 'Element index').description('Input/textarea value')
|
|
344
367
|
.action(browserAction(async (page, index) => {
|
|
345
|
-
|
|
368
|
+
await resolveRef(page, String(index));
|
|
369
|
+
const val = await page.evaluate(getValueResolvedJs());
|
|
346
370
|
console.log(val ?? '(empty)');
|
|
347
371
|
}));
|
|
348
372
|
get.command('html').option('--selector <css>', 'CSS selector scope').description('Page HTML (or scoped)')
|
|
@@ -353,7 +377,8 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
353
377
|
}));
|
|
354
378
|
get.command('attributes').argument('<index>', 'Element index').description('Element attributes')
|
|
355
379
|
.action(browserAction(async (page, index) => {
|
|
356
|
-
|
|
380
|
+
await resolveRef(page, String(index));
|
|
381
|
+
const attrs = await page.evaluate(getAttributesResolvedJs());
|
|
357
382
|
console.log(attrs ?? '{}');
|
|
358
383
|
}));
|
|
359
384
|
// ── Interact ──
|
|
@@ -369,16 +394,8 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
369
394
|
await page.wait(0.3);
|
|
370
395
|
await page.typeText(index, text);
|
|
371
396
|
// Detect autocomplete/combobox fields and wait for dropdown suggestions
|
|
372
|
-
|
|
373
|
-
(()
|
|
374
|
-
const el = document.querySelector('[data-opencli-ref="${index}"]');
|
|
375
|
-
if (!el) return false;
|
|
376
|
-
const role = el.getAttribute('role');
|
|
377
|
-
const ac = el.getAttribute('aria-autocomplete');
|
|
378
|
-
const list = el.getAttribute('list');
|
|
379
|
-
return role === 'combobox' || ac === 'list' || ac === 'both' || !!list;
|
|
380
|
-
})()
|
|
381
|
-
`);
|
|
397
|
+
// __resolved is already set by typeText's resolver call
|
|
398
|
+
const isAutocomplete = await page.evaluate(isAutocompleteResolvedJs());
|
|
382
399
|
if (isAutocomplete) {
|
|
383
400
|
await page.wait(0.4);
|
|
384
401
|
console.log(`Typed "${text}" into autocomplete [${index}] — use state to see suggestions`);
|
|
@@ -390,19 +407,8 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
390
407
|
browser.command('select').argument('<index>', 'Element index of <select>').argument('<option>', 'Option text')
|
|
391
408
|
.description('Select dropdown option')
|
|
392
409
|
.action(browserAction(async (page, index, option) => {
|
|
393
|
-
|
|
394
|
-
(
|
|
395
|
-
var sel = document.querySelector('[data-opencli-ref="${index}"]');
|
|
396
|
-
if (!sel || sel.tagName !== 'SELECT') return { error: 'Not a <select>' };
|
|
397
|
-
var match = Array.from(sel.options).find(o => o.text.trim() === ${JSON.stringify(option)} || o.value === ${JSON.stringify(option)});
|
|
398
|
-
if (!match) return { error: 'Option not found', available: Array.from(sel.options).map(o => o.text.trim()) };
|
|
399
|
-
var setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
|
400
|
-
if (setter) setter.call(sel, match.value); else sel.value = match.value;
|
|
401
|
-
sel.dispatchEvent(new Event('input', {bubbles:true}));
|
|
402
|
-
sel.dispatchEvent(new Event('change', {bubbles:true}));
|
|
403
|
-
return { selected: match.text };
|
|
404
|
-
})()
|
|
405
|
-
`);
|
|
410
|
+
await resolveRef(page, String(index));
|
|
411
|
+
const result = await page.evaluate(selectResolvedJs(option));
|
|
406
412
|
if (result?.error) {
|
|
407
413
|
console.error(`Error: ${result.error}${result.available ? ` — Available: ${result.available.join(', ')}` : ''}`);
|
|
408
414
|
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
@@ -1001,6 +1007,10 @@ cli({
|
|
|
1001
1007
|
});
|
|
1002
1008
|
// ── Built-in: daemon ──────────────────────────────────────────────────────
|
|
1003
1009
|
const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
|
|
1010
|
+
daemonCmd
|
|
1011
|
+
.command('status')
|
|
1012
|
+
.description('Show daemon status')
|
|
1013
|
+
.action(async () => { await daemonStatus(); });
|
|
1004
1014
|
daemonCmd
|
|
1005
1015
|
.command('stop')
|
|
1006
1016
|
.description('Stop the daemon')
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI
|
|
3
|
-
* opencli daemon
|
|
2
|
+
* CLI commands for daemon lifecycle:
|
|
3
|
+
* opencli daemon status — show daemon state
|
|
4
|
+
* opencli daemon stop — graceful shutdown
|
|
4
5
|
*/
|
|
6
|
+
export declare function daemonStatus(): Promise<void>;
|
|
5
7
|
export declare function daemonStop(): Promise<void>;
|
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI
|
|
3
|
-
* opencli daemon
|
|
2
|
+
* CLI commands for daemon lifecycle:
|
|
3
|
+
* opencli daemon status — show daemon state
|
|
4
|
+
* opencli daemon stop — graceful shutdown
|
|
4
5
|
*/
|
|
6
|
+
import { styleText } from 'node:util';
|
|
5
7
|
import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
|
|
8
|
+
import { formatDuration } from '../download/progress.js';
|
|
6
9
|
import { log } from '../logger.js';
|
|
10
|
+
export async function daemonStatus() {
|
|
11
|
+
const status = await fetchDaemonStatus();
|
|
12
|
+
if (!status) {
|
|
13
|
+
console.log(`Daemon: ${styleText('dim', 'not running')}`);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const extensionLabel = !status.extensionConnected
|
|
17
|
+
? styleText('yellow', 'disconnected')
|
|
18
|
+
: status.extensionVersion
|
|
19
|
+
? `${styleText('green', 'connected')} ${styleText('dim', `(v${status.extensionVersion})`)}`
|
|
20
|
+
: `${styleText('yellow', 'connected')} ${styleText('dim', '(version unknown)')}`;
|
|
21
|
+
console.log(`Daemon: ${styleText('green', 'running')} (PID ${status.pid})`);
|
|
22
|
+
console.log(`Uptime: ${formatDuration(Math.round(status.uptime * 1000))}`);
|
|
23
|
+
console.log(`Extension: ${extensionLabel}`);
|
|
24
|
+
console.log(`Memory: ${status.memoryMB} MB`);
|
|
25
|
+
console.log(`Port: ${status.port}`);
|
|
26
|
+
}
|
|
7
27
|
export async function daemonStop() {
|
|
8
28
|
const status = await fetchDaemonStatus();
|
|
9
29
|
if (!status) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
const { fetchDaemonStatusMock, requestDaemonShutdownMock, } = vi.hoisted(() => ({
|
|
3
3
|
fetchDaemonStatusMock: vi.fn(),
|
|
4
4
|
requestDaemonShutdownMock: vi.fn(),
|
|
@@ -7,7 +7,70 @@ vi.mock('../browser/daemon-client.js', () => ({
|
|
|
7
7
|
fetchDaemonStatus: fetchDaemonStatusMock,
|
|
8
8
|
requestDaemonShutdown: requestDaemonShutdownMock,
|
|
9
9
|
}));
|
|
10
|
-
import { daemonStop } from './daemon.js';
|
|
10
|
+
import { daemonStatus, daemonStop } from './daemon.js';
|
|
11
|
+
describe('daemonStatus', () => {
|
|
12
|
+
let stdoutSpy;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
15
|
+
fetchDaemonStatusMock.mockReset();
|
|
16
|
+
requestDaemonShutdownMock.mockReset();
|
|
17
|
+
});
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
it('reports "not running" when daemon is unreachable', async () => {
|
|
22
|
+
fetchDaemonStatusMock.mockResolvedValue(null);
|
|
23
|
+
await daemonStatus();
|
|
24
|
+
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
|
|
25
|
+
});
|
|
26
|
+
it('shows daemon info when running', async () => {
|
|
27
|
+
fetchDaemonStatusMock.mockResolvedValue({
|
|
28
|
+
ok: true,
|
|
29
|
+
pid: 12345,
|
|
30
|
+
uptime: 3661,
|
|
31
|
+
extensionConnected: true,
|
|
32
|
+
extensionVersion: '1.6.8',
|
|
33
|
+
pending: 0,
|
|
34
|
+
memoryMB: 64,
|
|
35
|
+
port: 19825,
|
|
36
|
+
});
|
|
37
|
+
await daemonStatus();
|
|
38
|
+
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('running'));
|
|
39
|
+
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('PID 12345'));
|
|
40
|
+
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('1h 1m'));
|
|
41
|
+
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('connected'));
|
|
42
|
+
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('v1.6.8'));
|
|
43
|
+
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('64 MB'));
|
|
44
|
+
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('19825'));
|
|
45
|
+
});
|
|
46
|
+
it('shows disconnected when extension is not connected', async () => {
|
|
47
|
+
fetchDaemonStatusMock.mockResolvedValue({
|
|
48
|
+
ok: true,
|
|
49
|
+
pid: 99,
|
|
50
|
+
uptime: 120,
|
|
51
|
+
extensionConnected: false,
|
|
52
|
+
pending: 0,
|
|
53
|
+
memoryMB: 32,
|
|
54
|
+
port: 19825,
|
|
55
|
+
});
|
|
56
|
+
await daemonStatus();
|
|
57
|
+
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('disconnected'));
|
|
58
|
+
});
|
|
59
|
+
it('shows version unknown when the connected extension does not report one', async () => {
|
|
60
|
+
fetchDaemonStatusMock.mockResolvedValue({
|
|
61
|
+
ok: true,
|
|
62
|
+
pid: 99,
|
|
63
|
+
uptime: 120,
|
|
64
|
+
extensionConnected: true,
|
|
65
|
+
extensionVersion: undefined,
|
|
66
|
+
pending: 0,
|
|
67
|
+
memoryMB: 32,
|
|
68
|
+
port: 19825,
|
|
69
|
+
});
|
|
70
|
+
await daemonStatus();
|
|
71
|
+
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('version unknown'));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
11
74
|
describe('daemonStop', () => {
|
|
12
75
|
let stderrSpy;
|
|
13
76
|
beforeEach(() => {
|