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,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