@jackwener/opencli 1.7.14 → 1.7.15
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/cli-manifest.json +215 -45
- package/clis/bilibili/subtitle.js +1 -1
- package/clis/dianping/cityResolver.js +185 -0
- package/clis/dianping/dianping.test.js +154 -0
- package/clis/dianping/search.js +6 -3
- package/clis/douyin/_shared/browser-fetch.js +14 -2
- package/clis/douyin/_shared/browser-fetch.test.js +13 -0
- package/clis/douyin/stats.js +1 -1
- package/clis/douyin/update.js +1 -1
- package/clis/jike/search.js +1 -1
- package/clis/reddit/search.js +1 -1
- package/clis/reddit/subreddit.js +1 -1
- package/clis/reddit/user-comments.js +1 -1
- package/clis/reddit/user-posts.js +1 -1
- package/clis/reddit/user.js +1 -1
- package/clis/twitter/article.js +2 -1
- package/clis/twitter/bookmark-folder.js +189 -0
- package/clis/twitter/bookmark-folder.test.js +334 -0
- package/clis/twitter/bookmark-folders.js +117 -0
- package/clis/twitter/bookmark-folders.test.js +150 -0
- package/clis/twitter/bookmark.js +15 -6
- package/clis/twitter/bookmark.test.js +74 -0
- package/clis/twitter/bookmarks.js +7 -5
- package/clis/twitter/delete.js +11 -35
- package/clis/twitter/delete.test.js +21 -9
- package/clis/twitter/download.js +5 -5
- package/clis/twitter/followers.js +9 -3
- package/clis/twitter/following.js +11 -5
- package/clis/twitter/hide-reply.js +24 -5
- package/clis/twitter/hide-reply.test.js +76 -0
- package/clis/twitter/like.js +21 -11
- package/clis/twitter/like.test.js +73 -0
- package/clis/twitter/likes.js +8 -6
- package/clis/twitter/list-add.js +4 -4
- package/clis/twitter/list-remove.js +4 -4
- package/clis/twitter/list-tweets.js +6 -4
- package/clis/twitter/lists.js +3 -3
- package/clis/twitter/notifications.js +2 -2
- package/clis/twitter/profile.js +4 -3
- package/clis/twitter/quote.js +60 -32
- package/clis/twitter/quote.test.js +96 -8
- package/clis/twitter/reply.js +24 -178
- package/clis/twitter/reply.test.js +29 -11
- package/clis/twitter/retweet.js +9 -14
- package/clis/twitter/retweet.test.js +5 -1
- package/clis/twitter/search.js +175 -23
- package/clis/twitter/search.test.js +266 -1
- package/clis/twitter/shared.js +43 -0
- package/clis/twitter/shared.test.js +107 -1
- package/clis/twitter/thread.js +6 -4
- package/clis/twitter/timeline.js +8 -6
- package/clis/twitter/tweets.js +5 -3
- package/clis/twitter/unbookmark.js +13 -6
- package/clis/twitter/unbookmark.test.js +73 -0
- package/clis/twitter/unlike.js +6 -13
- package/clis/twitter/unlike.test.js +5 -2
- package/clis/twitter/unretweet.js +9 -14
- package/clis/twitter/unretweet.test.js +5 -1
- package/clis/twitter/utils.js +286 -0
- package/clis/twitter/utils.test.js +169 -0
- package/dist/src/browser/ax-snapshot.d.ts +37 -0
- package/dist/src/browser/ax-snapshot.js +217 -0
- package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
- package/dist/src/browser/ax-snapshot.test.js +91 -0
- package/dist/src/browser/base-page.d.ts +51 -0
- package/dist/src/browser/base-page.js +545 -2
- package/dist/src/browser/base-page.test.js +520 -4
- package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -0
- package/dist/src/browser/cdp.js +5 -0
- package/dist/src/browser/cdp.test.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +3 -1
- package/dist/src/browser/find.d.ts +9 -1
- package/dist/src/browser/find.js +219 -0
- package/dist/src/browser/find.test.js +61 -1
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +13 -0
- package/dist/src/browser/page.test.js +28 -0
- package/dist/src/browser/target-errors.d.ts +3 -1
- package/dist/src/browser/target-errors.js +2 -0
- package/dist/src/browser/target-resolver.d.ts +14 -0
- package/dist/src/browser/target-resolver.js +28 -0
- package/dist/src/browser/visual-refs.d.ts +11 -0
- package/dist/src/browser/visual-refs.js +108 -0
- package/dist/src/build-manifest.d.ts +23 -0
- package/dist/src/build-manifest.js +34 -0
- package/dist/src/build-manifest.test.js +108 -1
- package/dist/src/cli.js +560 -58
- package/dist/src/cli.test.js +598 -0
- package/dist/src/help.d.ts +32 -0
- package/dist/src/help.js +145 -0
- package/dist/src/types.d.ts +82 -0
- package/package.json +1 -1
- package/scripts/typed-error-lint-baseline.json +18 -18
|
@@ -69,6 +69,7 @@ describe('CDPBridge cookies', () => {
|
|
|
69
69
|
['Input.insertText', { text: 'hello' }],
|
|
70
70
|
['Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', modifiers: 2 }],
|
|
71
71
|
['Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', modifiers: 2 }],
|
|
72
|
+
['Input.dispatchMouseEvent', { type: 'mouseMoved', x: 10, y: 20 }],
|
|
72
73
|
['Input.dispatchMouseEvent', { type: 'mousePressed', x: 10, y: 20, button: 'left', clickCount: 1 }],
|
|
73
74
|
['Input.dispatchMouseEvent', { type: 'mouseReleased', x: 10, y: 20, button: 'left', clickCount: 1 }],
|
|
74
75
|
['Page.handleJavaScriptDialog', { accept: true, promptText: 'ok' }],
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { BrowserSessionInfo } from '../types.js';
|
|
7
7
|
export interface DaemonCommand {
|
|
8
8
|
id: string;
|
|
9
|
-
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'cdp' | 'frames';
|
|
9
|
+
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames';
|
|
10
10
|
/** Target page identity (targetId). Cross-layer contract with the extension. */
|
|
11
11
|
page?: string;
|
|
12
12
|
code?: string;
|
|
@@ -32,6 +32,8 @@ export interface DaemonCommand {
|
|
|
32
32
|
text?: string;
|
|
33
33
|
/** URL substring filter pattern for network capture */
|
|
34
34
|
pattern?: string;
|
|
35
|
+
/** Download wait timeout in milliseconds */
|
|
36
|
+
timeoutMs?: number;
|
|
35
37
|
cdpMethod?: string;
|
|
36
38
|
cdpParams?: Record<string, unknown>;
|
|
37
39
|
/** When true, the owned automation container is created in the foreground */
|
|
@@ -57,7 +57,7 @@ export interface FindResult {
|
|
|
57
57
|
}
|
|
58
58
|
export interface FindError {
|
|
59
59
|
error: {
|
|
60
|
-
code: 'invalid_selector' | 'selector_not_found';
|
|
60
|
+
code: 'invalid_selector' | 'selector_not_found' | 'semantic_not_found';
|
|
61
61
|
message: string;
|
|
62
62
|
hint?: string;
|
|
63
63
|
};
|
|
@@ -68,9 +68,17 @@ export interface FindOptions {
|
|
|
68
68
|
/** Max chars of trimmed text per entry. Default 120. */
|
|
69
69
|
textMax?: number;
|
|
70
70
|
}
|
|
71
|
+
export interface SemanticFindOptions extends FindOptions {
|
|
72
|
+
role?: string;
|
|
73
|
+
name?: string;
|
|
74
|
+
label?: string;
|
|
75
|
+
text?: string;
|
|
76
|
+
testid?: string;
|
|
77
|
+
}
|
|
71
78
|
/**
|
|
72
79
|
* Build the browser-side JS that performs the CSS query and emits the
|
|
73
80
|
* FindResult (or FindError) envelope. Evaluated inside `page.evaluate`.
|
|
74
81
|
*/
|
|
75
82
|
export declare function buildFindJs(selector: string, opts?: FindOptions): string;
|
|
83
|
+
export declare function buildSemanticFindJs(opts: SemanticFindOptions): string;
|
|
76
84
|
export declare function isFindError(result: unknown): result is FindError;
|
package/dist/src/browser/find.js
CHANGED
|
@@ -174,6 +174,225 @@ export function buildFindJs(selector, opts = {}) {
|
|
|
174
174
|
})()
|
|
175
175
|
`;
|
|
176
176
|
}
|
|
177
|
+
export function buildSemanticFindJs(opts) {
|
|
178
|
+
const criteria = JSON.stringify({
|
|
179
|
+
role: opts.role ?? '',
|
|
180
|
+
name: opts.name ?? '',
|
|
181
|
+
label: opts.label ?? '',
|
|
182
|
+
text: opts.text ?? '',
|
|
183
|
+
testid: opts.testid ?? '',
|
|
184
|
+
});
|
|
185
|
+
const limit = opts.limit ?? 50;
|
|
186
|
+
const textMax = opts.textMax ?? 120;
|
|
187
|
+
const whitelist = JSON.stringify(FIND_ATTR_WHITELIST);
|
|
188
|
+
return `
|
|
189
|
+
(() => {
|
|
190
|
+
const CRITERIA = ${criteria};
|
|
191
|
+
const LIMIT = ${limit};
|
|
192
|
+
const TEXT_MAX = ${textMax};
|
|
193
|
+
const ATTR_WHITELIST = ${whitelist};
|
|
194
|
+
|
|
195
|
+
${COMPOUND_INFO_JS}
|
|
196
|
+
|
|
197
|
+
function normalize(value) {
|
|
198
|
+
return String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function includesNeedle(value, needle) {
|
|
202
|
+
const n = normalize(needle);
|
|
203
|
+
if (!n) return true;
|
|
204
|
+
return normalize(value).includes(n);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function nativeRole(el) {
|
|
208
|
+
const explicit = el.getAttribute('role');
|
|
209
|
+
if (explicit) return explicit;
|
|
210
|
+
const tag = el.tagName.toLowerCase();
|
|
211
|
+
const type = (el.getAttribute('type') || '').toLowerCase();
|
|
212
|
+
if (tag === 'button') return 'button';
|
|
213
|
+
if (tag === 'a' && el.getAttribute('href')) return 'link';
|
|
214
|
+
if (tag === 'textarea') return 'textbox';
|
|
215
|
+
if (tag === 'select') return 'combobox';
|
|
216
|
+
if (tag === 'option') return 'option';
|
|
217
|
+
if (tag === 'input') {
|
|
218
|
+
if (type === 'button' || type === 'submit' || type === 'reset') return 'button';
|
|
219
|
+
if (type === 'checkbox') return 'checkbox';
|
|
220
|
+
if (type === 'radio') return 'radio';
|
|
221
|
+
if (type === 'range') return 'slider';
|
|
222
|
+
if (type === 'search') return 'searchbox';
|
|
223
|
+
return 'textbox';
|
|
224
|
+
}
|
|
225
|
+
return '';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function labelText(el) {
|
|
229
|
+
const parts = [];
|
|
230
|
+
function cssEscape(value) {
|
|
231
|
+
try {
|
|
232
|
+
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
|
|
233
|
+
} catch (_) {}
|
|
234
|
+
return String(value).replace(/["\\\\]/g, '\\\\$&');
|
|
235
|
+
}
|
|
236
|
+
if (el.id) {
|
|
237
|
+
try {
|
|
238
|
+
const label = document.querySelector('label[for="' + cssEscape(el.id) + '"]');
|
|
239
|
+
if (label) parts.push(label.textContent || '');
|
|
240
|
+
} catch (_) {}
|
|
241
|
+
}
|
|
242
|
+
const parentLabel = el.closest?.('label');
|
|
243
|
+
if (parentLabel) parts.push(parentLabel.textContent || '');
|
|
244
|
+
return parts.join(' ');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function byIdText(ids) {
|
|
248
|
+
if (!ids) return '';
|
|
249
|
+
const parts = [];
|
|
250
|
+
for (const id of String(ids).split(/\\s+/)) {
|
|
251
|
+
if (!id) continue;
|
|
252
|
+
try {
|
|
253
|
+
const el = document.getElementById(id);
|
|
254
|
+
if (el) parts.push(el.textContent || '');
|
|
255
|
+
} catch (_) {}
|
|
256
|
+
}
|
|
257
|
+
return parts.join(' ');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function accessibleName(el) {
|
|
261
|
+
return [
|
|
262
|
+
el.getAttribute('aria-label') || '',
|
|
263
|
+
byIdText(el.getAttribute('aria-labelledby')),
|
|
264
|
+
labelText(el),
|
|
265
|
+
el.getAttribute('alt') || '',
|
|
266
|
+
el.getAttribute('title') || '',
|
|
267
|
+
el.getAttribute('placeholder') || '',
|
|
268
|
+
el.getAttribute('value') || '',
|
|
269
|
+
el.textContent || '',
|
|
270
|
+
].filter(Boolean).join(' ');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function pickAttrs(el) {
|
|
274
|
+
const out = {};
|
|
275
|
+
for (const key of ATTR_WHITELIST) {
|
|
276
|
+
const v = el.getAttribute(key);
|
|
277
|
+
if (v != null && v !== '') out[key] = v;
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function isVisible(el) {
|
|
283
|
+
const rect = el.getBoundingClientRect();
|
|
284
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
285
|
+
try {
|
|
286
|
+
const style = getComputedStyle(el);
|
|
287
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
288
|
+
if (parseFloat(style.opacity || '1') === 0) return false;
|
|
289
|
+
} catch (_) {}
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function fingerprintOf(el) {
|
|
294
|
+
return {
|
|
295
|
+
tag: el.tagName.toLowerCase(),
|
|
296
|
+
role: el.getAttribute('role') || '',
|
|
297
|
+
text: (el.textContent || '').trim().slice(0, 30),
|
|
298
|
+
ariaLabel: el.getAttribute('aria-label') || '',
|
|
299
|
+
id: el.id || '',
|
|
300
|
+
testId: el.getAttribute('data-testid') || el.getAttribute('data-test') || '',
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function matches(el) {
|
|
305
|
+
const role = nativeRole(el);
|
|
306
|
+
const name = accessibleName(el);
|
|
307
|
+
const label = labelText(el);
|
|
308
|
+
const text = el.textContent || '';
|
|
309
|
+
const testid = el.getAttribute('data-testid') || el.getAttribute('data-test') || el.getAttribute('test-id') || '';
|
|
310
|
+
if (CRITERIA.role && normalize(role) !== normalize(CRITERIA.role)) return false;
|
|
311
|
+
if (CRITERIA.name && !includesNeedle(name, CRITERIA.name)) return false;
|
|
312
|
+
if (CRITERIA.label && !includesNeedle(label, CRITERIA.label)) return false;
|
|
313
|
+
if (CRITERIA.text && !includesNeedle(text, CRITERIA.text)) return false;
|
|
314
|
+
if (CRITERIA.testid && !includesNeedle(testid, CRITERIA.testid)) return false;
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const candidates = Array.from(document.querySelectorAll([
|
|
319
|
+
'a[href]',
|
|
320
|
+
'button',
|
|
321
|
+
'input',
|
|
322
|
+
'textarea',
|
|
323
|
+
'select',
|
|
324
|
+
'option',
|
|
325
|
+
'[role]',
|
|
326
|
+
'[aria-label]',
|
|
327
|
+
'[aria-labelledby]',
|
|
328
|
+
'[data-testid]',
|
|
329
|
+
'[data-test]',
|
|
330
|
+
'[test-id]',
|
|
331
|
+
'label',
|
|
332
|
+
'[contenteditable="true"]',
|
|
333
|
+
].join(',')));
|
|
334
|
+
const matchesList = candidates.filter(matches);
|
|
335
|
+
|
|
336
|
+
if (matchesList.length === 0) {
|
|
337
|
+
return {
|
|
338
|
+
error: {
|
|
339
|
+
code: 'semantic_not_found',
|
|
340
|
+
message: 'Semantic locator matched 0 elements',
|
|
341
|
+
hint: 'Try browser state, --source ax, or relax --role/--name/--label/--text/--testid.',
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const identity = (window.__opencli_ref_identity = window.__opencli_ref_identity || {});
|
|
347
|
+
let maxRef = 0;
|
|
348
|
+
for (const k in identity) {
|
|
349
|
+
const n = parseInt(k, 10);
|
|
350
|
+
if (!isNaN(n) && n > maxRef) maxRef = n;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
const tagged = document.querySelectorAll('[data-opencli-ref]');
|
|
354
|
+
for (let t = 0; t < tagged.length; t++) {
|
|
355
|
+
const v = tagged[t].getAttribute('data-opencli-ref');
|
|
356
|
+
const n = v != null && /^\\d+$/.test(v) ? parseInt(v, 10) : NaN;
|
|
357
|
+
if (!isNaN(n) && n > maxRef) maxRef = n;
|
|
358
|
+
}
|
|
359
|
+
} catch (_) {}
|
|
360
|
+
|
|
361
|
+
const take = Math.min(matchesList.length, LIMIT);
|
|
362
|
+
const entries = [];
|
|
363
|
+
for (let i = 0; i < take; i++) {
|
|
364
|
+
const el = matchesList[i];
|
|
365
|
+
const refAttr = el.getAttribute('data-opencli-ref');
|
|
366
|
+
let refNum = refAttr != null && /^\\d+$/.test(refAttr) ? parseInt(refAttr, 10) : null;
|
|
367
|
+
if (refNum === null) {
|
|
368
|
+
refNum = ++maxRef;
|
|
369
|
+
try { el.setAttribute('data-opencli-ref', '' + refNum); } catch (_) {}
|
|
370
|
+
identity['' + refNum] = fingerprintOf(el);
|
|
371
|
+
} else if (!identity['' + refNum]) {
|
|
372
|
+
identity['' + refNum] = fingerprintOf(el);
|
|
373
|
+
}
|
|
374
|
+
const text = (el.textContent || '').trim();
|
|
375
|
+
const entry = {
|
|
376
|
+
nth: i,
|
|
377
|
+
ref: refNum,
|
|
378
|
+
tag: el.tagName.toLowerCase(),
|
|
379
|
+
role: nativeRole(el),
|
|
380
|
+
text: text.length > TEXT_MAX ? text.slice(0, TEXT_MAX) : text,
|
|
381
|
+
attrs: pickAttrs(el),
|
|
382
|
+
visible: isVisible(el),
|
|
383
|
+
};
|
|
384
|
+
const compound = compoundInfoOf(el);
|
|
385
|
+
if (compound) entry.compound = compound;
|
|
386
|
+
entries.push(entry);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
matches_n: matchesList.length,
|
|
391
|
+
entries,
|
|
392
|
+
};
|
|
393
|
+
})()
|
|
394
|
+
`;
|
|
395
|
+
}
|
|
177
396
|
export function isFindError(result) {
|
|
178
397
|
return !!result && typeof result === 'object' && 'error' in result;
|
|
179
398
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
3
|
+
import { buildFindJs, buildSemanticFindJs, FIND_ATTR_WHITELIST, isFindError } from './find.js';
|
|
3
4
|
/**
|
|
4
5
|
* These tests validate the shape and options of the generated JS string
|
|
5
6
|
* (no DOM available in the default vitest unit env). Runtime behavior of
|
|
@@ -118,3 +119,62 @@ describe('isFindError', () => {
|
|
|
118
119
|
expect(isFindError('string')).toBe(false);
|
|
119
120
|
});
|
|
120
121
|
});
|
|
122
|
+
describe('buildSemanticFindJs', () => {
|
|
123
|
+
function runSemanticFind(html, opts) {
|
|
124
|
+
const dom = new JSDOM(html, { runScripts: 'outside-only' });
|
|
125
|
+
return {
|
|
126
|
+
dom,
|
|
127
|
+
result: dom.window.eval(buildSemanticFindJs(opts)),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
it('produces syntactically valid JS and embeds semantic criteria safely', () => {
|
|
131
|
+
const js = buildSemanticFindJs({ role: 'button', name: 'Save "now"', testid: 'submit' });
|
|
132
|
+
expect(() => new Function(`return (${js});`)).not.toThrow();
|
|
133
|
+
expect(js).toContain(JSON.stringify({
|
|
134
|
+
role: 'button',
|
|
135
|
+
name: 'Save "now"',
|
|
136
|
+
label: '',
|
|
137
|
+
text: '',
|
|
138
|
+
testid: 'submit',
|
|
139
|
+
}));
|
|
140
|
+
});
|
|
141
|
+
it('matches native roles, accessible name, labels, text, and test ids', () => {
|
|
142
|
+
const js = buildSemanticFindJs({ role: 'button', name: 'Save', label: 'Category', text: 'Travel', testid: 'category' });
|
|
143
|
+
expect(js).toContain('function nativeRole(el)');
|
|
144
|
+
expect(js).toContain('function accessibleName(el)');
|
|
145
|
+
expect(js).toContain('function labelText(el)');
|
|
146
|
+
expect(js).toContain('CRITERIA.role');
|
|
147
|
+
expect(js).toContain('CRITERIA.name');
|
|
148
|
+
expect(js).toContain('CRITERIA.label');
|
|
149
|
+
expect(js).toContain('CRITERIA.text');
|
|
150
|
+
expect(js).toContain('CRITERIA.testid');
|
|
151
|
+
});
|
|
152
|
+
it('allocates refs exactly like CSS find so downstream actions can click them', () => {
|
|
153
|
+
const js = buildSemanticFindJs({ role: 'button', name: 'Save' });
|
|
154
|
+
expect(js).toContain("el.setAttribute('data-opencli-ref'");
|
|
155
|
+
expect(js).toContain('__opencli_ref_identity');
|
|
156
|
+
expect(js).toContain("identity['' + refNum] = fingerprintOf(el)");
|
|
157
|
+
expect(js).toContain("document.querySelectorAll('[data-opencli-ref]')");
|
|
158
|
+
});
|
|
159
|
+
it('executes semantic role/name/testid matching and allocates a clickable ref', () => {
|
|
160
|
+
const { dom, result } = runSemanticFind('<button aria-label="Save expense" data-testid="save-button">Ignored copy</button>', { role: 'button', name: 'Save', testid: 'save' });
|
|
161
|
+
expect(result).toMatchObject({
|
|
162
|
+
matches_n: 1,
|
|
163
|
+
entries: [
|
|
164
|
+
{ nth: 0, ref: 1, tag: 'button', role: 'button', attrs: { 'aria-label': 'Save expense', 'data-testid': 'save-button' } },
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
const button = dom.window.document.querySelector('button');
|
|
168
|
+
expect(button.getAttribute('data-opencli-ref')).toBe('1');
|
|
169
|
+
expect(dom.window.__opencli_ref_identity['1']).toMatchObject({ tag: 'button', ariaLabel: 'Save expense' });
|
|
170
|
+
});
|
|
171
|
+
it('matches associated labels and placeholders for form controls', () => {
|
|
172
|
+
const { result } = runSemanticFind('<label for="category">Category</label><input id="category" placeholder="Expense category" value="Travel" />', { role: 'textbox', label: 'Category', name: 'Expense category' });
|
|
173
|
+
expect(result).toMatchObject({
|
|
174
|
+
matches_n: 1,
|
|
175
|
+
entries: [
|
|
176
|
+
{ nth: 0, ref: 1, tag: 'input', role: 'textbox' },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* by the navigate action and pass it to all subsequent commands. This ensures
|
|
9
9
|
* page-scoped operations target the correct page without guessing.
|
|
10
10
|
*/
|
|
11
|
-
import type { BrowserCookie, ScreenshotOptions } from '../types.js';
|
|
11
|
+
import type { BrowserCookie, BrowserDownloadWaitResult, ScreenshotOptions } from '../types.js';
|
|
12
12
|
import { BasePage } from './base-page.js';
|
|
13
13
|
/**
|
|
14
14
|
* Page — implements IPage by talking to the daemon via HTTP.
|
|
@@ -53,6 +53,7 @@ export declare class Page extends BasePage {
|
|
|
53
53
|
screenshot(options?: ScreenshotOptions): Promise<string>;
|
|
54
54
|
startNetworkCapture(pattern?: string): Promise<boolean>;
|
|
55
55
|
readNetworkCapture(): Promise<unknown[]>;
|
|
56
|
+
waitForDownload(pattern?: string, timeoutMs?: number): Promise<BrowserDownloadWaitResult>;
|
|
56
57
|
/**
|
|
57
58
|
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
|
|
58
59
|
* Chrome reads the files directly from the local filesystem, avoiding the
|
package/dist/src/browser/page.js
CHANGED
|
@@ -246,6 +246,14 @@ export class Page extends BasePage {
|
|
|
246
246
|
return [];
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
|
+
async waitForDownload(pattern = '', timeoutMs = 30_000) {
|
|
250
|
+
const result = await sendCommand('wait-download', {
|
|
251
|
+
pattern,
|
|
252
|
+
timeoutMs,
|
|
253
|
+
...this._cmdOpts(),
|
|
254
|
+
});
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
249
257
|
/**
|
|
250
258
|
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
|
|
251
259
|
* Chrome reads the files directly from the local filesystem, avoiding the
|
|
@@ -360,6 +368,11 @@ export class Page extends BasePage {
|
|
|
360
368
|
`);
|
|
361
369
|
}
|
|
362
370
|
async nativeClick(x, y) {
|
|
371
|
+
await this.cdp('Input.dispatchMouseEvent', {
|
|
372
|
+
type: 'mouseMoved',
|
|
373
|
+
x,
|
|
374
|
+
y,
|
|
375
|
+
});
|
|
363
376
|
await this.cdp('Input.dispatchMouseEvent', {
|
|
364
377
|
type: 'mousePressed',
|
|
365
378
|
x, y,
|
|
@@ -102,6 +102,34 @@ describe('Page network capture compatibility', () => {
|
|
|
102
102
|
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
103
103
|
});
|
|
104
104
|
});
|
|
105
|
+
describe('Page download waits', () => {
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
sendCommandMock.mockReset();
|
|
108
|
+
sendCommandFullMock.mockReset();
|
|
109
|
+
warnMock.mockReset();
|
|
110
|
+
});
|
|
111
|
+
it('sends wait-download through the daemon with workspace and timeout', async () => {
|
|
112
|
+
sendCommandMock.mockResolvedValueOnce({
|
|
113
|
+
downloaded: true,
|
|
114
|
+
filename: '/tmp/receipt.pdf',
|
|
115
|
+
state: 'complete',
|
|
116
|
+
elapsedMs: 5,
|
|
117
|
+
});
|
|
118
|
+
const page = new Page('site:mercury');
|
|
119
|
+
const result = await page.waitForDownload('receipt', 1234);
|
|
120
|
+
expect(result).toEqual({
|
|
121
|
+
downloaded: true,
|
|
122
|
+
filename: '/tmp/receipt.pdf',
|
|
123
|
+
state: 'complete',
|
|
124
|
+
elapsedMs: 5,
|
|
125
|
+
});
|
|
126
|
+
expect(sendCommandMock).toHaveBeenCalledWith('wait-download', expect.objectContaining({
|
|
127
|
+
workspace: 'site:mercury',
|
|
128
|
+
pattern: 'receipt',
|
|
129
|
+
timeoutMs: 1234,
|
|
130
|
+
}));
|
|
131
|
+
});
|
|
132
|
+
});
|
|
105
133
|
describe('Page CDP helpers', () => {
|
|
106
134
|
beforeEach(() => {
|
|
107
135
|
sendCommandMock.mockReset();
|
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
* - selector_ambiguous: >1 matches for a write op without --nth
|
|
17
17
|
* - selector_nth_out_of_range: --nth beyond matches_n
|
|
18
18
|
* - not_editable: target exists but cannot accept text input
|
|
19
|
+
* - not_checkable: target exists but cannot be checked/unchecked
|
|
20
|
+
* - not_file_input: target exists but is not a usable file input
|
|
19
21
|
*/
|
|
20
|
-
export type TargetErrorCode = 'not_found' | 'stale_ref' | 'invalid_selector' | 'selector_not_found' | 'selector_ambiguous' | 'selector_nth_out_of_range' | 'not_editable';
|
|
22
|
+
export type TargetErrorCode = 'not_found' | 'stale_ref' | 'invalid_selector' | 'selector_not_found' | 'selector_ambiguous' | 'selector_nth_out_of_range' | 'not_editable' | 'not_checkable' | 'not_file_input';
|
|
21
23
|
export interface TargetErrorInfo {
|
|
22
24
|
code: TargetErrorCode;
|
|
23
25
|
message: string;
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* - selector_ambiguous: >1 matches for a write op without --nth
|
|
17
17
|
* - selector_nth_out_of_range: --nth beyond matches_n
|
|
18
18
|
* - not_editable: target exists but cannot accept text input
|
|
19
|
+
* - not_checkable: target exists but cannot be checked/unchecked
|
|
20
|
+
* - not_file_input: target exists but is not a usable file input
|
|
19
21
|
*/
|
|
20
22
|
export class TargetError extends Error {
|
|
21
23
|
code;
|
|
@@ -68,9 +68,23 @@ export type TargetMatchLevel = 'exact' | 'stable' | 'reidentified';
|
|
|
68
68
|
* The resolved element is stored in `window.__resolved` for downstream helpers.
|
|
69
69
|
*/
|
|
70
70
|
export declare function resolveTargetJs(ref: string, opts?: ResolveOptions): string;
|
|
71
|
+
/**
|
|
72
|
+
* Generate JS that scrolls + measures `__resolved` without clicking.
|
|
73
|
+
*
|
|
74
|
+
* Generic click prefers CDP `Input.dispatchMouseEvent`, which fires the full
|
|
75
|
+
* pointer/mouse chain that Radix/MUI/shadcn dropdowns rely on. Keep measurement
|
|
76
|
+
* separate so the CDP-primary path does not call DOM `el.click()` first.
|
|
77
|
+
*/
|
|
78
|
+
export declare function boundingRectResolvedJs(opts?: {
|
|
79
|
+
skipScroll?: boolean;
|
|
80
|
+
}): string;
|
|
71
81
|
/**
|
|
72
82
|
* Generate JS for click that uses the unified resolver.
|
|
73
83
|
* Assumes resolveTargetJs has been called and __resolved is set.
|
|
84
|
+
*
|
|
85
|
+
* This is the JS fallback path. BasePage.click uses boundingRectResolvedJs for
|
|
86
|
+
* the CDP-primary path and only reaches this when native click is unavailable
|
|
87
|
+
* or the target has no usable rect.
|
|
74
88
|
*/
|
|
75
89
|
export declare function clickResolvedJs(opts?: {
|
|
76
90
|
skipScroll?: boolean;
|
|
@@ -273,9 +273,37 @@ export function resolveTargetJs(ref, opts = {}) {
|
|
|
273
273
|
})()
|
|
274
274
|
`;
|
|
275
275
|
}
|
|
276
|
+
/**
|
|
277
|
+
* Generate JS that scrolls + measures `__resolved` without clicking.
|
|
278
|
+
*
|
|
279
|
+
* Generic click prefers CDP `Input.dispatchMouseEvent`, which fires the full
|
|
280
|
+
* pointer/mouse chain that Radix/MUI/shadcn dropdowns rely on. Keep measurement
|
|
281
|
+
* separate so the CDP-primary path does not call DOM `el.click()` first.
|
|
282
|
+
*/
|
|
283
|
+
export function boundingRectResolvedJs(opts = {}) {
|
|
284
|
+
const shouldScroll = opts.skipScroll ? 'false' : 'true';
|
|
285
|
+
return `
|
|
286
|
+
(() => {
|
|
287
|
+
const el = window.__resolved;
|
|
288
|
+
if (!el) throw new Error('No resolved element');
|
|
289
|
+
if (${shouldScroll}) el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
290
|
+
const rect = el.getBoundingClientRect();
|
|
291
|
+
const w = Math.round(rect.width);
|
|
292
|
+
const h = Math.round(rect.height);
|
|
293
|
+
const x = Math.round(rect.left + rect.width / 2);
|
|
294
|
+
const y = Math.round(rect.top + rect.height / 2);
|
|
295
|
+
const visible = w > 0 && h > 0;
|
|
296
|
+
return { x, y, w, h, visible };
|
|
297
|
+
})()
|
|
298
|
+
`;
|
|
299
|
+
}
|
|
276
300
|
/**
|
|
277
301
|
* Generate JS for click that uses the unified resolver.
|
|
278
302
|
* Assumes resolveTargetJs has been called and __resolved is set.
|
|
303
|
+
*
|
|
304
|
+
* This is the JS fallback path. BasePage.click uses boundingRectResolvedJs for
|
|
305
|
+
* the CDP-primary path and only reaches this when native click is unavailable
|
|
306
|
+
* or the target has no usable rect.
|
|
279
307
|
*/
|
|
280
308
|
export function clickResolvedJs(opts = {}) {
|
|
281
309
|
const shouldScroll = opts.skipScroll ? 'false' : 'true';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual ref overlay for annotated screenshots.
|
|
3
|
+
*
|
|
4
|
+
* The overlay is intentionally DOM-side and temporary. It reuses the same
|
|
5
|
+
* `data-opencli-ref` attributes produced by the DOM snapshot path so the
|
|
6
|
+
* screenshot labels map back to normal `browser click <ref>` targets.
|
|
7
|
+
*/
|
|
8
|
+
export declare function installVisualRefOverlayJs(opts?: {
|
|
9
|
+
maxRefs?: number;
|
|
10
|
+
}): string;
|
|
11
|
+
export declare function removeVisualRefOverlayJs(): string;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual ref overlay for annotated screenshots.
|
|
3
|
+
*
|
|
4
|
+
* The overlay is intentionally DOM-side and temporary. It reuses the same
|
|
5
|
+
* `data-opencli-ref` attributes produced by the DOM snapshot path so the
|
|
6
|
+
* screenshot labels map back to normal `browser click <ref>` targets.
|
|
7
|
+
*/
|
|
8
|
+
const OVERLAY_ID = '__opencli_visual_ref_overlay';
|
|
9
|
+
export function installVisualRefOverlayJs(opts = {}) {
|
|
10
|
+
const maxRefs = Math.max(1, Math.min(opts.maxRefs ?? 120, 500));
|
|
11
|
+
return `
|
|
12
|
+
(() => {
|
|
13
|
+
const OVERLAY_ID = ${JSON.stringify(OVERLAY_ID)};
|
|
14
|
+
const MAX_REFS = ${maxRefs};
|
|
15
|
+
document.getElementById(OVERLAY_ID)?.remove();
|
|
16
|
+
|
|
17
|
+
const overlay = document.createElement('div');
|
|
18
|
+
overlay.id = OVERLAY_ID;
|
|
19
|
+
overlay.setAttribute('aria-hidden', 'true');
|
|
20
|
+
Object.assign(overlay.style, {
|
|
21
|
+
position: 'fixed',
|
|
22
|
+
inset: '0',
|
|
23
|
+
zIndex: '2147483647',
|
|
24
|
+
pointerEvents: 'none',
|
|
25
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const refs = Array.from(document.querySelectorAll('[data-opencli-ref]'))
|
|
29
|
+
.map((el) => {
|
|
30
|
+
const rawRef = el.getAttribute('data-opencli-ref') || '';
|
|
31
|
+
const ref = Number(rawRef);
|
|
32
|
+
const rect = el.getBoundingClientRect();
|
|
33
|
+
const visible = Number.isFinite(ref)
|
|
34
|
+
&& rect.width >= 2
|
|
35
|
+
&& rect.height >= 2
|
|
36
|
+
&& rect.bottom >= 0
|
|
37
|
+
&& rect.right >= 0
|
|
38
|
+
&& rect.top <= window.innerHeight
|
|
39
|
+
&& rect.left <= window.innerWidth;
|
|
40
|
+
return { el, rawRef, ref, rect, visible };
|
|
41
|
+
})
|
|
42
|
+
.filter((entry) => entry.visible)
|
|
43
|
+
.sort((a, b) => a.ref - b.ref)
|
|
44
|
+
.slice(0, MAX_REFS);
|
|
45
|
+
|
|
46
|
+
for (const entry of refs) {
|
|
47
|
+
const left = Math.max(0, Math.min(window.innerWidth - 1, entry.rect.left));
|
|
48
|
+
const top = Math.max(0, Math.min(window.innerHeight - 1, entry.rect.top));
|
|
49
|
+
const right = Math.max(0, Math.min(window.innerWidth, entry.rect.right));
|
|
50
|
+
const bottom = Math.max(0, Math.min(window.innerHeight, entry.rect.bottom));
|
|
51
|
+
const width = Math.max(2, right - left);
|
|
52
|
+
const height = Math.max(2, bottom - top);
|
|
53
|
+
|
|
54
|
+
const box = document.createElement('div');
|
|
55
|
+
Object.assign(box.style, {
|
|
56
|
+
position: 'fixed',
|
|
57
|
+
left: left + 'px',
|
|
58
|
+
top: top + 'px',
|
|
59
|
+
width: width + 'px',
|
|
60
|
+
height: height + 'px',
|
|
61
|
+
border: '2px solid #ff3b30',
|
|
62
|
+
borderRadius: '4px',
|
|
63
|
+
boxSizing: 'border-box',
|
|
64
|
+
boxShadow: '0 0 0 1px rgba(255,255,255,.9), 0 4px 16px rgba(0,0,0,.25)',
|
|
65
|
+
background: 'rgba(255,59,48,.08)',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const badge = document.createElement('div');
|
|
69
|
+
badge.textContent = entry.rawRef;
|
|
70
|
+
Object.assign(badge.style, {
|
|
71
|
+
position: 'fixed',
|
|
72
|
+
left: left + 'px',
|
|
73
|
+
top: Math.max(0, top - 20) + 'px',
|
|
74
|
+
minWidth: '18px',
|
|
75
|
+
height: '18px',
|
|
76
|
+
padding: '0 5px',
|
|
77
|
+
borderRadius: '999px',
|
|
78
|
+
border: '1px solid rgba(255,255,255,.95)',
|
|
79
|
+
background: '#ff3b30',
|
|
80
|
+
color: '#fff',
|
|
81
|
+
fontSize: '12px',
|
|
82
|
+
fontWeight: '700',
|
|
83
|
+
lineHeight: '18px',
|
|
84
|
+
textAlign: 'center',
|
|
85
|
+
textShadow: '0 1px 1px rgba(0,0,0,.25)',
|
|
86
|
+
boxShadow: '0 2px 8px rgba(0,0,0,.35)',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
overlay.appendChild(box);
|
|
90
|
+
overlay.appendChild(badge);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
document.documentElement.appendChild(overlay);
|
|
94
|
+
return {
|
|
95
|
+
annotated: refs.length,
|
|
96
|
+
truncated: refs.length >= MAX_REFS,
|
|
97
|
+
};
|
|
98
|
+
})()
|
|
99
|
+
`.trim();
|
|
100
|
+
}
|
|
101
|
+
export function removeVisualRefOverlayJs() {
|
|
102
|
+
return `
|
|
103
|
+
(() => {
|
|
104
|
+
document.getElementById(${JSON.stringify(OVERLAY_ID)})?.remove();
|
|
105
|
+
return true;
|
|
106
|
+
})()
|
|
107
|
+
`.trim();
|
|
108
|
+
}
|
|
@@ -66,6 +66,29 @@ export declare function loadManifestEntries(filePath: string, site: string, impo
|
|
|
66
66
|
export declare function scanClisDir(clisDir: string, importer?: (moduleHref: string) => Promise<unknown>): Promise<BuildManifestResult>;
|
|
67
67
|
export declare function buildManifest(): Promise<BuildManifestResult>;
|
|
68
68
|
export declare function serializeManifest(manifest: ManifestEntry[]): string;
|
|
69
|
+
/**
|
|
70
|
+
* Metadata audit: every positional arg must carry a non-empty `help` string.
|
|
71
|
+
*
|
|
72
|
+
* Why this is a hard gate (not advisory):
|
|
73
|
+
* - `opencli twitter followers --help` rendered `Arguments:\n user ` with
|
|
74
|
+
* an empty trailing column. Agents and humans both saw a blank field —
|
|
75
|
+
* impossible to recover the parameter's purpose without reading source.
|
|
76
|
+
* - This is metadata completeness, not stylistic taste; failing closed is
|
|
77
|
+
* the only way to keep the help surface trustworthy as adapters land.
|
|
78
|
+
*
|
|
79
|
+
* Note: semantic quality (e.g. "what does the optional positional mean when
|
|
80
|
+
* omitted?") is intentionally NOT enforced here. That belongs to a follow-up
|
|
81
|
+
* advisory audit — see PR plan `Arg metadata v2` for the structured
|
|
82
|
+
* `when_omitted / when_present / value_format` schema.
|
|
83
|
+
*/
|
|
84
|
+
export interface ManifestMetadataIssue {
|
|
85
|
+
site: string;
|
|
86
|
+
command: string;
|
|
87
|
+
arg: string;
|
|
88
|
+
sourceFile?: string;
|
|
89
|
+
reason: string;
|
|
90
|
+
}
|
|
91
|
+
export declare function findManifestMetadataIssues(entries: readonly ManifestEntry[]): ManifestMetadataIssue[];
|
|
69
92
|
/**
|
|
70
93
|
* Diff helper: returns site/name keys that exist in `prev` but not in
|
|
71
94
|
* `next`. Used as a safety net to detect accidental mass-deletions caused
|