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,613 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'base64'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
module Puppeteer
|
|
8
|
+
module Bidi
|
|
9
|
+
# Page represents a single page/tab in the browser
|
|
10
|
+
# This is a high-level wrapper around Core::BrowsingContext
|
|
11
|
+
class Page
|
|
12
|
+
DEFAULT_TIMEOUT = 30_000 #: Integer
|
|
13
|
+
|
|
14
|
+
attr_reader :browsing_context #: Core::BrowsingContext
|
|
15
|
+
attr_reader :browser_context #: BrowserContext
|
|
16
|
+
attr_reader :timeout_settings #: TimeoutSettings
|
|
17
|
+
|
|
18
|
+
# @rbs browser_context: BrowserContext
|
|
19
|
+
# @rbs browsing_context: Core::BrowsingContext
|
|
20
|
+
# @rbs return: void
|
|
21
|
+
def initialize(browser_context, browsing_context)
|
|
22
|
+
@browser_context = browser_context
|
|
23
|
+
@browsing_context = browsing_context
|
|
24
|
+
@timeout_settings = TimeoutSettings.new(DEFAULT_TIMEOUT)
|
|
25
|
+
@emitter = Core::EventEmitter.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Event emitter delegation methods
|
|
29
|
+
# Following Puppeteer's trustedEmitter pattern
|
|
30
|
+
|
|
31
|
+
# Register an event listener
|
|
32
|
+
# @rbs event: Symbol | String
|
|
33
|
+
# @rbs &block: (untyped) -> void
|
|
34
|
+
# @rbs return: void
|
|
35
|
+
def on(event, &block)
|
|
36
|
+
@emitter.on(event, &block)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Register a one-time event listener
|
|
40
|
+
# @rbs event: Symbol | String
|
|
41
|
+
# @rbs &block: (untyped) -> void
|
|
42
|
+
# @rbs return: void
|
|
43
|
+
def once(event, &block)
|
|
44
|
+
@emitter.once(event, &block)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Remove an event listener
|
|
48
|
+
# @rbs event: Symbol | String
|
|
49
|
+
# @rbs &block: (untyped) -> void
|
|
50
|
+
# @rbs return: void
|
|
51
|
+
def off(event, &block)
|
|
52
|
+
@emitter.off(event, &block)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Emit an event to all registered listeners
|
|
56
|
+
# @rbs event: Symbol | String
|
|
57
|
+
# @rbs data: untyped
|
|
58
|
+
# @rbs return: void
|
|
59
|
+
def emit(event, data = nil)
|
|
60
|
+
@emitter.emit(event, data)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Navigate to a URL
|
|
64
|
+
# @rbs url: String
|
|
65
|
+
# @rbs wait_until: String
|
|
66
|
+
# @rbs return: HTTPResponse?
|
|
67
|
+
def goto(url, wait_until: 'load')
|
|
68
|
+
assert_not_closed
|
|
69
|
+
|
|
70
|
+
main_frame.goto(url, wait_until: wait_until)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Set page content
|
|
74
|
+
# @rbs html: String
|
|
75
|
+
# @rbs wait_until: String
|
|
76
|
+
# @rbs return: void
|
|
77
|
+
def set_content(html, wait_until: 'load')
|
|
78
|
+
main_frame.set_content(html, wait_until: wait_until)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Take a screenshot
|
|
82
|
+
# @rbs path: String?
|
|
83
|
+
# @rbs type: String
|
|
84
|
+
# @rbs full_page: bool
|
|
85
|
+
# @rbs clip: Hash[Symbol, Numeric]?
|
|
86
|
+
# @rbs capture_beyond_viewport: bool
|
|
87
|
+
# @rbs return: String
|
|
88
|
+
def screenshot(path: nil, type: 'png', full_page: false, clip: nil, capture_beyond_viewport: true)
|
|
89
|
+
assert_not_closed
|
|
90
|
+
|
|
91
|
+
options = {
|
|
92
|
+
format: {
|
|
93
|
+
type: type == 'jpeg' ? 'image/jpeg' : 'image/png'
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Handle fullPage screenshot
|
|
98
|
+
if full_page
|
|
99
|
+
# If captureBeyondViewport is false, then we set the viewport to
|
|
100
|
+
# capture the full page. Note this may be affected by on-page CSS and JavaScript.
|
|
101
|
+
unless capture_beyond_viewport
|
|
102
|
+
# Get scroll dimensions
|
|
103
|
+
scroll_dimensions = evaluate(<<~JS)
|
|
104
|
+
(() => {
|
|
105
|
+
const element = document.documentElement;
|
|
106
|
+
return {
|
|
107
|
+
width: element.scrollWidth,
|
|
108
|
+
height: element.scrollHeight
|
|
109
|
+
};
|
|
110
|
+
})()
|
|
111
|
+
JS
|
|
112
|
+
|
|
113
|
+
# Save original viewport (could be nil)
|
|
114
|
+
original_viewport = viewport
|
|
115
|
+
|
|
116
|
+
# If no viewport is set, save current window size
|
|
117
|
+
unless original_viewport
|
|
118
|
+
original_size = evaluate('({ width: window.innerWidth, height: window.innerHeight })')
|
|
119
|
+
original_viewport = { width: original_size['width'].to_i, height: original_size['height'].to_i }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Set viewport to full page size
|
|
123
|
+
set_viewport(
|
|
124
|
+
width: scroll_dimensions['width'].to_i,
|
|
125
|
+
height: scroll_dimensions['height'].to_i
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
begin
|
|
129
|
+
# Capture screenshot with viewport origin
|
|
130
|
+
options[:origin] = 'viewport'
|
|
131
|
+
data = @browsing_context.capture_screenshot(**options).wait
|
|
132
|
+
ensure
|
|
133
|
+
# Restore original viewport
|
|
134
|
+
if original_viewport
|
|
135
|
+
set_viewport(
|
|
136
|
+
width: original_viewport[:width],
|
|
137
|
+
height: original_viewport[:height]
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Save to file if path is provided
|
|
143
|
+
if path
|
|
144
|
+
dir = File.dirname(path)
|
|
145
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
146
|
+
File.binwrite(path, Base64.decode64(data))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
return data
|
|
150
|
+
else
|
|
151
|
+
# Capture full document with origin: document
|
|
152
|
+
options[:origin] = 'document'
|
|
153
|
+
end
|
|
154
|
+
elsif !clip
|
|
155
|
+
# If not fullPage and no clip, force captureBeyondViewport to false
|
|
156
|
+
capture_beyond_viewport = false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Add clip region if specified
|
|
160
|
+
if clip
|
|
161
|
+
# Set origin based on captureBeyondViewport (only when clip is specified)
|
|
162
|
+
if capture_beyond_viewport
|
|
163
|
+
options[:origin] = 'document'
|
|
164
|
+
else
|
|
165
|
+
options[:origin] = 'viewport'
|
|
166
|
+
end
|
|
167
|
+
box = clip.dup
|
|
168
|
+
|
|
169
|
+
# When captureBeyondViewport is false, convert document coordinates to viewport coordinates
|
|
170
|
+
unless capture_beyond_viewport
|
|
171
|
+
# Get viewport offset
|
|
172
|
+
page_left = evaluate('window.visualViewport.pageLeft')
|
|
173
|
+
page_top = evaluate('window.visualViewport.pageTop')
|
|
174
|
+
|
|
175
|
+
# Convert to viewport coordinates
|
|
176
|
+
box = {
|
|
177
|
+
x: clip[:x] - page_left,
|
|
178
|
+
y: clip[:y] - page_top,
|
|
179
|
+
width: clip[:width],
|
|
180
|
+
height: clip[:height]
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
options[:clip] = {
|
|
185
|
+
type: 'box',
|
|
186
|
+
x: box[:x],
|
|
187
|
+
y: box[:y],
|
|
188
|
+
width: box[:width],
|
|
189
|
+
height: box[:height]
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Get screenshot data from browsing context
|
|
194
|
+
data = @browsing_context.capture_screenshot(**options).wait
|
|
195
|
+
|
|
196
|
+
# Save to file if path is provided
|
|
197
|
+
if path
|
|
198
|
+
# Ensure directory exists
|
|
199
|
+
dir = File.dirname(path)
|
|
200
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
201
|
+
|
|
202
|
+
# data is base64 encoded, decode and write
|
|
203
|
+
File.binwrite(path, Base64.decode64(data))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
data
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Evaluate JavaScript in the page context
|
|
210
|
+
# @rbs script: String
|
|
211
|
+
# @rbs *args: untyped
|
|
212
|
+
# @rbs return: untyped
|
|
213
|
+
def evaluate(script, *args)
|
|
214
|
+
main_frame.evaluate(script, *args)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Evaluate JavaScript and return a handle to the result
|
|
218
|
+
# @rbs script: String
|
|
219
|
+
# @rbs *args: untyped
|
|
220
|
+
# @rbs return: JSHandle
|
|
221
|
+
def evaluate_handle(script, *args)
|
|
222
|
+
main_frame.evaluate_handle(script, *args)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Query for an element matching the selector
|
|
226
|
+
# @rbs selector: String
|
|
227
|
+
# @rbs return: ElementHandle?
|
|
228
|
+
def query_selector(selector)
|
|
229
|
+
main_frame.query_selector(selector)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Query for all elements matching the selector
|
|
233
|
+
# @rbs selector: String
|
|
234
|
+
# @rbs return: Array[ElementHandle]
|
|
235
|
+
def query_selector_all(selector)
|
|
236
|
+
main_frame.query_selector_all(selector)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Evaluate a function on the first element matching the selector
|
|
240
|
+
# @rbs selector: String
|
|
241
|
+
# @rbs page_function: String
|
|
242
|
+
# @rbs *args: untyped
|
|
243
|
+
# @rbs return: untyped
|
|
244
|
+
def eval_on_selector(selector, page_function, *args)
|
|
245
|
+
main_frame.eval_on_selector(selector, page_function, *args)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Evaluate a function on all elements matching the selector
|
|
249
|
+
# @rbs selector: String
|
|
250
|
+
# @rbs page_function: String
|
|
251
|
+
# @rbs *args: untyped
|
|
252
|
+
# @rbs return: untyped
|
|
253
|
+
def eval_on_selector_all(selector, page_function, *args)
|
|
254
|
+
main_frame.eval_on_selector_all(selector, page_function, *args)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Click an element matching the selector
|
|
258
|
+
# @rbs selector: String
|
|
259
|
+
# @rbs button: String
|
|
260
|
+
# @rbs count: Integer
|
|
261
|
+
# @rbs delay: Numeric?
|
|
262
|
+
# @rbs offset: Hash[Symbol, Numeric]?
|
|
263
|
+
# @rbs return: void
|
|
264
|
+
def click(selector, button: Mouse::LEFT, count: 1, delay: nil, offset: nil)
|
|
265
|
+
main_frame.click(selector, button: button, count: count, delay: delay, offset: offset)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Type text into an element matching the selector
|
|
269
|
+
# @rbs selector: String
|
|
270
|
+
# @rbs text: String
|
|
271
|
+
# @rbs delay: Numeric
|
|
272
|
+
# @rbs return: void
|
|
273
|
+
def type(selector, text, delay: 0)
|
|
274
|
+
main_frame.type(selector, text, delay: delay)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Hover over an element matching the selector
|
|
278
|
+
# @rbs selector: String
|
|
279
|
+
# @rbs return: void
|
|
280
|
+
def hover(selector)
|
|
281
|
+
main_frame.hover(selector)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Focus an element matching the selector
|
|
285
|
+
# @rbs selector: String
|
|
286
|
+
# @rbs return: void
|
|
287
|
+
def focus(selector)
|
|
288
|
+
handle = main_frame.query_selector(selector)
|
|
289
|
+
raise SelectorNotFoundError, selector unless handle
|
|
290
|
+
|
|
291
|
+
begin
|
|
292
|
+
handle.focus
|
|
293
|
+
ensure
|
|
294
|
+
handle.dispose
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Get the page title
|
|
299
|
+
# @rbs return: String
|
|
300
|
+
def title
|
|
301
|
+
evaluate('document.title')
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Get the page URL
|
|
305
|
+
# @rbs return: String
|
|
306
|
+
def url
|
|
307
|
+
@browsing_context.url
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Close the page
|
|
311
|
+
# @rbs return: void
|
|
312
|
+
def close
|
|
313
|
+
return if closed?
|
|
314
|
+
|
|
315
|
+
@browsing_context.close.wait
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Check if page is closed
|
|
319
|
+
# @rbs return: bool
|
|
320
|
+
def closed?
|
|
321
|
+
@browsing_context.closed?
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Get the main frame
|
|
325
|
+
# @rbs return: Frame
|
|
326
|
+
def main_frame
|
|
327
|
+
@main_frame ||= Frame.from(self, @browsing_context)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Get the focused frame
|
|
331
|
+
# @rbs return: Frame
|
|
332
|
+
def focused_frame
|
|
333
|
+
assert_not_closed
|
|
334
|
+
|
|
335
|
+
# Evaluate in main frame to find the focused window
|
|
336
|
+
handle = main_frame.evaluate_handle(<<~JS)
|
|
337
|
+
() => {
|
|
338
|
+
let win = window;
|
|
339
|
+
while (
|
|
340
|
+
win.document.activeElement instanceof win.HTMLIFrameElement ||
|
|
341
|
+
win.document.activeElement instanceof win.HTMLFrameElement
|
|
342
|
+
) {
|
|
343
|
+
if (win.document.activeElement.contentWindow === null) {
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
win = win.document.activeElement.contentWindow;
|
|
347
|
+
}
|
|
348
|
+
return win;
|
|
349
|
+
}
|
|
350
|
+
JS
|
|
351
|
+
|
|
352
|
+
# Get the remote value (should be a window object)
|
|
353
|
+
remote_value = handle.remote_value
|
|
354
|
+
handle.dispose
|
|
355
|
+
|
|
356
|
+
unless remote_value['type'] == 'window'
|
|
357
|
+
raise "Expected window type, got #{remote_value['type']}"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Find the frame with matching context ID
|
|
361
|
+
context_id = remote_value['value']['context']
|
|
362
|
+
frame = frames.find { |f| f.browsing_context.id == context_id }
|
|
363
|
+
|
|
364
|
+
raise "Could not find frame with context #{context_id}" unless frame
|
|
365
|
+
|
|
366
|
+
frame
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Get all frames (main frame + all nested child frames)
|
|
370
|
+
# Following Puppeteer's pattern of returning all frames recursively
|
|
371
|
+
# @rbs return: Array[Frame]
|
|
372
|
+
def frames
|
|
373
|
+
collect_frames(main_frame)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Get the mouse instance
|
|
377
|
+
# @rbs return: Mouse
|
|
378
|
+
def mouse
|
|
379
|
+
@mouse ||= Mouse.new(@browsing_context)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Get the keyboard instance
|
|
383
|
+
# @rbs return: Keyboard
|
|
384
|
+
def keyboard
|
|
385
|
+
@keyboard ||= Keyboard.new(self, @browsing_context)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Wait for a function to return a truthy value
|
|
389
|
+
# @rbs page_function: String
|
|
390
|
+
# @rbs options: Hash[Symbol, untyped]
|
|
391
|
+
# @rbs *args: untyped
|
|
392
|
+
# @rbs &block: ((JSHandle) -> void)?
|
|
393
|
+
# @rbs return: JSHandle
|
|
394
|
+
def wait_for_function(page_function, options = {}, *args, &block)
|
|
395
|
+
main_frame.wait_for_function(page_function, options, *args, &block)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Wait for an element matching the selector to appear in the page
|
|
399
|
+
# @rbs selector: String
|
|
400
|
+
# @rbs visible: bool?
|
|
401
|
+
# @rbs hidden: bool?
|
|
402
|
+
# @rbs timeout: Numeric?
|
|
403
|
+
# @rbs &block: ((ElementHandle?) -> void)?
|
|
404
|
+
# @rbs return: ElementHandle?
|
|
405
|
+
def wait_for_selector(selector, visible: nil, hidden: nil, timeout: nil, &block)
|
|
406
|
+
main_frame.wait_for_selector(selector, visible: visible, hidden: hidden, timeout: timeout, &block)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Set the default timeout for waiting operations (e.g., waitForFunction).
|
|
410
|
+
# @rbs timeout: Numeric
|
|
411
|
+
# @rbs return: void
|
|
412
|
+
def set_default_timeout(timeout)
|
|
413
|
+
raise ArgumentError, 'timeout must be a non-negative number' unless timeout.is_a?(Numeric) && timeout >= 0
|
|
414
|
+
|
|
415
|
+
@timeout_settings.set_default_timeout(timeout)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Get the current default timeout in milliseconds.
|
|
419
|
+
# @rbs return: Numeric
|
|
420
|
+
def default_timeout
|
|
421
|
+
@timeout_settings.timeout
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Wait for navigation to complete
|
|
425
|
+
# @rbs timeout: Numeric
|
|
426
|
+
# @rbs wait_until: String
|
|
427
|
+
# @rbs &block: (-> void)?
|
|
428
|
+
# @rbs return: HTTPResponse?
|
|
429
|
+
def wait_for_navigation(timeout: 30000, wait_until: 'load', &block)
|
|
430
|
+
main_frame.wait_for_navigation(timeout: timeout, wait_until: wait_until, &block)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Wait for a file chooser to be opened
|
|
434
|
+
# @rbs timeout: Numeric?
|
|
435
|
+
# @rbs &block: (-> void)?
|
|
436
|
+
# @rbs return: FileChooser
|
|
437
|
+
def wait_for_file_chooser(timeout: nil, &block)
|
|
438
|
+
assert_not_closed
|
|
439
|
+
|
|
440
|
+
# Use provided timeout, or default timeout, treating 0 as infinite
|
|
441
|
+
effective_timeout = timeout || @timeout_settings.timeout
|
|
442
|
+
|
|
443
|
+
promise = Async::Promise.new
|
|
444
|
+
|
|
445
|
+
# Listener for file dialog opened event
|
|
446
|
+
file_dialog_listener = lambda do |info|
|
|
447
|
+
# info contains: element, multiple
|
|
448
|
+
element_info = info['element']
|
|
449
|
+
return unless element_info
|
|
450
|
+
|
|
451
|
+
# Create ElementHandle from the element info
|
|
452
|
+
# The element info should have sharedId and/or handle
|
|
453
|
+
element_remote_value = {
|
|
454
|
+
'type' => 'node',
|
|
455
|
+
'sharedId' => element_info['sharedId'],
|
|
456
|
+
'handle' => element_info['handle']
|
|
457
|
+
}.compact
|
|
458
|
+
|
|
459
|
+
element = ElementHandle.from(element_remote_value, @browsing_context.default_realm)
|
|
460
|
+
multiple = info['multiple'] || false
|
|
461
|
+
|
|
462
|
+
file_chooser = FileChooser.new(element, multiple)
|
|
463
|
+
promise.resolve(file_chooser)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
begin
|
|
467
|
+
# Register listener before executing the block
|
|
468
|
+
@browsing_context.on(:filedialogopened, &file_dialog_listener)
|
|
469
|
+
|
|
470
|
+
# Execute the block that triggers the file chooser
|
|
471
|
+
Async(&block).wait if block
|
|
472
|
+
|
|
473
|
+
# Wait for file chooser with timeout
|
|
474
|
+
if timeout == 0
|
|
475
|
+
promise.wait
|
|
476
|
+
else
|
|
477
|
+
AsyncUtils.async_timeout(effective_timeout, promise).wait
|
|
478
|
+
end
|
|
479
|
+
rescue Async::TimeoutError
|
|
480
|
+
raise TimeoutError, "Waiting for file chooser timed out after #{effective_timeout}ms"
|
|
481
|
+
ensure
|
|
482
|
+
@browsing_context.off(:filedialogopened, &file_dialog_listener)
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Wait for network to be idle (no more than concurrency connections for idle_time)
|
|
487
|
+
# Based on Puppeteer's waitForNetworkIdle implementation
|
|
488
|
+
# @rbs idle_time: Numeric
|
|
489
|
+
# @rbs timeout: Numeric
|
|
490
|
+
# @rbs concurrency: Integer
|
|
491
|
+
# @rbs return: void
|
|
492
|
+
def wait_for_network_idle(idle_time: 500, timeout: 30000, concurrency: 0)
|
|
493
|
+
assert_not_closed
|
|
494
|
+
|
|
495
|
+
promise = Async::Promise.new
|
|
496
|
+
idle_timer = nil
|
|
497
|
+
idle_timer_mutex = Thread::Mutex.new
|
|
498
|
+
|
|
499
|
+
# Listener for inflight changes
|
|
500
|
+
inflight_listener = lambda do |data|
|
|
501
|
+
inflight = data[:inflight]
|
|
502
|
+
|
|
503
|
+
idle_timer_mutex.synchronize do
|
|
504
|
+
# Cancel existing timer if any
|
|
505
|
+
idle_timer&.stop
|
|
506
|
+
|
|
507
|
+
# If inflight requests exceed concurrency, don't start timer
|
|
508
|
+
if inflight > concurrency
|
|
509
|
+
idle_timer = nil
|
|
510
|
+
return
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Start idle timer
|
|
514
|
+
idle_timer = Async do |task|
|
|
515
|
+
task.sleep(idle_time / 1000.0)
|
|
516
|
+
promise.resolve(nil)
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Close listener
|
|
522
|
+
close_listener = lambda do |_data|
|
|
523
|
+
promise.reject(PageClosedError.new)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
begin
|
|
527
|
+
# Register listeners
|
|
528
|
+
@browsing_context.on(:inflight_changed, &inflight_listener)
|
|
529
|
+
@browsing_context.on(:closed, &close_listener)
|
|
530
|
+
|
|
531
|
+
# Check initial state - if already idle, start timer immediately
|
|
532
|
+
current_inflight = @browsing_context.inflight_requests
|
|
533
|
+
if current_inflight <= concurrency
|
|
534
|
+
idle_timer_mutex.synchronize do
|
|
535
|
+
idle_timer = Async do |task|
|
|
536
|
+
task.sleep(idle_time / 1000.0)
|
|
537
|
+
promise.resolve(nil)
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Wait with timeout
|
|
543
|
+
AsyncUtils.async_timeout(timeout, promise).wait
|
|
544
|
+
ensure
|
|
545
|
+
# Clean up
|
|
546
|
+
idle_timer_mutex.synchronize do
|
|
547
|
+
idle_timer&.stop
|
|
548
|
+
end
|
|
549
|
+
@browsing_context.off(:inflight_changed, &inflight_listener)
|
|
550
|
+
@browsing_context.off(:closed, &close_listener)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
nil
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# Set viewport size
|
|
557
|
+
# @rbs width: Integer
|
|
558
|
+
# @rbs height: Integer
|
|
559
|
+
# @rbs return: void
|
|
560
|
+
def set_viewport(width:, height:)
|
|
561
|
+
@viewport = { width: width, height: height }
|
|
562
|
+
@browsing_context.set_viewport(
|
|
563
|
+
viewport: {
|
|
564
|
+
width: width,
|
|
565
|
+
height: height
|
|
566
|
+
}
|
|
567
|
+
).wait
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Get current viewport size
|
|
571
|
+
# @rbs return: Hash[Symbol, Integer]?
|
|
572
|
+
def viewport
|
|
573
|
+
@viewport
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
alias viewport= set_viewport
|
|
577
|
+
alias default_timeout= set_default_timeout
|
|
578
|
+
|
|
579
|
+
# Set JavaScript enabled state
|
|
580
|
+
# @rbs enabled: bool
|
|
581
|
+
# @rbs return: void
|
|
582
|
+
def set_javascript_enabled(enabled)
|
|
583
|
+
assert_not_closed
|
|
584
|
+
@browsing_context.set_javascript_enabled(enabled).wait
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Check if JavaScript is enabled
|
|
588
|
+
# @rbs return: bool
|
|
589
|
+
def javascript_enabled?
|
|
590
|
+
@browsing_context.javascript_enabled?
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
private
|
|
594
|
+
|
|
595
|
+
# Recursively collect all frames starting from the given frame
|
|
596
|
+
# @rbs frame: Frame
|
|
597
|
+
# @rbs return: Array[Frame]
|
|
598
|
+
def collect_frames(frame)
|
|
599
|
+
result = [frame]
|
|
600
|
+
frame.child_frames.each do |child|
|
|
601
|
+
result.concat(collect_frames(child))
|
|
602
|
+
end
|
|
603
|
+
result
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Check if this page is closed and raise error if so
|
|
607
|
+
# @rbs return: void
|
|
608
|
+
def assert_not_closed
|
|
609
|
+
raise PageClosedError if closed?
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
end
|