@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
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
* getCookies, screenshot, tabs, etc.
|
|
10
10
|
*/
|
|
11
11
|
import { generateSnapshotJs, getFormStateJs } from './dom-snapshot.js';
|
|
12
|
+
import { buildAxSnapshotFromTrees, findAxRefReplacement } from './ax-snapshot.js';
|
|
12
13
|
import { pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
|
|
13
|
-
import { resolveTargetJs, clickResolvedJs, typeResolvedJs, prepareNativeTypeResolvedJs, verifyFilledResolvedJs, scrollResolvedJs, } from './target-resolver.js';
|
|
14
|
+
import { resolveTargetJs, boundingRectResolvedJs, clickResolvedJs, typeResolvedJs, prepareNativeTypeResolvedJs, verifyFilledResolvedJs, scrollResolvedJs, } from './target-resolver.js';
|
|
14
15
|
import { TargetError } from './target-errors.js';
|
|
15
16
|
import { CliError } from '../errors.js';
|
|
16
17
|
import { formatSnapshot } from '../snapshotFormatter.js';
|
|
18
|
+
import { installVisualRefOverlayJs, removeVisualRefOverlayJs } from './visual-refs.js';
|
|
17
19
|
/**
|
|
18
20
|
* Execute `resolveTargetJs` once, throw structured `TargetError` on failure.
|
|
19
21
|
* Single helper so click/typeText/scrollTo share one resolution pathway,
|
|
@@ -62,6 +64,7 @@ export class BasePage {
|
|
|
62
64
|
/** Cached previous snapshot hashes for incremental diff marking */
|
|
63
65
|
_prevSnapshotHashes = null;
|
|
64
66
|
_cdpTargetMarkerSeq = 0;
|
|
67
|
+
_axRefs = new Map();
|
|
65
68
|
/**
|
|
66
69
|
* Safely evaluate JS with pre-serialized arguments.
|
|
67
70
|
* Each key in `args` becomes a `const` declaration with JSON-serialized value,
|
|
@@ -151,12 +154,35 @@ export class BasePage {
|
|
|
151
154
|
throw new CliError('FETCH_ERROR', `Expected JSON from ${targetUrl}${contentType}`, previewText(text));
|
|
152
155
|
}
|
|
153
156
|
}
|
|
157
|
+
async annotatedScreenshot(options = {}) {
|
|
158
|
+
// Refresh DOM refs first so visual labels map to immediate `browser click <ref>` targets.
|
|
159
|
+
await this.snapshot({ source: 'dom', viewportExpand: 0 });
|
|
160
|
+
try {
|
|
161
|
+
await this.evaluate(installVisualRefOverlayJs());
|
|
162
|
+
return await this.screenshot({ ...options, annotate: false });
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await this.evaluate(removeVisualRefOverlayJs()).catch(() => { });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
154
168
|
// ── Shared DOM helper implementations ──
|
|
155
169
|
async click(ref, opts = {}) {
|
|
170
|
+
const axClick = await this.tryClickAxRef(ref);
|
|
171
|
+
if (axClick)
|
|
172
|
+
return axClick;
|
|
156
173
|
// Phase 1: Resolve target with fingerprint verification
|
|
157
174
|
const resolved = await runResolve(this, ref, opts);
|
|
158
175
|
const nativeScrolled = await this.tryCdpOnResolvedElement('DOM.scrollIntoViewIfNeeded');
|
|
159
|
-
// Phase 2:
|
|
176
|
+
// Phase 2: measure first so native click can run before DOM el.click().
|
|
177
|
+
// Custom dropdowns often listen to pointer/mouse down/up; DOM el.click()
|
|
178
|
+
// only fires click and can silently report success without opening/selecting.
|
|
179
|
+
const rect = await this.evaluate(boundingRectResolvedJs({ skipScroll: nativeScrolled }));
|
|
180
|
+
if (rect?.visible === true) {
|
|
181
|
+
const success = await this.tryNativeClick(rect.x, rect.y);
|
|
182
|
+
if (success)
|
|
183
|
+
return resolved;
|
|
184
|
+
}
|
|
185
|
+
// JS fallback for older backends or zero-rect targets.
|
|
160
186
|
const result = await this.evaluate(clickResolvedJs({ skipScroll: nativeScrolled }));
|
|
161
187
|
if (typeof result === 'string' || result == null)
|
|
162
188
|
return resolved;
|
|
@@ -183,6 +209,115 @@ export class BasePage {
|
|
|
183
209
|
return false;
|
|
184
210
|
}
|
|
185
211
|
}
|
|
212
|
+
async tryNativeMouseMove(x, y) {
|
|
213
|
+
const cdp = this.cdp;
|
|
214
|
+
if (typeof cdp !== 'function')
|
|
215
|
+
return false;
|
|
216
|
+
try {
|
|
217
|
+
await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async tryNativeDoubleClick(x, y) {
|
|
225
|
+
const cdp = this.cdp;
|
|
226
|
+
if (typeof cdp !== 'function')
|
|
227
|
+
return false;
|
|
228
|
+
try {
|
|
229
|
+
await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
|
|
230
|
+
await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
|
|
231
|
+
await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
|
|
232
|
+
await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 2 });
|
|
233
|
+
await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 2 });
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async tryNativeDrag(from, to) {
|
|
241
|
+
const cdp = this.cdp;
|
|
242
|
+
if (typeof cdp !== 'function')
|
|
243
|
+
return false;
|
|
244
|
+
const midX = Math.round((from.x + to.x) / 2);
|
|
245
|
+
const midY = Math.round((from.y + to.y) / 2);
|
|
246
|
+
try {
|
|
247
|
+
await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: from.x, y: from.y });
|
|
248
|
+
await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: from.x, y: from.y, button: 'left', clickCount: 1 });
|
|
249
|
+
await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: midX, y: midY, button: 'left', buttons: 1 });
|
|
250
|
+
await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: to.x, y: to.y, button: 'left', buttons: 1 });
|
|
251
|
+
await cdp.call(this, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: to.x, y: to.y, button: 'left', clickCount: 1 });
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async tryClickAxRef(ref) {
|
|
259
|
+
if (!/^\d+$/.test(ref))
|
|
260
|
+
return null;
|
|
261
|
+
const entry = this._axRefs.get(ref);
|
|
262
|
+
if (!entry)
|
|
263
|
+
return null;
|
|
264
|
+
const nativeClick = this.nativeClick;
|
|
265
|
+
if (typeof nativeClick !== 'function')
|
|
266
|
+
return null;
|
|
267
|
+
const resolved = await this.resolveAxRefPoint(entry);
|
|
268
|
+
if (!resolved)
|
|
269
|
+
return null;
|
|
270
|
+
try {
|
|
271
|
+
await nativeClick.call(this, resolved.x, resolved.y);
|
|
272
|
+
return { matches_n: 1, match_level: resolved.matchLevel };
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async resolveAxRefPoint(entry) {
|
|
279
|
+
const cdp = this.cdp;
|
|
280
|
+
if (typeof cdp !== 'function')
|
|
281
|
+
return null;
|
|
282
|
+
if (entry.backendNodeId != null) {
|
|
283
|
+
const point = await this.axBoxCenter(entry.backendNodeId, entry.frame).catch(() => null);
|
|
284
|
+
if (point)
|
|
285
|
+
return { ...point, matchLevel: 'exact' };
|
|
286
|
+
}
|
|
287
|
+
await cdp.call(this, 'Accessibility.enable', axEnableParams(entry.frame));
|
|
288
|
+
const axTree = await cdp.call(this, 'Accessibility.getFullAXTree', axTreeParams(entry.frame)).catch(() => null);
|
|
289
|
+
const recovered = findAxRefReplacement(axTree, entry);
|
|
290
|
+
if (!recovered?.backendNodeId)
|
|
291
|
+
return null;
|
|
292
|
+
this._axRefs.set(entry.ref, recovered);
|
|
293
|
+
const point = await this.axBoxCenter(recovered.backendNodeId, recovered.frame).catch(() => null);
|
|
294
|
+
return point ? { ...point, matchLevel: 'reidentified' } : null;
|
|
295
|
+
}
|
|
296
|
+
async axBoxCenter(backendNodeId, frame) {
|
|
297
|
+
const cdp = this.cdp;
|
|
298
|
+
if (typeof cdp !== 'function')
|
|
299
|
+
return null;
|
|
300
|
+
const result = await cdp.call(this, 'DOM.getBoxModel', {
|
|
301
|
+
backendNodeId,
|
|
302
|
+
...(frame?.sessionId
|
|
303
|
+
? { frameId: frame.frameId, sessionId: frame.sessionId, ...(frame.targetUrl ? { targetUrl: frame.targetUrl } : {}) }
|
|
304
|
+
: {}),
|
|
305
|
+
});
|
|
306
|
+
const quad = Array.isArray(result?.model?.content) && result.model.content.length >= 8
|
|
307
|
+
? result.model.content
|
|
308
|
+
: Array.isArray(result?.model?.border) && result.model.border.length >= 8
|
|
309
|
+
? result.model.border
|
|
310
|
+
: null;
|
|
311
|
+
if (!quad)
|
|
312
|
+
return null;
|
|
313
|
+
const nums = quad.slice(0, 8).map((value) => typeof value === 'number' ? value : Number(value));
|
|
314
|
+
if (nums.some((value) => !Number.isFinite(value)))
|
|
315
|
+
return null;
|
|
316
|
+
return {
|
|
317
|
+
x: Math.round((nums[0] + nums[2] + nums[4] + nums[6]) / 4),
|
|
318
|
+
y: Math.round((nums[1] + nums[3] + nums[5] + nums[7]) / 4),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
186
321
|
/** Uses native CDP text insertion when the concrete page exposes it. */
|
|
187
322
|
async tryNativeType(text) {
|
|
188
323
|
const nativeType = this.nativeType;
|
|
@@ -219,6 +354,19 @@ export class BasePage {
|
|
|
219
354
|
return false;
|
|
220
355
|
}
|
|
221
356
|
}
|
|
357
|
+
async isResolvedFocused() {
|
|
358
|
+
try {
|
|
359
|
+
return await this.evaluate(`
|
|
360
|
+
(() => {
|
|
361
|
+
const el = window.__resolved;
|
|
362
|
+
return !!el && (document.activeElement === el || (typeof el.matches === 'function' && el.matches(':focus')));
|
|
363
|
+
})()
|
|
364
|
+
`);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
222
370
|
/**
|
|
223
371
|
* Run a DOM-domain CDP command against `window.__resolved`.
|
|
224
372
|
*
|
|
@@ -303,6 +451,322 @@ export class BasePage {
|
|
|
303
451
|
}
|
|
304
452
|
return resolved;
|
|
305
453
|
}
|
|
454
|
+
async hover(ref, opts = {}) {
|
|
455
|
+
const resolved = await runResolve(this, ref, opts);
|
|
456
|
+
const nativeScrolled = await this.tryCdpOnResolvedElement('DOM.scrollIntoViewIfNeeded');
|
|
457
|
+
const rect = await this.evaluate(boundingRectResolvedJs({ skipScroll: nativeScrolled }));
|
|
458
|
+
if (rect?.visible === true && await this.tryNativeMouseMove(rect.x, rect.y))
|
|
459
|
+
return resolved;
|
|
460
|
+
await this.evaluate(`
|
|
461
|
+
(() => {
|
|
462
|
+
const el = window.__resolved;
|
|
463
|
+
if (!el) throw new Error('No resolved element');
|
|
464
|
+
if (${nativeScrolled ? 'false' : 'true'}) el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
465
|
+
const rect = el.getBoundingClientRect();
|
|
466
|
+
const init = {
|
|
467
|
+
bubbles: true,
|
|
468
|
+
cancelable: true,
|
|
469
|
+
view: window,
|
|
470
|
+
clientX: Math.round(rect.left + rect.width / 2),
|
|
471
|
+
clientY: Math.round(rect.top + rect.height / 2),
|
|
472
|
+
};
|
|
473
|
+
try { el.dispatchEvent(new PointerEvent('pointerover', init)); } catch (_) {}
|
|
474
|
+
try { el.dispatchEvent(new PointerEvent('pointermove', init)); } catch (_) {}
|
|
475
|
+
el.dispatchEvent(new MouseEvent('mouseover', init));
|
|
476
|
+
el.dispatchEvent(new MouseEvent('mousemove', init));
|
|
477
|
+
})()
|
|
478
|
+
`);
|
|
479
|
+
return resolved;
|
|
480
|
+
}
|
|
481
|
+
async focus(ref, opts = {}) {
|
|
482
|
+
const resolved = await runResolve(this, ref, opts);
|
|
483
|
+
let focused = await this.tryCdpOnResolvedElement('DOM.focus') && await this.isResolvedFocused();
|
|
484
|
+
if (!focused) {
|
|
485
|
+
focused = await this.evaluate(`
|
|
486
|
+
(() => {
|
|
487
|
+
const el = window.__resolved;
|
|
488
|
+
if (!el || typeof el.focus !== 'function') return false;
|
|
489
|
+
try { el.focus({ preventScroll: true }); } catch (_) { try { el.focus(); } catch (_) {} }
|
|
490
|
+
return document.activeElement === el || (typeof el.matches === 'function' && el.matches(':focus'));
|
|
491
|
+
})()
|
|
492
|
+
`);
|
|
493
|
+
}
|
|
494
|
+
return { ...resolved, focused: !!focused };
|
|
495
|
+
}
|
|
496
|
+
async dblClick(ref, opts = {}) {
|
|
497
|
+
const resolved = await runResolve(this, ref, opts);
|
|
498
|
+
const nativeScrolled = await this.tryCdpOnResolvedElement('DOM.scrollIntoViewIfNeeded');
|
|
499
|
+
const rect = await this.evaluate(boundingRectResolvedJs({ skipScroll: nativeScrolled }));
|
|
500
|
+
if (rect?.visible === true && await this.tryNativeDoubleClick(rect.x, rect.y))
|
|
501
|
+
return resolved;
|
|
502
|
+
await this.evaluate(`
|
|
503
|
+
(() => {
|
|
504
|
+
const el = window.__resolved;
|
|
505
|
+
if (!el) throw new Error('No resolved element');
|
|
506
|
+
if (${nativeScrolled ? 'false' : 'true'}) el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
507
|
+
const rect = el.getBoundingClientRect();
|
|
508
|
+
const init = {
|
|
509
|
+
bubbles: true,
|
|
510
|
+
cancelable: true,
|
|
511
|
+
view: window,
|
|
512
|
+
clientX: Math.round(rect.left + rect.width / 2),
|
|
513
|
+
clientY: Math.round(rect.top + rect.height / 2),
|
|
514
|
+
button: 0,
|
|
515
|
+
detail: 2,
|
|
516
|
+
};
|
|
517
|
+
el.dispatchEvent(new MouseEvent('dblclick', init));
|
|
518
|
+
})()
|
|
519
|
+
`);
|
|
520
|
+
return resolved;
|
|
521
|
+
}
|
|
522
|
+
async readCheckableState() {
|
|
523
|
+
return await this.evaluate(`
|
|
524
|
+
(() => {
|
|
525
|
+
const el = window.__resolved;
|
|
526
|
+
if (!el || el.nodeType !== 1) return { ok: false, reason: 'not_checkable' };
|
|
527
|
+
const tag = el.tagName.toLowerCase();
|
|
528
|
+
const role = (el.getAttribute('role') || '').toLowerCase();
|
|
529
|
+
const type = (el.getAttribute('type') || '').toLowerCase();
|
|
530
|
+
if (tag === 'input' && (type === 'checkbox' || type === 'radio')) {
|
|
531
|
+
return {
|
|
532
|
+
ok: true,
|
|
533
|
+
checked: !!el.checked,
|
|
534
|
+
disabled: !!el.disabled,
|
|
535
|
+
kind: type,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
if (role === 'checkbox' || role === 'switch' || role === 'menuitemcheckbox' || role === 'radio' || role === 'menuitemradio') {
|
|
539
|
+
const aria = (el.getAttribute('aria-checked') || '').toLowerCase();
|
|
540
|
+
return {
|
|
541
|
+
ok: true,
|
|
542
|
+
checked: aria === 'true' || aria === 'mixed',
|
|
543
|
+
disabled: el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled'),
|
|
544
|
+
kind: role,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
return { ok: false, reason: 'not_checkable', tag, role };
|
|
548
|
+
})()
|
|
549
|
+
`);
|
|
550
|
+
}
|
|
551
|
+
async setChecked(ref, checked, opts = {}) {
|
|
552
|
+
const resolved = await runResolve(this, ref, opts);
|
|
553
|
+
const before = await this.readCheckableState();
|
|
554
|
+
if (before?.ok !== true) {
|
|
555
|
+
throw new TargetError({
|
|
556
|
+
code: 'not_checkable',
|
|
557
|
+
message: `Target "${ref}" is not a checkbox, radio, switch, or aria-checked control.`,
|
|
558
|
+
hint: 'Use `opencli browser state` or `browser find` to pick an input[type=checkbox], input[type=radio], or role=checkbox/switch target.',
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
if (before.disabled) {
|
|
562
|
+
throw new TargetError({
|
|
563
|
+
code: 'not_checkable',
|
|
564
|
+
message: `Target "${ref}" is disabled and cannot be ${checked ? 'checked' : 'unchecked'}.`,
|
|
565
|
+
hint: 'Pick an enabled control, or inspect the form state before retrying.',
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
if ((before.kind === 'radio' || before.kind === 'menuitemradio') && !checked) {
|
|
569
|
+
throw new TargetError({
|
|
570
|
+
code: 'not_checkable',
|
|
571
|
+
message: `Target "${ref}" is a radio button and cannot be unchecked directly.`,
|
|
572
|
+
hint: 'Select another radio option in the same group instead.',
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
if (before.checked === checked) {
|
|
576
|
+
return {
|
|
577
|
+
...resolved,
|
|
578
|
+
checked,
|
|
579
|
+
changed: false,
|
|
580
|
+
...(before.kind ? { kind: before.kind } : {}),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
const clicked = await this.click(ref, opts);
|
|
584
|
+
const after = await this.readCheckableState();
|
|
585
|
+
if (after?.ok !== true || after.checked !== checked) {
|
|
586
|
+
throw new TargetError({
|
|
587
|
+
code: 'not_checkable',
|
|
588
|
+
message: `Target "${ref}" did not become ${checked ? 'checked' : 'unchecked'} after click.`,
|
|
589
|
+
hint: 'The control may be custom, disabled by app logic, or require a different target such as its visible label.',
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
matches_n: clicked.matches_n,
|
|
594
|
+
match_level: clicked.match_level,
|
|
595
|
+
checked,
|
|
596
|
+
changed: true,
|
|
597
|
+
...(after.kind ? { kind: after.kind } : {}),
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
async setFileInputBySelector(files, selector) {
|
|
601
|
+
const setFileInput = this.setFileInput;
|
|
602
|
+
if (typeof setFileInput === 'function') {
|
|
603
|
+
await setFileInput.call(this, files, selector);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const cdp = this.cdp;
|
|
607
|
+
if (typeof cdp !== 'function') {
|
|
608
|
+
throw new Error('File upload requires setFileInput or CDP support from the active browser backend.');
|
|
609
|
+
}
|
|
610
|
+
await cdp.call(this, 'DOM.enable', {}).catch(() => undefined);
|
|
611
|
+
const doc = await cdp.call(this, 'DOM.getDocument', {});
|
|
612
|
+
const rootNodeId = doc?.root?.nodeId;
|
|
613
|
+
if (typeof rootNodeId !== 'number')
|
|
614
|
+
throw new Error('DOM.getDocument returned no root node.');
|
|
615
|
+
const query = await cdp.call(this, 'DOM.querySelector', { nodeId: rootNodeId, selector });
|
|
616
|
+
const nodeId = query?.nodeId;
|
|
617
|
+
if (typeof nodeId !== 'number' || nodeId <= 0)
|
|
618
|
+
throw new Error(`No element found matching selector: ${selector}`);
|
|
619
|
+
await cdp.call(this, 'DOM.setFileInputFiles', { files, nodeId });
|
|
620
|
+
}
|
|
621
|
+
async uploadFiles(ref, files, opts = {}) {
|
|
622
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
623
|
+
throw new TargetError({
|
|
624
|
+
code: 'not_file_input',
|
|
625
|
+
message: 'No files were provided for upload.',
|
|
626
|
+
hint: 'Pass one or more local file paths after the target.',
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
const resolved = await runResolve(this, ref, opts);
|
|
630
|
+
const markerAttr = 'data-opencli-upload-target';
|
|
631
|
+
const markerValue = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
632
|
+
const selector = `[${markerAttr}="${markerValue}"]`;
|
|
633
|
+
let marked = false;
|
|
634
|
+
let info = null;
|
|
635
|
+
try {
|
|
636
|
+
info = await this.evaluateWithArgs(`
|
|
637
|
+
(() => {
|
|
638
|
+
const el = window.__resolved;
|
|
639
|
+
if (!el || el.nodeType !== 1) return { ok: false, reason: 'not_file_input' };
|
|
640
|
+
const tag = el.tagName.toLowerCase();
|
|
641
|
+
const type = (el.getAttribute('type') || '').toLowerCase();
|
|
642
|
+
if (tag !== 'input' || type !== 'file') return { ok: false, reason: 'not_file_input', tag, type };
|
|
643
|
+
el.setAttribute(markerAttr, markerValue);
|
|
644
|
+
return {
|
|
645
|
+
ok: true,
|
|
646
|
+
multiple: !!el.multiple,
|
|
647
|
+
accept: el.getAttribute('accept') || '',
|
|
648
|
+
};
|
|
649
|
+
})()
|
|
650
|
+
`, { markerAttr, markerValue });
|
|
651
|
+
marked = info?.ok === true;
|
|
652
|
+
if (!marked) {
|
|
653
|
+
throw new TargetError({
|
|
654
|
+
code: 'not_file_input',
|
|
655
|
+
message: `Target "${ref}" is not an input[type=file].`,
|
|
656
|
+
hint: 'Use `opencli browser find --css "input[type=file]"` or inspect `compound` output from browser state/find.',
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
if (files.length > 1 && !info?.multiple) {
|
|
660
|
+
throw new TargetError({
|
|
661
|
+
code: 'not_file_input',
|
|
662
|
+
message: `Target "${ref}" does not allow multiple files, but ${files.length} files were provided.`,
|
|
663
|
+
hint: 'Pass one file, or choose a file input with the multiple attribute.',
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
await this.setFileInputBySelector(files, selector);
|
|
667
|
+
const verification = await this.evaluate(`
|
|
668
|
+
(() => {
|
|
669
|
+
const el = window.__resolved;
|
|
670
|
+
const names = [];
|
|
671
|
+
try {
|
|
672
|
+
if (el && el.files) {
|
|
673
|
+
for (let i = 0; i < el.files.length; i++) names.push(el.files[i].name || '');
|
|
674
|
+
}
|
|
675
|
+
} catch (_) {}
|
|
676
|
+
return names;
|
|
677
|
+
})()
|
|
678
|
+
`);
|
|
679
|
+
const fileNames = Array.isArray(verification)
|
|
680
|
+
? verification.map((value) => String(value))
|
|
681
|
+
: [];
|
|
682
|
+
return {
|
|
683
|
+
...resolved,
|
|
684
|
+
uploaded: true,
|
|
685
|
+
files: fileNames.length || files.length,
|
|
686
|
+
file_names: fileNames,
|
|
687
|
+
target: ref,
|
|
688
|
+
multiple: !!info?.multiple,
|
|
689
|
+
...(info?.accept ? { accept: info.accept } : {}),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
finally {
|
|
693
|
+
if (marked) {
|
|
694
|
+
await this.evaluateWithArgs(`
|
|
695
|
+
(() => {
|
|
696
|
+
for (const el of document.querySelectorAll(selector)) {
|
|
697
|
+
el.removeAttribute(markerAttr);
|
|
698
|
+
}
|
|
699
|
+
})()
|
|
700
|
+
`, { selector, markerAttr }).catch(() => undefined);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
async drag(source, target, opts = {}) {
|
|
705
|
+
const sourceResolved = await runResolve(this, source, opts.from ?? {});
|
|
706
|
+
const sourceScrolled = await this.tryCdpOnResolvedElement('DOM.scrollIntoViewIfNeeded');
|
|
707
|
+
const sourceRect = await this.evaluate(`
|
|
708
|
+
(() => {
|
|
709
|
+
const el = window.__resolved;
|
|
710
|
+
if (!el) throw new Error('No resolved drag source');
|
|
711
|
+
window.__opencli_drag_source = el;
|
|
712
|
+
if (${sourceScrolled ? 'false' : 'true'}) el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
713
|
+
const rect = el.getBoundingClientRect();
|
|
714
|
+
const w = Math.round(rect.width);
|
|
715
|
+
const h = Math.round(rect.height);
|
|
716
|
+
const x = Math.round(rect.left + rect.width / 2);
|
|
717
|
+
const y = Math.round(rect.top + rect.height / 2);
|
|
718
|
+
const visible = w > 0 && h > 0 && x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight;
|
|
719
|
+
return { x, y, w, h, visible };
|
|
720
|
+
})()
|
|
721
|
+
`);
|
|
722
|
+
if (sourceRect?.visible !== true) {
|
|
723
|
+
throw new Error(`Drag source "${source}" has no visible bounding box.`);
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
const targetResolved = await runResolve(this, target, opts.to ?? {});
|
|
727
|
+
const targetScrolled = await this.tryCdpOnResolvedElement('DOM.scrollIntoViewIfNeeded');
|
|
728
|
+
const endpoints = await this.evaluate(`
|
|
729
|
+
(() => {
|
|
730
|
+
const sourceEl = window.__opencli_drag_source;
|
|
731
|
+
const targetEl = window.__resolved;
|
|
732
|
+
if (!sourceEl) throw new Error('No resolved drag source');
|
|
733
|
+
if (!targetEl) throw new Error('No resolved drag target');
|
|
734
|
+
if (${targetScrolled ? 'false' : 'true'}) targetEl.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
735
|
+
const measure = (el) => {
|
|
736
|
+
const rect = el.getBoundingClientRect();
|
|
737
|
+
const w = Math.round(rect.width);
|
|
738
|
+
const h = Math.round(rect.height);
|
|
739
|
+
const x = Math.round(rect.left + rect.width / 2);
|
|
740
|
+
const y = Math.round(rect.top + rect.height / 2);
|
|
741
|
+
const visible = w > 0 && h > 0 && x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight;
|
|
742
|
+
return { x, y, w, h, visible };
|
|
743
|
+
};
|
|
744
|
+
return { source: measure(sourceEl), target: measure(targetEl) };
|
|
745
|
+
})()
|
|
746
|
+
`);
|
|
747
|
+
if (endpoints?.source?.visible !== true) {
|
|
748
|
+
throw new Error(`Drag source "${source}" is not visible at drag time.`);
|
|
749
|
+
}
|
|
750
|
+
if (endpoints?.target?.visible !== true) {
|
|
751
|
+
throw new Error(`Drag target "${target}" has no visible bounding box.`);
|
|
752
|
+
}
|
|
753
|
+
const dragged = await this.tryNativeDrag({ x: endpoints.source.x, y: endpoints.source.y }, { x: endpoints.target.x, y: endpoints.target.y });
|
|
754
|
+
if (!dragged)
|
|
755
|
+
throw new Error('Native drag requires CDP Input.dispatchMouseEvent support.');
|
|
756
|
+
return {
|
|
757
|
+
dragged: true,
|
|
758
|
+
source,
|
|
759
|
+
target,
|
|
760
|
+
source_matches_n: sourceResolved.matches_n,
|
|
761
|
+
target_matches_n: targetResolved.matches_n,
|
|
762
|
+
source_match_level: sourceResolved.match_level,
|
|
763
|
+
target_match_level: targetResolved.match_level,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
finally {
|
|
767
|
+
await this.evaluate('delete window.__opencli_drag_source').catch(() => { });
|
|
768
|
+
}
|
|
769
|
+
}
|
|
306
770
|
async fillText(ref, text, opts = {}) {
|
|
307
771
|
const resolved = await runResolve(this, ref, opts);
|
|
308
772
|
let nativeScrolled = false;
|
|
@@ -411,6 +875,20 @@ export class BasePage {
|
|
|
411
875
|
}
|
|
412
876
|
}
|
|
413
877
|
async snapshot(opts = {}) {
|
|
878
|
+
if (opts.source === 'ax') {
|
|
879
|
+
const cdp = this.cdp;
|
|
880
|
+
if (typeof cdp !== 'function') {
|
|
881
|
+
throw new CliError('BROWSER_AX_UNAVAILABLE', 'AX snapshot requires CDP support from the active browser backend.', 'Use the default DOM state, or update/reload the Browser Bridge extension.');
|
|
882
|
+
}
|
|
883
|
+
const axTrees = await this.collectAxSnapshotTrees(cdp);
|
|
884
|
+
const built = buildAxSnapshotFromTrees(axTrees, {
|
|
885
|
+
maxDepth: opts.maxDepth,
|
|
886
|
+
interactiveOnly: opts.interactive,
|
|
887
|
+
});
|
|
888
|
+
this._axRefs = built.refs;
|
|
889
|
+
return built.text;
|
|
890
|
+
}
|
|
891
|
+
this._axRefs.clear();
|
|
414
892
|
const snapshotJs = generateSnapshotJs({
|
|
415
893
|
viewportExpand: opts.viewportExpand ?? 2000,
|
|
416
894
|
maxDepth: Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200)),
|
|
@@ -440,6 +918,22 @@ export class BasePage {
|
|
|
440
918
|
return this._basicSnapshot(opts);
|
|
441
919
|
}
|
|
442
920
|
}
|
|
921
|
+
async collectAxSnapshotTrees(cdp) {
|
|
922
|
+
await cdp.call(this, 'Accessibility.enable', {});
|
|
923
|
+
const rootTree = await cdp.call(this, 'Accessibility.getFullAXTree', {});
|
|
924
|
+
const trees = [{ tree: rootTree }];
|
|
925
|
+
const frameTreeResult = await cdp.call(this, 'Page.getFrameTree', {}).catch(() => null);
|
|
926
|
+
const frames = collectAxFrameRefs(frameTreeResult);
|
|
927
|
+
for (const frame of frames) {
|
|
928
|
+
if (frame.sessionId) {
|
|
929
|
+
await cdp.call(this, 'Accessibility.enable', axEnableParams(frame)).catch(() => null);
|
|
930
|
+
}
|
|
931
|
+
const tree = await cdp.call(this, 'Accessibility.getFullAXTree', axTreeParams(frame)).catch(() => null);
|
|
932
|
+
if (tree)
|
|
933
|
+
trees.push({ tree, frame });
|
|
934
|
+
}
|
|
935
|
+
return trees;
|
|
936
|
+
}
|
|
443
937
|
async getCurrentUrl() {
|
|
444
938
|
if (this._lastUrl)
|
|
445
939
|
return this._lastUrl;
|
|
@@ -509,3 +1003,52 @@ export class BasePage {
|
|
|
509
1003
|
return raw;
|
|
510
1004
|
}
|
|
511
1005
|
}
|
|
1006
|
+
function axTreeParams(frame) {
|
|
1007
|
+
return frame?.frameId
|
|
1008
|
+
? {
|
|
1009
|
+
frameId: frame.frameId,
|
|
1010
|
+
...(frame.sessionId ? { sessionId: frame.sessionId } : {}),
|
|
1011
|
+
...(frame.targetUrl ? { targetUrl: frame.targetUrl } : {}),
|
|
1012
|
+
}
|
|
1013
|
+
: {};
|
|
1014
|
+
}
|
|
1015
|
+
function axEnableParams(frame) {
|
|
1016
|
+
return frame?.frameId && frame.sessionId
|
|
1017
|
+
? { frameId: frame.frameId, sessionId: frame.sessionId, ...(frame.targetUrl ? { targetUrl: frame.targetUrl } : {}) }
|
|
1018
|
+
: {};
|
|
1019
|
+
}
|
|
1020
|
+
function collectAxFrameRefs(frameTreeResult) {
|
|
1021
|
+
const root = frameTreeResult?.frameTree;
|
|
1022
|
+
const rootUrl = root?.frame?.url || root?.frame?.unreachableUrl || '';
|
|
1023
|
+
const rootOrigin = urlOrigin(rootUrl);
|
|
1024
|
+
if (!root || !rootOrigin)
|
|
1025
|
+
return [];
|
|
1026
|
+
const frames = [];
|
|
1027
|
+
function collect(node) {
|
|
1028
|
+
for (const child of node?.childFrames ?? []) {
|
|
1029
|
+
const frame = child.frame;
|
|
1030
|
+
const frameId = frame?.id;
|
|
1031
|
+
const frameUrl = frame?.url || frame?.unreachableUrl || '';
|
|
1032
|
+
const origin = urlOrigin(frameUrl);
|
|
1033
|
+
if (!frameId)
|
|
1034
|
+
continue;
|
|
1035
|
+
if (origin === rootOrigin) {
|
|
1036
|
+
frames.push({ frameId, url: frameUrl });
|
|
1037
|
+
collect(child);
|
|
1038
|
+
}
|
|
1039
|
+
else {
|
|
1040
|
+
frames.push({ frameId, url: frameUrl, targetUrl: frameUrl, sessionId: 'target' });
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
collect(root);
|
|
1045
|
+
return frames;
|
|
1046
|
+
}
|
|
1047
|
+
function urlOrigin(url) {
|
|
1048
|
+
try {
|
|
1049
|
+
return new URL(url).origin;
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
return null;
|
|
1053
|
+
}
|
|
1054
|
+
}
|