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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/CLAUDE/README.md +158 -0
- data/CLAUDE/async_programming.md +158 -0
- data/CLAUDE/click_implementation.md +340 -0
- data/CLAUDE/core_layer_gotchas.md +136 -0
- data/CLAUDE/error_handling.md +232 -0
- data/CLAUDE/file_chooser.md +95 -0
- data/CLAUDE/frame_architecture.md +346 -0
- data/CLAUDE/javascript_evaluation.md +341 -0
- data/CLAUDE/jshandle_implementation.md +505 -0
- data/CLAUDE/keyboard_implementation.md +250 -0
- data/CLAUDE/mouse_implementation.md +140 -0
- data/CLAUDE/navigation_waiting.md +234 -0
- data/CLAUDE/porting_puppeteer.md +214 -0
- data/CLAUDE/query_handler.md +194 -0
- data/CLAUDE/rspec_pending_vs_skip.md +262 -0
- data/CLAUDE/selector_evaluation.md +198 -0
- data/CLAUDE/test_server_routes.md +263 -0
- data/CLAUDE/testing_strategy.md +236 -0
- data/CLAUDE/two_layer_architecture.md +180 -0
- data/CLAUDE/wrapped_element_click.md +247 -0
- data/CLAUDE.md +185 -0
- data/LICENSE.txt +21 -0
- data/README.md +488 -0
- data/Rakefile +21 -0
- data/lib/puppeteer/bidi/async_utils.rb +151 -0
- data/lib/puppeteer/bidi/browser.rb +285 -0
- data/lib/puppeteer/bidi/browser_context.rb +53 -0
- data/lib/puppeteer/bidi/browser_launcher.rb +240 -0
- data/lib/puppeteer/bidi/connection.rb +182 -0
- data/lib/puppeteer/bidi/core/README.md +169 -0
- data/lib/puppeteer/bidi/core/browser.rb +230 -0
- data/lib/puppeteer/bidi/core/browsing_context.rb +601 -0
- data/lib/puppeteer/bidi/core/disposable.rb +69 -0
- data/lib/puppeteer/bidi/core/errors.rb +64 -0
- data/lib/puppeteer/bidi/core/event_emitter.rb +83 -0
- data/lib/puppeteer/bidi/core/navigation.rb +128 -0
- data/lib/puppeteer/bidi/core/realm.rb +315 -0
- data/lib/puppeteer/bidi/core/request.rb +300 -0
- data/lib/puppeteer/bidi/core/session.rb +153 -0
- data/lib/puppeteer/bidi/core/user_context.rb +208 -0
- data/lib/puppeteer/bidi/core/user_prompt.rb +102 -0
- data/lib/puppeteer/bidi/core.rb +45 -0
- data/lib/puppeteer/bidi/deserializer.rb +132 -0
- data/lib/puppeteer/bidi/element_handle.rb +602 -0
- data/lib/puppeteer/bidi/errors.rb +42 -0
- data/lib/puppeteer/bidi/file_chooser.rb +52 -0
- data/lib/puppeteer/bidi/frame.rb +597 -0
- data/lib/puppeteer/bidi/http_response.rb +23 -0
- data/lib/puppeteer/bidi/injected.js +1 -0
- data/lib/puppeteer/bidi/injected_source.rb +21 -0
- data/lib/puppeteer/bidi/js_handle.rb +302 -0
- data/lib/puppeteer/bidi/keyboard.rb +265 -0
- data/lib/puppeteer/bidi/lazy_arg.rb +23 -0
- data/lib/puppeteer/bidi/mouse.rb +170 -0
- data/lib/puppeteer/bidi/page.rb +613 -0
- data/lib/puppeteer/bidi/query_handler.rb +397 -0
- data/lib/puppeteer/bidi/realm.rb +242 -0
- data/lib/puppeteer/bidi/serializer.rb +139 -0
- data/lib/puppeteer/bidi/target.rb +81 -0
- data/lib/puppeteer/bidi/task_manager.rb +44 -0
- data/lib/puppeteer/bidi/timeout_settings.rb +20 -0
- data/lib/puppeteer/bidi/transport.rb +129 -0
- data/lib/puppeteer/bidi/version.rb +7 -0
- data/lib/puppeteer/bidi/wait_task.rb +322 -0
- data/lib/puppeteer/bidi.rb +49 -0
- data/scripts/update_injected_source.rb +57 -0
- data/sig/puppeteer/bidi/browser.rbs +80 -0
- data/sig/puppeteer/bidi/element_handle.rbs +238 -0
- data/sig/puppeteer/bidi/frame.rbs +205 -0
- data/sig/puppeteer/bidi/js_handle.rbs +90 -0
- data/sig/puppeteer/bidi/page.rbs +247 -0
- data/sig/puppeteer/bidi.rbs +15 -0
- 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
|