puppeteer-bidi 0.0.1.beta1

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 (76) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +13 -0
  4. data/CLAUDE/README.md +158 -0
  5. data/CLAUDE/async_programming.md +158 -0
  6. data/CLAUDE/click_implementation.md +340 -0
  7. data/CLAUDE/core_layer_gotchas.md +136 -0
  8. data/CLAUDE/error_handling.md +232 -0
  9. data/CLAUDE/file_chooser.md +95 -0
  10. data/CLAUDE/frame_architecture.md +346 -0
  11. data/CLAUDE/javascript_evaluation.md +341 -0
  12. data/CLAUDE/jshandle_implementation.md +505 -0
  13. data/CLAUDE/keyboard_implementation.md +250 -0
  14. data/CLAUDE/mouse_implementation.md +140 -0
  15. data/CLAUDE/navigation_waiting.md +234 -0
  16. data/CLAUDE/porting_puppeteer.md +214 -0
  17. data/CLAUDE/query_handler.md +194 -0
  18. data/CLAUDE/rspec_pending_vs_skip.md +262 -0
  19. data/CLAUDE/selector_evaluation.md +198 -0
  20. data/CLAUDE/test_server_routes.md +263 -0
  21. data/CLAUDE/testing_strategy.md +236 -0
  22. data/CLAUDE/two_layer_architecture.md +180 -0
  23. data/CLAUDE/wrapped_element_click.md +247 -0
  24. data/CLAUDE.md +185 -0
  25. data/LICENSE.txt +21 -0
  26. data/README.md +488 -0
  27. data/Rakefile +21 -0
  28. data/lib/puppeteer/bidi/async_utils.rb +151 -0
  29. data/lib/puppeteer/bidi/browser.rb +285 -0
  30. data/lib/puppeteer/bidi/browser_context.rb +53 -0
  31. data/lib/puppeteer/bidi/browser_launcher.rb +240 -0
  32. data/lib/puppeteer/bidi/connection.rb +182 -0
  33. data/lib/puppeteer/bidi/core/README.md +169 -0
  34. data/lib/puppeteer/bidi/core/browser.rb +230 -0
  35. data/lib/puppeteer/bidi/core/browsing_context.rb +601 -0
  36. data/lib/puppeteer/bidi/core/disposable.rb +69 -0
  37. data/lib/puppeteer/bidi/core/errors.rb +64 -0
  38. data/lib/puppeteer/bidi/core/event_emitter.rb +83 -0
  39. data/lib/puppeteer/bidi/core/navigation.rb +128 -0
  40. data/lib/puppeteer/bidi/core/realm.rb +315 -0
  41. data/lib/puppeteer/bidi/core/request.rb +300 -0
  42. data/lib/puppeteer/bidi/core/session.rb +153 -0
  43. data/lib/puppeteer/bidi/core/user_context.rb +208 -0
  44. data/lib/puppeteer/bidi/core/user_prompt.rb +102 -0
  45. data/lib/puppeteer/bidi/core.rb +45 -0
  46. data/lib/puppeteer/bidi/deserializer.rb +132 -0
  47. data/lib/puppeteer/bidi/element_handle.rb +602 -0
  48. data/lib/puppeteer/bidi/errors.rb +42 -0
  49. data/lib/puppeteer/bidi/file_chooser.rb +52 -0
  50. data/lib/puppeteer/bidi/frame.rb +597 -0
  51. data/lib/puppeteer/bidi/http_response.rb +23 -0
  52. data/lib/puppeteer/bidi/injected.js +1 -0
  53. data/lib/puppeteer/bidi/injected_source.rb +21 -0
  54. data/lib/puppeteer/bidi/js_handle.rb +302 -0
  55. data/lib/puppeteer/bidi/keyboard.rb +265 -0
  56. data/lib/puppeteer/bidi/lazy_arg.rb +23 -0
  57. data/lib/puppeteer/bidi/mouse.rb +170 -0
  58. data/lib/puppeteer/bidi/page.rb +613 -0
  59. data/lib/puppeteer/bidi/query_handler.rb +397 -0
  60. data/lib/puppeteer/bidi/realm.rb +242 -0
  61. data/lib/puppeteer/bidi/serializer.rb +139 -0
  62. data/lib/puppeteer/bidi/target.rb +81 -0
  63. data/lib/puppeteer/bidi/task_manager.rb +44 -0
  64. data/lib/puppeteer/bidi/timeout_settings.rb +20 -0
  65. data/lib/puppeteer/bidi/transport.rb +129 -0
  66. data/lib/puppeteer/bidi/version.rb +7 -0
  67. data/lib/puppeteer/bidi/wait_task.rb +322 -0
  68. data/lib/puppeteer/bidi.rb +49 -0
  69. data/scripts/update_injected_source.rb +57 -0
  70. data/sig/puppeteer/bidi/browser.rbs +80 -0
  71. data/sig/puppeteer/bidi/element_handle.rbs +238 -0
  72. data/sig/puppeteer/bidi/frame.rbs +205 -0
  73. data/sig/puppeteer/bidi/js_handle.rbs +90 -0
  74. data/sig/puppeteer/bidi/page.rbs +247 -0
  75. data/sig/puppeteer/bidi.rbs +15 -0
  76. metadata +176 -0
@@ -0,0 +1,602 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Puppeteer
5
+ module Bidi
6
+ # ElementHandle represents a reference to a DOM element
7
+ # Based on Puppeteer's BidiElementHandle implementation
8
+ # This extends JSHandle with DOM-specific methods
9
+ class ElementHandle < JSHandle
10
+ # Bounding box data class representing element position and dimensions
11
+ BoundingBox = Data.define(:x, :y, :width, :height)
12
+
13
+ # Point data class representing a coordinate
14
+ Point = Data.define(:x, :y)
15
+
16
+ # Box model data class representing element's CSS box model
17
+ # Each quad (content, padding, border, margin) contains 4 Points representing corners
18
+ # Corners are ordered: top-left, top-right, bottom-right, bottom-left
19
+ BoxModel = Data.define(:content, :padding, :border, :margin, :width, :height)
20
+
21
+ # Factory method to create ElementHandle from remote value
22
+ # @rbs remote_value: Hash[String, untyped]
23
+ # @rbs realm: Core::Realm
24
+ # @rbs return: ElementHandle
25
+ def self.from(remote_value, realm)
26
+ new(realm, remote_value)
27
+ end
28
+
29
+ # Query for a descendant element matching the selector
30
+ # Supports CSS selectors and prefixed selectors (xpath/, text/, aria/, pierce/)
31
+ # @rbs selector: String
32
+ # @rbs return: ElementHandle?
33
+ def query_selector(selector)
34
+ assert_not_disposed
35
+
36
+ result = QueryHandler.instance.get_query_handler_and_selector(selector)
37
+ result.query_handler.new.run_query_one(self, result.updated_selector)
38
+ end
39
+
40
+ # Query for all descendant elements matching the selector
41
+ # Supports CSS selectors and prefixed selectors (xpath/, text/, aria/, pierce/)
42
+ # @rbs selector: String
43
+ # @rbs return: Array[ElementHandle]
44
+ def query_selector_all(selector)
45
+ assert_not_disposed
46
+
47
+ result = QueryHandler.instance.get_query_handler_and_selector(selector)
48
+ result.query_handler.new.run_query_all(self, result.updated_selector)
49
+ end
50
+
51
+ # Evaluate a function on the first element matching the selector
52
+ # @rbs selector: String
53
+ # @rbs page_function: String
54
+ # @rbs *args: untyped
55
+ # @rbs return: untyped
56
+ def eval_on_selector(selector, page_function, *args)
57
+ assert_not_disposed
58
+
59
+ element_handle = query_selector(selector)
60
+ raise SelectorNotFoundError, selector unless element_handle
61
+
62
+ begin
63
+ element_handle.evaluate(page_function, *args)
64
+ ensure
65
+ element_handle.dispose
66
+ end
67
+ end
68
+
69
+ # Evaluate a function on all elements matching the selector
70
+ # @rbs selector: String
71
+ # @rbs page_function: String
72
+ # @rbs *args: untyped
73
+ # @rbs return: untyped
74
+ def eval_on_selector_all(selector, page_function, *args)
75
+ assert_not_disposed
76
+
77
+ # Get all matching elements
78
+ element_handles = query_selector_all(selector)
79
+
80
+ begin
81
+ # Create an array handle containing all element handles
82
+ # Use evaluateHandle to create an array in the browser context
83
+ array_handle = @realm.call_function(
84
+ '(...elements) => elements',
85
+ false,
86
+ arguments: element_handles.map(&:remote_value)
87
+ ).wait
88
+
89
+ # Create a JSHandle for the array
90
+ array_js_handle = JSHandle.from(array_handle['result'], @realm)
91
+
92
+ begin
93
+ # Evaluate the page_function with the array as first argument
94
+ array_js_handle.evaluate(page_function, *args)
95
+ ensure
96
+ array_js_handle.dispose
97
+ end
98
+ ensure
99
+ # Dispose all element handles
100
+ element_handles.each(&:dispose)
101
+ end
102
+ end
103
+
104
+ # Wait for an element matching the selector to appear as a descendant of this element
105
+ # @rbs selector: String
106
+ # @rbs visible: bool?
107
+ # @rbs hidden: bool?
108
+ # @rbs timeout: Numeric?
109
+ # @rbs &block: ((ElementHandle?) -> void)?
110
+ # @rbs return: ElementHandle?
111
+ def wait_for_selector(selector, visible: nil, hidden: nil, timeout: nil, &block)
112
+ result = QueryHandler.instance.get_query_handler_and_selector(selector)
113
+ result.query_handler.new.wait_for(self, result.updated_selector, visible: visible, hidden: hidden, polling: result.polling, timeout: timeout, &block)
114
+ end
115
+
116
+ # Click the element
117
+ # @rbs button: String
118
+ # @rbs count: Integer
119
+ # @rbs delay: Numeric?
120
+ # @rbs offset: Hash[Symbol, Numeric]?
121
+ # @rbs return: void
122
+ def click(button: 'left', count: 1, delay: nil, offset: nil)
123
+ assert_not_disposed
124
+
125
+ scroll_into_view_if_needed
126
+ point = clickable_point(offset: offset)
127
+
128
+ frame.page.mouse.click(point.x, point.y, button: button, count: count, delay: delay)
129
+ end
130
+
131
+ # Type text into the element
132
+ # @rbs text: String
133
+ # @rbs delay: Numeric
134
+ # @rbs return: void
135
+ def type(text, delay: 0)
136
+ assert_not_disposed
137
+
138
+ # Focus the element first
139
+ focus
140
+
141
+ # Get keyboard instance - use frame.page to access the page
142
+ # Following Puppeteer's pattern: this.frame.page().keyboard
143
+ keyboard = Keyboard.new(frame.page, @realm.browsing_context)
144
+ keyboard.type(text, delay: delay)
145
+ end
146
+
147
+ # Press a key on the element
148
+ # @rbs key: String
149
+ # @rbs delay: Numeric?
150
+ # @rbs text: String?
151
+ # @rbs return: void
152
+ def press(key, delay: nil, text: nil)
153
+ assert_not_disposed
154
+
155
+ # Focus the element first
156
+ focus
157
+
158
+ # Get keyboard instance - use frame.page to access the page
159
+ # Following Puppeteer's pattern: this.frame.page().keyboard
160
+ keyboard = Keyboard.new(frame.page, @realm.browsing_context)
161
+ keyboard.press(key, delay: delay, text: text)
162
+ end
163
+
164
+ # Get the frame this element belongs to
165
+ # Following Puppeteer's pattern: realm.environment
166
+ # @rbs return: Frame
167
+ def frame
168
+ @realm.environment
169
+ end
170
+
171
+ # Get the content frame for iframe/frame elements
172
+ # Returns the frame that the iframe/frame element refers to
173
+ # @rbs return: Frame?
174
+ def content_frame
175
+ assert_not_disposed
176
+
177
+ handle = evaluate_handle(<<~JS)
178
+ element => {
179
+ if (element instanceof HTMLIFrameElement || element instanceof HTMLFrameElement) {
180
+ return element.contentWindow;
181
+ }
182
+ return undefined;
183
+ }
184
+ JS
185
+
186
+ begin
187
+ value = handle.remote_value
188
+ if value['type'] == 'window'
189
+ # Find the frame with matching browsing context ID
190
+ context_id = value.dig('value', 'context')
191
+ return nil unless context_id
192
+
193
+ frame.page.frames.find { |f| f.browsing_context.id == context_id }
194
+ else
195
+ nil
196
+ end
197
+ ensure
198
+ handle.dispose
199
+ end
200
+ end
201
+
202
+ # Check if the element is visible
203
+ # An element is considered visible if:
204
+ # - It has computed styles
205
+ # - Its visibility is not 'hidden' or 'collapse'
206
+ # - Its bounding box is not empty (width > 0 AND height > 0)
207
+ # @rbs return: bool
208
+ def visible?
209
+ check_visibility(true)
210
+ end
211
+
212
+ # Check if the element is hidden
213
+ # An element is considered hidden if:
214
+ # - It has no computed styles
215
+ # - Its visibility is 'hidden' or 'collapse'
216
+ # - Its bounding box is empty (width == 0 OR height == 0)
217
+ # @rbs return: bool
218
+ def hidden?
219
+ check_visibility(false)
220
+ end
221
+
222
+ # Convert the current handle to the given element type
223
+ # Validates that the element matches the expected tag name
224
+ # @rbs tag_name: String
225
+ # @rbs return: ElementHandle
226
+ def to_element(tag_name)
227
+ assert_not_disposed
228
+
229
+ is_matching = evaluate('(node, tagName) => node.nodeName === tagName.toUpperCase()', tag_name)
230
+ raise "Element is not a(n) `#{tag_name}` element" unless is_matching
231
+
232
+ self
233
+ end
234
+
235
+ # Focus the element
236
+ # @rbs return: void
237
+ def focus
238
+ assert_not_disposed
239
+
240
+ evaluate('element => element.focus()')
241
+ end
242
+
243
+ # Hover over the element
244
+ # Scrolls element into view if needed and moves mouse to element center
245
+ # @rbs return: void
246
+ def hover
247
+ assert_not_disposed
248
+
249
+ scroll_into_view_if_needed
250
+ point = clickable_point
251
+ frame.page.mouse.move(point.x, point.y)
252
+ end
253
+
254
+ # Upload files to this element (for <input type="file">)
255
+ # Following Puppeteer's implementation: ElementHandle.uploadFile -> Frame.setFiles
256
+ # @rbs *files: String
257
+ # @rbs return: void
258
+ def upload_file(*files)
259
+ assert_not_disposed
260
+
261
+ # Resolve relative paths to absolute paths
262
+ files = files.map do |file|
263
+ if File.absolute_path?(file)
264
+ file
265
+ else
266
+ File.expand_path(file)
267
+ end
268
+ end
269
+
270
+ frame.set_files(self, files)
271
+ end
272
+
273
+ # Get the remote value as a SharedReference for BiDi commands
274
+ # @rbs return: Hash[Symbol, String]
275
+ def remote_value_as_shared_reference
276
+ if @remote_value['sharedId']
277
+ { sharedId: @remote_value['sharedId'] }
278
+ else
279
+ { handle: @remote_value['handle'] }
280
+ end
281
+ end
282
+
283
+ # Scroll element into view if needed
284
+ # @rbs return: void
285
+ def scroll_into_view_if_needed
286
+ assert_not_disposed
287
+
288
+ # Check if element is already visible
289
+ return if intersecting_viewport?(threshold: 1)
290
+
291
+ scroll_into_view
292
+ end
293
+
294
+ # Scroll element into view
295
+ # @rbs return: void
296
+ def scroll_into_view
297
+ assert_not_disposed
298
+
299
+ evaluate('element => element.scrollIntoView({block: "center", inline: "center", behavior: "instant"})')
300
+ end
301
+
302
+ # Check if element is intersecting the viewport
303
+ # @rbs threshold: Numeric
304
+ # @rbs return: bool
305
+ def intersecting_viewport?(threshold: 0)
306
+ assert_not_disposed
307
+
308
+ result = evaluate(<<~JS, threshold)
309
+ (element, threshold) => {
310
+ return new Promise(resolve => {
311
+ const observer = new IntersectionObserver(entries => {
312
+ resolve(entries[0].intersectionRatio > threshold);
313
+ observer.disconnect();
314
+ });
315
+ observer.observe(element);
316
+ });
317
+ }
318
+ JS
319
+
320
+ result
321
+ end
322
+
323
+ # Get clickable point for the element
324
+ # @rbs offset: Hash[Symbol, Numeric]?
325
+ # @rbs return: Point
326
+ def clickable_point(offset: nil)
327
+ assert_not_disposed
328
+
329
+ box = clickable_box
330
+ raise 'Node is either not clickable or not an Element' unless box
331
+
332
+ if offset
333
+ Point.new(
334
+ x: box[:x] + offset[:x],
335
+ y: box[:y] + offset[:y]
336
+ )
337
+ else
338
+ Point.new(
339
+ x: box[:x] + box[:width] / 2,
340
+ y: box[:y] + box[:height] / 2
341
+ )
342
+ end
343
+ end
344
+
345
+ # Get the bounding box of the element
346
+ # Uses getBoundingClientRect() to get the element's position and size
347
+ # @rbs return: BoundingBox?
348
+ def bounding_box
349
+ assert_not_disposed
350
+
351
+ result = evaluate(<<~JS)
352
+ element => {
353
+ if (!(element instanceof Element)) {
354
+ return null;
355
+ }
356
+ const rect = element.getBoundingClientRect();
357
+ return {x: rect.x, y: rect.y, width: rect.width, height: rect.height};
358
+ }
359
+ JS
360
+
361
+ return nil unless result
362
+
363
+ # Return nil if element has zero dimensions (not visible)
364
+ return nil if result['width'].zero? && result['height'].zero?
365
+
366
+ BoundingBox.new(
367
+ x: result['x'],
368
+ y: result['y'],
369
+ width: result['width'],
370
+ height: result['height']
371
+ )
372
+ end
373
+
374
+ # Get the box model of the element (content, padding, border, margin)
375
+ # @rbs return: BoxModel?
376
+ def box_model
377
+ assert_not_disposed
378
+
379
+ model = evaluate(<<~JS)
380
+ element => {
381
+ if (!(element instanceof Element)) {
382
+ return null;
383
+ }
384
+ // Element is not visible
385
+ if (element.getClientRects().length === 0) {
386
+ return null;
387
+ }
388
+ const rect = element.getBoundingClientRect();
389
+ const style = window.getComputedStyle(element);
390
+ const offsets = {
391
+ padding: {
392
+ left: parseInt(style.paddingLeft, 10),
393
+ top: parseInt(style.paddingTop, 10),
394
+ right: parseInt(style.paddingRight, 10),
395
+ bottom: parseInt(style.paddingBottom, 10),
396
+ },
397
+ margin: {
398
+ left: -parseInt(style.marginLeft, 10),
399
+ top: -parseInt(style.marginTop, 10),
400
+ right: -parseInt(style.marginRight, 10),
401
+ bottom: -parseInt(style.marginBottom, 10),
402
+ },
403
+ border: {
404
+ left: parseInt(style.borderLeftWidth, 10),
405
+ top: parseInt(style.borderTopWidth, 10),
406
+ right: parseInt(style.borderRightWidth, 10),
407
+ bottom: parseInt(style.borderBottomWidth, 10),
408
+ },
409
+ };
410
+ const border = [
411
+ {x: rect.left, y: rect.top},
412
+ {x: rect.left + rect.width, y: rect.top},
413
+ {x: rect.left + rect.width, y: rect.top + rect.height},
414
+ {x: rect.left, y: rect.top + rect.height},
415
+ ];
416
+ const padding = transformQuadWithOffsets(border, offsets.border);
417
+ const content = transformQuadWithOffsets(padding, offsets.padding);
418
+ const margin = transformQuadWithOffsets(border, offsets.margin);
419
+ return {
420
+ content,
421
+ padding,
422
+ border,
423
+ margin,
424
+ width: rect.width,
425
+ height: rect.height,
426
+ };
427
+
428
+ function transformQuadWithOffsets(quad, offsets) {
429
+ return [
430
+ {
431
+ x: quad[0].x + offsets.left,
432
+ y: quad[0].y + offsets.top,
433
+ },
434
+ {
435
+ x: quad[1].x - offsets.right,
436
+ y: quad[1].y + offsets.top,
437
+ },
438
+ {
439
+ x: quad[2].x - offsets.right,
440
+ y: quad[2].y - offsets.bottom,
441
+ },
442
+ {
443
+ x: quad[3].x + offsets.left,
444
+ y: quad[3].y - offsets.bottom,
445
+ },
446
+ ];
447
+ }
448
+ }
449
+ JS
450
+
451
+ return nil unless model
452
+
453
+ # Convert raw arrays to Point objects for each quad
454
+ BoxModel.new(
455
+ content: model['content'].map { |p| Point.new(x: p['x'], y: p['y']) },
456
+ padding: model['padding'].map { |p| Point.new(x: p['x'], y: p['y']) },
457
+ border: model['border'].map { |p| Point.new(x: p['x'], y: p['y']) },
458
+ margin: model['margin'].map { |p| Point.new(x: p['x'], y: p['y']) },
459
+ width: model['width'],
460
+ height: model['height']
461
+ )
462
+ end
463
+
464
+ # Get the clickable box for the element
465
+ # Uses getClientRects() to handle wrapped/multi-line elements correctly
466
+ # Following Puppeteer's implementation:
467
+ # https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/api/ElementHandle.ts#clickableBox
468
+ # @rbs return: Hash[Symbol, Numeric]?
469
+ def clickable_box
470
+ assert_not_disposed
471
+
472
+ # Get client rects - returns multiple boxes for wrapped elements
473
+ boxes = evaluate(<<~JS)
474
+ element => {
475
+ if (!(element instanceof Element)) {
476
+ return null;
477
+ }
478
+ return [...element.getClientRects()].map(rect => {
479
+ return {x: rect.x, y: rect.y, width: rect.width, height: rect.height};
480
+ });
481
+ }
482
+ JS
483
+
484
+ return nil unless boxes&.is_a?(Array) && !boxes.empty?
485
+
486
+ # Intersect boxes with frame boundaries
487
+ intersect_bounding_boxes_with_frame(boxes)
488
+
489
+ # TODO: Handle parent frames (for iframe support)
490
+ # frame = self.frame
491
+ # while (parent_frame = frame.parent_frame)
492
+ # # Adjust coordinates for parent frame offset
493
+ # end
494
+
495
+ # Find first box with valid dimensions
496
+ box = boxes.find { |rect| rect['width'] >= 1 && rect['height'] >= 1 }
497
+ return nil unless box
498
+
499
+ {
500
+ x: box['x'],
501
+ y: box['y'],
502
+ width: box['width'],
503
+ height: box['height']
504
+ }
505
+ end
506
+
507
+ private
508
+
509
+ # Intersect bounding boxes with frame viewport boundaries
510
+ # Modifies boxes in-place to clip them to visible area
511
+ # @rbs boxes: Array[Hash[String, Numeric]]
512
+ # @rbs return: void
513
+ def intersect_bounding_boxes_with_frame(boxes)
514
+ # Get document dimensions using element's evaluate (which handles deserialization)
515
+ dimensions = evaluate(<<~JS)
516
+ element => {
517
+ return {
518
+ documentWidth: element.ownerDocument.documentElement.clientWidth,
519
+ documentHeight: element.ownerDocument.documentElement.clientHeight
520
+ };
521
+ }
522
+ JS
523
+
524
+ document_width = dimensions['documentWidth']
525
+ document_height = dimensions['documentHeight']
526
+
527
+ # Intersect each box with document boundaries
528
+ boxes.each do |box|
529
+ intersect_bounding_box(box, document_width, document_height)
530
+ end
531
+ end
532
+
533
+ # Intersect a single bounding box with given width/height boundaries
534
+ # Modifies box in-place
535
+ # @rbs box: Hash[String, Numeric]
536
+ # @rbs width: Numeric
537
+ # @rbs height: Numeric
538
+ # @rbs return: void
539
+ def intersect_bounding_box(box, width, height)
540
+ # Clip width
541
+ box['width'] = [
542
+ box['x'] >= 0 ?
543
+ [width - box['x'], box['width']].min :
544
+ [width, box['width'] + box['x']].min,
545
+ 0
546
+ ].max
547
+
548
+ # Clip height
549
+ box['height'] = [
550
+ box['y'] >= 0 ?
551
+ [height - box['y'], box['height']].min :
552
+ [height, box['height'] + box['y']].min,
553
+ 0
554
+ ].max
555
+
556
+ # Ensure non-negative coordinates
557
+ box['x'] = [box['x'], 0].max
558
+ box['y'] = [box['y'], 0].max
559
+ end
560
+
561
+ # Check element visibility
562
+ # @rbs visible: bool
563
+ # @rbs return: bool
564
+ def check_visibility(visible)
565
+ assert_not_disposed
566
+
567
+ evaluate(<<~JS, visible)
568
+ (node, visible) => {
569
+ const HIDDEN_VISIBILITY_VALUES = ['hidden', 'collapse'];
570
+
571
+ if (!node) {
572
+ return visible === false;
573
+ }
574
+
575
+ // For text nodes, check parent element
576
+ const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
577
+ if (!element) {
578
+ return visible === false;
579
+ }
580
+
581
+ const style = window.getComputedStyle(element);
582
+ const rect = element.getBoundingClientRect();
583
+ const isBoundingBoxEmpty = rect.width === 0 || rect.height === 0;
584
+
585
+ const isVisible = style &&
586
+ !HIDDEN_VISIBILITY_VALUES.includes(style.visibility) &&
587
+ !isBoundingBoxEmpty;
588
+
589
+ return visible === isVisible;
590
+ }
591
+ JS
592
+ end
593
+
594
+ # String representation includes element type
595
+ # @rbs return: String
596
+ def to_s
597
+ return 'ElementHandle@disposed' if disposed?
598
+ 'ElementHandle@node'
599
+ end
600
+ end
601
+ end
602
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puppeteer
4
+ module Bidi
5
+ class Error < StandardError; end
6
+
7
+ # Raised when attempting to use a disposed JSHandle or ElementHandle
8
+ class JSHandleDisposedError < Error
9
+ def initialize
10
+ super('JSHandle is disposed')
11
+ end
12
+ end
13
+
14
+ # Raised when attempting to use a closed Page
15
+ class PageClosedError < Error
16
+ def initialize
17
+ super('Page is closed')
18
+ end
19
+ end
20
+
21
+ # Raised when attempting to use a detached Frame
22
+ class FrameDetachedError < Error
23
+ def initialize(message = 'Frame is detached')
24
+ super(message)
25
+ end
26
+ end
27
+
28
+ # Raised when a selector does not match any elements
29
+ class SelectorNotFoundError < Error
30
+ attr_reader :selector
31
+
32
+ def initialize(selector)
33
+ @selector = selector
34
+ super("Error: failed to find element matching selector \"#{selector}\"")
35
+ end
36
+ end
37
+
38
+ # Raised when a timeout occurs (e.g., navigation timeout)
39
+ class TimeoutError < Error
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puppeteer
4
+ module Bidi
5
+ # FileChooser represents a file chooser dialog opened by an input element
6
+ # Based on Puppeteer's FileChooser implementation
7
+ class FileChooser
8
+ # @param element [ElementHandle] The input element that opened the file chooser
9
+ # @param multiple [Boolean] Whether multiple files can be selected
10
+ def initialize(element, multiple)
11
+ @element = element
12
+ @multiple = multiple
13
+ @handled = false
14
+ end
15
+
16
+ # Check if multiple files can be selected
17
+ # @return [Boolean] True if multiple files can be selected
18
+ def multiple?
19
+ @multiple
20
+ end
21
+
22
+ # Accept the file chooser with the given file paths
23
+ # @param paths [Array<String>] File paths to accept
24
+ # @raise [RuntimeError] If the file chooser has already been handled
25
+ # @raise [RuntimeError] If multiple files passed to single-file input
26
+ def accept(paths)
27
+ raise 'Cannot accept FileChooser which is already handled!' if @handled
28
+
29
+ # Validate that single-file inputs don't receive multiple files
30
+ if !@multiple && paths.length > 1
31
+ raise 'Multiple file paths passed to a file input that does not accept multiple files'
32
+ end
33
+
34
+ @handled = true
35
+ @element.upload_file(*paths)
36
+ end
37
+
38
+ # Cancel the file chooser
39
+ # @raise [RuntimeError] If the file chooser has already been handled
40
+ def cancel
41
+ raise 'Cannot cancel FileChooser which is already handled!' if @handled
42
+
43
+ @handled = true
44
+ @element.evaluate(<<~JAVASCRIPT)
45
+ element => {
46
+ element.dispatchEvent(new Event('cancel', {bubbles: true}));
47
+ }
48
+ JAVASCRIPT
49
+ end
50
+ end
51
+ end
52
+ end