@jackwener/opencli 1.7.14 → 1.7.16

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.
Files changed (153) hide show
  1. package/README.md +9 -6
  2. package/README.zh-CN.md +9 -6
  3. package/cli-manifest.json +374 -74
  4. package/clis/bilibili/subtitle.js +1 -1
  5. package/clis/chatgpt/ask.js +2 -1
  6. package/clis/chatgpt/detail.js +6 -1
  7. package/clis/chatgpt/read.js +2 -1
  8. package/clis/chatgpt/send.js +2 -1
  9. package/clis/chatgpt/utils.js +54 -12
  10. package/clis/chatgpt/utils.test.js +36 -1
  11. package/clis/claude/ask.js +22 -7
  12. package/clis/claude/detail.js +9 -2
  13. package/clis/claude/new.js +8 -2
  14. package/clis/claude/read.js +2 -1
  15. package/clis/claude/send.js +8 -3
  16. package/clis/claude/utils.js +27 -4
  17. package/clis/deepseek/ask.js +21 -8
  18. package/clis/deepseek/detail.js +9 -1
  19. package/clis/deepseek/new.js +13 -2
  20. package/clis/deepseek/read.js +2 -1
  21. package/clis/deepseek/utils.js +8 -1
  22. package/clis/dianping/cityResolver.js +185 -0
  23. package/clis/dianping/dianping.test.js +154 -0
  24. package/clis/dianping/search.js +6 -3
  25. package/clis/douyin/_shared/browser-fetch.js +14 -2
  26. package/clis/douyin/_shared/browser-fetch.test.js +13 -0
  27. package/clis/douyin/stats.js +1 -1
  28. package/clis/douyin/update.js +1 -1
  29. package/clis/jike/search.js +1 -1
  30. package/clis/linkedin/search.js +8 -11
  31. package/clis/maimai/search-talents.js +10 -6
  32. package/clis/openreview/author.js +58 -0
  33. package/clis/openreview/openreview.test.js +83 -1
  34. package/clis/openreview/utils.js +14 -0
  35. package/clis/reddit/comment.js +1 -0
  36. package/clis/reddit/frontpage.js +1 -0
  37. package/clis/reddit/popular.js +1 -0
  38. package/clis/reddit/read.js +2 -0
  39. package/clis/reddit/read.test.js +4 -0
  40. package/clis/reddit/save.js +1 -0
  41. package/clis/reddit/saved.js +1 -0
  42. package/clis/reddit/search.js +2 -1
  43. package/clis/reddit/subreddit.js +2 -1
  44. package/clis/reddit/subscribe.js +1 -0
  45. package/clis/reddit/upvote.js +1 -0
  46. package/clis/reddit/upvoted.js +1 -0
  47. package/clis/reddit/user-comments.js +2 -1
  48. package/clis/reddit/user-posts.js +2 -1
  49. package/clis/reddit/user.js +2 -1
  50. package/clis/twitter/article.js +9 -5
  51. package/clis/twitter/bookmark-folder.js +187 -0
  52. package/clis/twitter/bookmark-folder.test.js +337 -0
  53. package/clis/twitter/bookmark-folders.js +115 -0
  54. package/clis/twitter/bookmark-folders.test.js +152 -0
  55. package/clis/twitter/bookmark.js +15 -6
  56. package/clis/twitter/bookmark.test.js +74 -0
  57. package/clis/twitter/bookmarks.js +10 -10
  58. package/clis/twitter/delete.js +11 -35
  59. package/clis/twitter/delete.test.js +21 -9
  60. package/clis/twitter/download.js +6 -5
  61. package/clis/twitter/followers.js +10 -3
  62. package/clis/twitter/following.js +14 -11
  63. package/clis/twitter/following.test.js +2 -1
  64. package/clis/twitter/hide-reply.js +24 -5
  65. package/clis/twitter/hide-reply.test.js +76 -0
  66. package/clis/twitter/like.js +21 -11
  67. package/clis/twitter/like.test.js +73 -0
  68. package/clis/twitter/likes.js +11 -11
  69. package/clis/twitter/list-add.js +8 -7
  70. package/clis/twitter/list-add.test.js +23 -1
  71. package/clis/twitter/list-remove.js +8 -7
  72. package/clis/twitter/list-remove.test.js +23 -1
  73. package/clis/twitter/list-tweets.js +9 -9
  74. package/clis/twitter/lists.js +6 -8
  75. package/clis/twitter/notifications.js +3 -2
  76. package/clis/twitter/profile.js +11 -7
  77. package/clis/twitter/quote.js +60 -32
  78. package/clis/twitter/quote.test.js +96 -8
  79. package/clis/twitter/reply.js +24 -178
  80. package/clis/twitter/reply.test.js +29 -11
  81. package/clis/twitter/retweet.js +9 -14
  82. package/clis/twitter/retweet.test.js +5 -1
  83. package/clis/twitter/search.js +176 -23
  84. package/clis/twitter/search.test.js +266 -1
  85. package/clis/twitter/shared.js +43 -0
  86. package/clis/twitter/shared.test.js +107 -1
  87. package/clis/twitter/thread.js +11 -11
  88. package/clis/twitter/timeline.js +13 -13
  89. package/clis/twitter/trending.js +4 -4
  90. package/clis/twitter/tweets.js +8 -9
  91. package/clis/twitter/unbookmark.js +13 -6
  92. package/clis/twitter/unbookmark.test.js +73 -0
  93. package/clis/twitter/unlike.js +6 -13
  94. package/clis/twitter/unlike.test.js +5 -2
  95. package/clis/twitter/unretweet.js +9 -14
  96. package/clis/twitter/unretweet.test.js +5 -1
  97. package/clis/twitter/utils.js +286 -0
  98. package/clis/twitter/utils.test.js +169 -0
  99. package/clis/youtube/like.js +6 -2
  100. package/clis/youtube/subscribe.js +6 -2
  101. package/clis/youtube/unlike.js +6 -2
  102. package/clis/youtube/unsubscribe.js +6 -2
  103. package/clis/youtube/utils.js +19 -13
  104. package/clis/youtube/utils.test.js +17 -1
  105. package/dist/src/browser/ax-snapshot.d.ts +37 -0
  106. package/dist/src/browser/ax-snapshot.js +217 -0
  107. package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
  108. package/dist/src/browser/ax-snapshot.test.js +91 -0
  109. package/dist/src/browser/base-page.d.ts +51 -0
  110. package/dist/src/browser/base-page.js +545 -2
  111. package/dist/src/browser/base-page.test.js +520 -4
  112. package/dist/src/browser/bridge.d.ts +1 -0
  113. package/dist/src/browser/bridge.js +1 -1
  114. package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
  115. package/dist/src/browser/cdp-click-fixture.test.js +87 -0
  116. package/dist/src/browser/cdp.d.ts +1 -0
  117. package/dist/src/browser/cdp.js +5 -0
  118. package/dist/src/browser/cdp.test.js +1 -0
  119. package/dist/src/browser/daemon-client.d.ts +5 -3
  120. package/dist/src/browser/daemon-client.js +6 -3
  121. package/dist/src/browser/daemon-client.test.js +10 -0
  122. package/dist/src/browser/find.d.ts +9 -1
  123. package/dist/src/browser/find.js +219 -0
  124. package/dist/src/browser/find.test.js +61 -1
  125. package/dist/src/browser/page.d.ts +4 -2
  126. package/dist/src/browser/page.js +18 -1
  127. package/dist/src/browser/page.test.js +28 -0
  128. package/dist/src/browser/target-errors.d.ts +3 -1
  129. package/dist/src/browser/target-errors.js +2 -0
  130. package/dist/src/browser/target-resolver.d.ts +14 -0
  131. package/dist/src/browser/target-resolver.js +28 -0
  132. package/dist/src/browser/visual-refs.d.ts +11 -0
  133. package/dist/src/browser/visual-refs.js +108 -0
  134. package/dist/src/build-manifest.d.ts +23 -0
  135. package/dist/src/build-manifest.js +34 -0
  136. package/dist/src/build-manifest.test.js +108 -1
  137. package/dist/src/cli.js +630 -60
  138. package/dist/src/cli.test.js +731 -1
  139. package/dist/src/commanderAdapter.js +7 -0
  140. package/dist/src/doctor.js +2 -2
  141. package/dist/src/doctor.test.js +4 -4
  142. package/dist/src/execution.d.ts +2 -0
  143. package/dist/src/execution.js +31 -6
  144. package/dist/src/execution.test.js +43 -16
  145. package/dist/src/external-clis.yaml +24 -0
  146. package/dist/src/help.d.ts +33 -0
  147. package/dist/src/help.js +174 -0
  148. package/dist/src/main.js +4 -14
  149. package/dist/src/runtime.d.ts +3 -0
  150. package/dist/src/runtime.js +1 -0
  151. package/dist/src/types.d.ts +83 -1
  152. package/package.json +1 -1
  153. 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: Execute click on resolved element
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
+ }