puppeteer-ruby 0.52.0 → 0.53.0

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/docs/api_coverage.md +16 -15
  3. data/lib/puppeteer/accessibility.rb +300 -0
  4. data/lib/puppeteer/browser.rb +107 -8
  5. data/lib/puppeteer/browser_connector.rb +42 -6
  6. data/lib/puppeteer/browser_context.rb +0 -1
  7. data/lib/puppeteer/cdp_session.rb +29 -8
  8. data/lib/puppeteer/chrome_target_manager.rb +145 -34
  9. data/lib/puppeteer/chrome_user_data_dir.rb +89 -0
  10. data/lib/puppeteer/connection.rb +19 -0
  11. data/lib/puppeteer/coverage.rb +5 -0
  12. data/lib/puppeteer/css_coverage.rb +4 -0
  13. data/lib/puppeteer/dialog.rb +5 -0
  14. data/lib/puppeteer/element_handle.rb +2 -3
  15. data/lib/puppeteer/emulation_manager.rb +70 -9
  16. data/lib/puppeteer/events.rb +2 -0
  17. data/lib/puppeteer/execution_context.rb +7 -0
  18. data/lib/puppeteer/extension.rb +20 -6
  19. data/lib/puppeteer/frame.rb +7 -3
  20. data/lib/puppeteer/frame_manager.rb +71 -26
  21. data/lib/puppeteer/http_response.rb +15 -1
  22. data/lib/puppeteer/isolated_world.rb +10 -4
  23. data/lib/puppeteer/js_coverage.rb +4 -0
  24. data/lib/puppeteer/keyboard.rb +6 -0
  25. data/lib/puppeteer/launcher/browser_options.rb +13 -1
  26. data/lib/puppeteer/launcher/chrome.rb +48 -4
  27. data/lib/puppeteer/lifecycle_watcher.rb +1 -6
  28. data/lib/puppeteer/locators.rb +54 -26
  29. data/lib/puppeteer/mouse.rb +12 -24
  30. data/lib/puppeteer/network_manager.rb +16 -4
  31. data/lib/puppeteer/page.rb +259 -33
  32. data/lib/puppeteer/puppeteer.rb +13 -7
  33. data/lib/puppeteer/screen_recorder.rb +269 -0
  34. data/lib/puppeteer/target.rb +7 -3
  35. data/lib/puppeteer/touch_screen.rb +6 -0
  36. data/lib/puppeteer/tracing.rb +4 -0
  37. data/lib/puppeteer/version.rb +2 -2
  38. data/lib/puppeteer/wait_task.rb +1 -2
  39. data/lib/puppeteer/web_mcp.rb +227 -0
  40. data/lib/puppeteer/web_worker.rb +135 -9
  41. data/lib/puppeteer.rb +11 -1
  42. data/puppeteer-ruby.gemspec +1 -0
  43. data/sig/_supplementary.rbs +33 -1
  44. data/sig/puppeteer/accessibility.rbs +73 -0
  45. data/sig/puppeteer/browser.rbs +27 -3
  46. data/sig/puppeteer/cdp_session.rbs +4 -0
  47. data/sig/puppeteer/chrome_user_data_dir.rbs +30 -0
  48. data/sig/puppeteer/dialog.rbs +3 -0
  49. data/sig/puppeteer/element_handle.rbs +1 -2
  50. data/sig/puppeteer/execution_context.rbs +5 -0
  51. data/sig/puppeteer/extension.rbs +4 -0
  52. data/sig/puppeteer/frame.rbs +4 -2
  53. data/sig/puppeteer/http_response.rbs +4 -0
  54. data/sig/puppeteer/keyboard.rbs +4 -0
  55. data/sig/puppeteer/locators.rbs +3 -4
  56. data/sig/puppeteer/mouse.rbs +5 -6
  57. data/sig/puppeteer/page.rbs +48 -4
  58. data/sig/puppeteer/puppeteer.rbs +5 -5
  59. data/sig/puppeteer/screen_recorder.rbs +48 -0
  60. data/sig/puppeteer/touch_screen.rbs +4 -0
  61. data/sig/puppeteer/web_mcp.rbs +112 -0
  62. data/sig/puppeteer/web_worker.rbs +39 -0
  63. metadata +24 -2
@@ -197,22 +197,21 @@ class Puppeteer::Locator
197
197
 
198
198
  # @rbs delay: Numeric? -- Delay between down and up (ms)
199
199
  # @rbs button: String? -- Mouse button
200
- # @rbs click_count: Integer? -- Deprecated click count
201
200
  # @rbs count: Integer? -- Number of clicks
202
201
  # @rbs offset: Hash[Symbol, Numeric]? -- Click offset
203
202
  # @rbs return: void -- No return value
204
- def click(delay: nil, button: nil, click_count: nil, count: nil, offset: nil)
203
+ def click(delay: nil, button: nil, count: nil, offset: nil)
205
204
  perform_action('Locator.click',
206
205
  conditions: [
207
206
  method(:ensure_element_is_in_viewport_if_needed),
208
207
  method(:wait_for_stable_bounding_box_if_needed),
209
208
  method(:wait_for_enabled_if_needed),
210
209
  ]) do |handle, _options|
211
- handle.click(delay: delay, button: button, click_count: click_count, count: count, offset: offset)
210
+ handle.click(delay: delay, button: button, count: count, offset: offset)
212
211
  end
213
212
  end
214
213
 
215
- # @rbs value: String -- Value to fill
214
+ # @rbs value: String | bool -- Value to fill
216
215
  # @rbs typing_threshold: Integer -- Minimum length to switch to direct assignment
217
216
  # @rbs return: void -- No return value
218
217
  def fill(value, typing_threshold: 100)
@@ -433,21 +432,28 @@ class Puppeteer::Locator
433
432
  return 'typeable-input';
434
433
  }
435
434
  if (el instanceof HTMLInputElement) {
436
- if (
437
- new Set([
438
- 'textarea',
439
- 'text',
440
- 'url',
441
- 'tel',
442
- 'search',
443
- 'password',
444
- 'number',
445
- 'email',
446
- ]).has(el.type)
447
- ) {
448
- return 'typeable-input';
435
+ switch (el.type) {
436
+ case 'checkbox':
437
+ case 'radio':
438
+ return 'checkable-input';
439
+ case 'text':
440
+ case 'url':
441
+ case 'tel':
442
+ case 'search':
443
+ case 'password':
444
+ case 'number':
445
+ case 'email':
446
+ return 'typeable-input';
447
+ default:
448
+ return 'other-input';
449
449
  }
450
- return 'other-input';
450
+ }
451
+
452
+ switch (el.getAttribute('role')) {
453
+ case 'checkbox':
454
+ case 'radio':
455
+ case 'switch':
456
+ return 'checkable-input';
451
457
  }
452
458
 
453
459
  if (el.isContentEditable) {
@@ -459,26 +465,47 @@ class Puppeteer::Locator
459
465
  JAVASCRIPT
460
466
 
461
467
  case input_type
468
+ when 'checkable-input'
469
+ current_state = handle.evaluate(<<~JAVASCRIPT)
470
+ toggleEl => {
471
+ if (
472
+ toggleEl.indeterminate ||
473
+ toggleEl.getAttribute('aria-checked') === 'mixed'
474
+ ) {
475
+ return 'mixed';
476
+ }
477
+ return (
478
+ toggleEl.checked ||
479
+ toggleEl.getAttribute('aria-checked') === 'true'
480
+ );
481
+ }
482
+ JAVASCRIPT
483
+ handle.click if current_state == 'mixed' || current_state != !!value
462
484
  when 'select'
463
485
  handle.select(value)
464
486
  when 'contenteditable', 'typeable-input'
465
- if value.length < typing_threshold
487
+ if value.is_a?(String) && value.length < typing_threshold
466
488
  text_to_type = handle.evaluate(<<~JAVASCRIPT, value)
467
489
  (input, newValue) => {
490
+ const valString = String(newValue);
468
491
  const currentValue = input.isContentEditable
469
492
  ? input.innerText
470
493
  : input.value;
471
494
 
495
+ if (currentValue === valString) {
496
+ return '';
497
+ }
498
+
472
499
  if (
473
- newValue.length <= currentValue.length ||
474
- !newValue.startsWith(input.value)
500
+ !valString.startsWith(currentValue) ||
501
+ !currentValue
475
502
  ) {
476
503
  if (input.isContentEditable) {
477
504
  input.innerText = '';
478
505
  } else {
479
506
  input.value = '';
480
507
  }
481
- return newValue;
508
+ return valString;
482
509
  }
483
510
  const originalValue = input.isContentEditable
484
511
  ? input.innerText
@@ -491,7 +518,7 @@ class Puppeteer::Locator
491
518
  input.value = '';
492
519
  input.value = originalValue;
493
520
  }
494
- return newValue.substring(originalValue.length);
521
+ return valString.substring(originalValue.length);
495
522
  }
496
523
  JAVASCRIPT
497
524
  text_to_type = text_to_type.to_s
@@ -510,16 +537,17 @@ class Puppeteer::Locator
510
537
  handle.focus
511
538
  handle.evaluate(<<~JAVASCRIPT, value)
512
539
  (input, newValue) => {
540
+ const valString = String(newValue);
513
541
  const currentValue = input.isContentEditable
514
542
  ? input.innerText
515
543
  : input.value;
516
- if (currentValue === newValue) {
544
+ if (currentValue === valString) {
517
545
  return;
518
546
  }
519
547
  if (input.isContentEditable) {
520
- input.innerText = newValue;
548
+ input.innerText = valString;
521
549
  } else {
522
- input.value = newValue;
550
+ input.value = valString;
523
551
  }
524
552
  input.dispatchEvent(new Event('input', { bubbles: true }));
525
553
  input.dispatchEvent(new Event('change', { bubbles: true }));
@@ -40,6 +40,12 @@ class Puppeteer::Mouse
40
40
  @dispatch_mutex = Mutex.new
41
41
  end
42
42
 
43
+ # @rbs client: Puppeteer::CDPSession -- Replacement CDP session
44
+ # @rbs return: void -- No return value
45
+ def update_client(client)
46
+ @client = client
47
+ end
48
+
43
49
  # @rbs return: void -- No return value
44
50
  def reset
45
51
  [
@@ -102,48 +108,30 @@ class Puppeteer::Mouse
102
108
  # @rbs y: Numeric -- Y coordinate
103
109
  # @rbs delay: Numeric? -- Delay between down and up (ms)
104
110
  # @rbs button: String? -- Mouse button
105
- # @rbs click_count: Integer? -- Deprecated: use count (click_count only sets clickCount)
106
111
  # @rbs count: Integer? -- Number of click repetitions
107
112
  # @rbs return: void -- No return value
108
- def click(x, y, delay: nil, button: nil, click_count: nil, count: nil)
109
- warn_deprecated_click_count if !click_count.nil?
113
+ def click(x, y, delay: nil, button: nil, count: nil)
110
114
  count ||= 1
111
- click_count ||= count
112
115
  if count < 1
113
116
  raise Puppeteer::Error.new('Click must occur a positive number of times.')
114
117
  end
115
118
  # Serialize click sequences to keep event ordering stable under thread-based concurrency.
116
119
  @dispatch_mutex.synchronize do
117
120
  move(x, y)
118
- if click_count == count
119
- 1.upto(count - 1) do |i|
120
- down(button: button, click_count: i)
121
- up(button: button, click_count: i)
122
- end
121
+ 1.upto(count - 1) do |i|
122
+ down(button: button, click_count: i)
123
+ up(button: button, click_count: i)
123
124
  end
124
- down(button: button, click_count: click_count)
125
+ down(button: button, click_count: count)
125
126
  if !delay.nil?
126
127
  Puppeteer::AsyncUtils.sleep_seconds(delay / 1000.0)
127
128
  end
128
- up(button: button, click_count: click_count)
129
+ up(button: button, click_count: count)
129
130
  end
130
131
  end
131
132
 
132
133
  define_async_method :async_click
133
134
 
134
- private def warn_deprecated_click_count
135
- return if self.class.deprecated_click_count_warned
136
-
137
- self.class.deprecated_click_count_warned = true
138
- warn('DEPRECATED: `click_count` is deprecated; use `count` instead.')
139
- end
140
-
141
- class << self
142
- attr_accessor :deprecated_click_count_warned
143
- end
144
-
145
- self.deprecated_click_count_warned = false
146
-
147
135
  # @rbs button: String? -- Mouse button
148
136
  # @rbs click_count: Integer? -- Click count to report
149
137
  # @rbs return: void -- No return value
@@ -99,6 +99,7 @@ class Puppeteer::NetworkManager
99
99
  @extra_http_headers = {}
100
100
  @user_agent = nil
101
101
  @user_agent_metadata = nil
102
+ @accept_language = nil
102
103
 
103
104
  @attempted_authentications = Set.new
104
105
  @user_request_interception_enabled = false
@@ -146,12 +147,12 @@ class Puppeteer::NetworkManager
146
147
  end
147
148
 
148
149
  private def ignore_client_error?(error)
150
+ return true if error.is_a?(Puppeteer::TargetCloseError)
151
+
149
152
  message = error&.message
150
153
  return false unless message
151
154
 
152
155
  lowered = message.downcase
153
- return true if lowered.include?('target closed')
154
- return true if lowered.include?('session closed')
155
156
  return true if lowered.include?('not supported')
156
157
  return true if lowered.include?("wasn't found")
157
158
 
@@ -175,10 +176,12 @@ class Puppeteer::NetworkManager
175
176
  end
176
177
 
177
178
  private def apply_user_agent(client)
178
- return unless @user_agent
179
+ user_agent = @user_agent || @frame_manager.page.browser.user_agent
180
+ return unless user_agent
179
181
 
180
182
  safe_send_message(client, 'Network.setUserAgentOverride', {
181
- userAgent: @user_agent,
183
+ userAgent: user_agent,
184
+ acceptLanguage: @accept_language,
182
185
  userAgentMetadata: @user_agent_metadata,
183
186
  }.compact)
184
187
  end
@@ -212,6 +215,8 @@ class Puppeteer::NetworkManager
212
215
  if @internal_network_condition.active?
213
216
  safe_send_message(client, 'Network.emulateNetworkConditions', @internal_network_condition.params)
214
217
  end
218
+ rescue => err
219
+ raise unless ignore_client_error?(err)
215
220
  end
216
221
 
217
222
  # @param username [String|NilClass]
@@ -281,6 +286,13 @@ class Puppeteer::NetworkManager
281
286
  end
282
287
  alias_method :user_agent=, :set_user_agent
283
288
 
289
+ # @rbs accept_language: String? -- Accept-Language override
290
+ # @rbs return: void -- No return value
291
+ def set_accept_language(accept_language)
292
+ @accept_language = accept_language
293
+ apply_to_clients { |client| apply_user_agent(client) }
294
+ end
295
+
284
296
  def cache_enabled=(enabled)
285
297
  @user_cache_disabled = !enabled
286
298
  update_protocol_cache_disabled
@@ -40,32 +40,46 @@ class Puppeteer::Page
40
40
  @closed = false
41
41
  @client = client
42
42
  @target = target
43
- @tab_id = nil
43
+ @tab_session = client.parent_session || client
44
+ @tab_target = @tab_session.target || target
45
+ @tab_id = @tab_target.target_id
44
46
  @keyboard = Puppeteer::Keyboard.new(client)
45
47
  @mouse = Puppeteer::Mouse.new(client, @keyboard)
46
48
  @timeout_settings = Puppeteer::TimeoutSettings.new
47
49
  @touchscreen = Puppeteer::TouchScreen.new(client, @keyboard)
48
- # @accessibility = Accessibility.new(client)
49
50
  @frame_manager = Puppeteer::FrameManager.new(client, self, ignore_https_errors, @timeout_settings, network_enabled: network_enabled)
50
51
  @emulation_manager = Puppeteer::EmulationManager.new(client)
51
52
  @tracing = Puppeteer::Tracing.new(client)
53
+ @webmcp = Puppeteer::WebMCP.new(client, @frame_manager)
52
54
  @page_bindings = {}
53
55
  @page_binding_ids = {}
54
56
  @coverage = Puppeteer::Coverage.new(client)
55
57
  @javascript_enabled = true
56
58
  @screenshot_task_queue = ScreenshotTaskQueue.new
59
+ @screencast_session_count = 0
60
+ @screencast_start_promise = nil
61
+ @screencast_mutex = Mutex.new
57
62
  @inflight_requests = Set.new
58
63
  @request_intercepted_listener_map = ObjectSpace::WeakMap.new
59
64
  @attached_sessions = Set.new
65
+ @primary_session_listener_ids = []
60
66
 
61
67
  @workers = {}
62
68
  @user_drag_interception_enabled = false
63
69
  @service_worker_bypassed = false
64
70
 
65
- @attached_session_listener_id = @client.add_event_listener(CDPSessionEmittedEvents::Ready) do |session|
66
- handle_attached_to_session(session)
71
+ @swapped_session_listener_id = @tab_session.add_event_listener(CDPSessionEmittedEvents::Swapped) do |session|
72
+ Async do
73
+ handle_activation(session)
74
+ rescue => err
75
+ debug_puts(err)
76
+ end
67
77
  end
68
- @target_gone_listener_id = @target.target_manager.add_event_listener(
78
+ @secondary_session_listener_id = @tab_session.add_event_listener(CDPSessionEmittedEvents::Ready) do |session|
79
+ handle_secondary_target(session)
80
+ end
81
+ @target_manager = @target.target_manager
82
+ @target_gone_listener_id = @target_manager.add_event_listener(
69
83
  TargetManagerEmittedEvents::TargetGone,
70
84
  &method(:handle_detached_from_target)
71
85
  )
@@ -102,47 +116,95 @@ class Puppeteer::Page
102
116
  @file_chooser_interception_is_disabled = false
103
117
  @file_chooser_interceptors = Set.new
104
118
 
105
- @client.on_event('Page.domContentEventFired') do |event|
119
+ setup_primary_target_listeners(@client)
120
+ end
121
+
122
+ private def setup_primary_target_listeners(client)
123
+ listener_id = client.add_event_listener(CDPSessionEmittedEvents::Ready) do |session|
124
+ handle_attached_to_session(session)
125
+ end
126
+ @primary_session_listener_ids << [client, listener_id]
127
+
128
+ client.on_event('Page.domContentEventFired') do |event|
106
129
  emit_event(PageEmittedEvents::DOMContentLoaded)
107
130
  end
108
- @client.on_event('Page.loadEventFired') do |event|
131
+ client.on_event('Page.loadEventFired') do |event|
109
132
  emit_event(PageEmittedEvents::Load)
110
133
  end
111
- @client.add_event_listener('Runtime.consoleAPICalled') do |event|
134
+ client.add_event_listener('Runtime.consoleAPICalled') do |event|
112
135
  handle_console_api(event)
113
136
  end
114
- @client.add_event_listener('Runtime.bindingCalled') do |event|
137
+ client.add_event_listener('Runtime.bindingCalled') do |event|
115
138
  handle_binding_called(event)
116
139
  end
117
- @client.on_event('Page.javascriptDialogOpening') do |event|
140
+ client.on_event('Page.javascriptDialogOpening') do |event|
118
141
  handle_dialog_opening(event)
119
142
  end
120
- @client.on_event('Runtime.exceptionThrown') do |exception|
143
+ client.on_event('Runtime.exceptionThrown') do |exception|
121
144
  handle_exception(exception['exceptionDetails'])
122
145
  end
123
- @client.on_event('Inspector.targetCrashed') do |event|
146
+ client.on_event('Inspector.targetCrashed') do |event|
124
147
  handle_target_crashed
125
148
  end
126
- @client.on_event('Performance.metrics') do |event|
149
+ client.on_event('Performance.metrics') do |event|
127
150
  emit_event(PageEmittedEvents::Metrics, MetricsEvent.new(event))
128
151
  end
129
- @client.on_event('Log.entryAdded') do |event|
152
+ client.on_event('Log.entryAdded') do |event|
130
153
  handle_log_entry_added(event)
131
154
  end
132
- @client.on_event('Page.fileChooserOpened') do |event|
155
+ client.on_event('Page.fileChooserOpened') do |event|
133
156
  handle_file_chooser(event)
134
157
  end
135
- Async do
136
- @target.is_closed_promise.wait
137
- @client.remove_event_listener(@attached_session_listener_id)
138
- @target.target_manager.remove_event_listener(@target_gone_listener_id)
158
+ end
159
+
160
+ private def handle_activation(session)
161
+ @client = session
162
+ @target = session.target
163
+ @target.instance_variable_set(:@page, self)
164
+ @keyboard.update_client(session)
165
+ @mouse.update_client(session)
166
+ @touchscreen.update_client(session)
167
+ @emulation_manager.update_client(session)
168
+ @tracing.update_client(session)
169
+ @webmcp.update_client(session)
170
+ @coverage.update_client(session)
171
+ @frame_manager.swap_frame_tree(session)
172
+ setup_primary_target_listeners(session)
173
+ end
174
+
175
+ private def handle_secondary_target(session)
176
+ target = session.target
177
+ return unless target&.target_info&.subtype == 'prerender'
139
178
 
140
- emit_event(PageEmittedEvents::Close)
141
- @closed = true
179
+ @frame_manager.register_speculative_session(session)
180
+ @emulation_manager.register_speculative_session(session)
181
+ end
182
+
183
+ private def mark_closed
184
+ return if @closed
185
+
186
+ cleanup_page_listeners
187
+ @closed = true
188
+ emit_event(PageEmittedEvents::Close)
189
+ end
190
+
191
+ private def cleanup_page_listeners
192
+ return if @page_listeners_cleaned
193
+
194
+ @page_listeners_cleaned = true
195
+ @primary_session_listener_ids.each do |session, listener_id|
196
+ session.remove_event_listener(listener_id)
142
197
  end
198
+ @tab_session.remove_event_listener(@swapped_session_listener_id, @secondary_session_listener_id)
199
+ @target_manager.remove_event_listener(@target_gone_listener_id)
143
200
  end
144
201
 
145
202
  private def handle_detached_from_target(target)
203
+ if target == @tab_target
204
+ mark_closed
205
+ return
206
+ end
207
+
146
208
  session_id = target.session&.id
147
209
  @frame_manager.handle_detached_from_target(target)
148
210
  return unless session_id
@@ -201,6 +263,7 @@ class Puppeteer::Page
201
263
  @frame_manager.async_init(@target.target_id),
202
264
  @client.async_send_message('Performance.enable'),
203
265
  @client.async_send_message('Log.enable'),
266
+ @webmcp.async_initialize_domain,
204
267
  )
205
268
  end
206
269
 
@@ -360,10 +423,23 @@ class Puppeteer::Page
360
423
  end
361
424
 
362
425
  # @rbs return: bool -- Whether DevTools is attached to this page
363
- def has_devtools
426
+ def has_dev_tools
364
427
  !!browser._has_devtools_target(@target.target_id)
365
428
  end
366
429
 
430
+ # @rbs return: Puppeteer::Page -- Existing or newly opened DevTools page
431
+ def open_dev_tools
432
+ page_target_id = @target.target_id
433
+ devtools_target_id = browser._has_devtools_target(page_target_id)
434
+ if devtools_target_id
435
+ return browser._get_devtools_target_page(devtools_target_id)
436
+ end
437
+
438
+ browser._create_devtools_page(page_target_id)
439
+ end
440
+
441
+ define_async_method :async_open_dev_tools
442
+
367
443
  # @rbs extension: Puppeteer::Extension -- Extension to trigger
368
444
  # @rbs return: void -- No return value
369
445
  def trigger_extension_action(extension)
@@ -409,9 +485,14 @@ class Puppeteer::Page
409
485
  @frame_manager.main_frame
410
486
  end
411
487
 
412
- attr_reader :touchscreen, :coverage, :tracing, :accessibility
488
+ attr_reader :touchscreen, :coverage, :tracing, :webmcp
413
489
  alias_method :touch_screen, :touchscreen
414
490
 
491
+ # @rbs return: Puppeteer::Accessibility -- Main-frame accessibility tree
492
+ def accessibility
493
+ main_frame.accessibility
494
+ end
495
+
415
496
  # @rbs block: Proc? -- Optional block for instance_eval
416
497
  # @rbs return: Puppeteer::Keyboard -- Keyboard instance
417
498
  def keyboard(&block)
@@ -1324,6 +1405,13 @@ class Puppeteer::Page
1324
1405
  end
1325
1406
  end
1326
1407
 
1408
+ # @rbs locale: String? -- Locale to emulate, or nil to disable emulation
1409
+ # @rbs return: void -- No return value
1410
+ def emulate_locale(locale = nil)
1411
+ @emulation_manager.emulate_locale(locale)
1412
+ @frame_manager.network_manager.set_accept_language(locale)
1413
+ end
1414
+
1327
1415
  VISION_DEFICIENCY_TYPES = %w[
1328
1416
  none
1329
1417
  achromatopsia
@@ -1369,6 +1457,151 @@ class Puppeteer::Page
1369
1457
 
1370
1458
  attr_reader :viewport
1371
1459
 
1460
+ # @rbs path: String? -- Output file path
1461
+ # @rbs overwrite: bool -- Overwrite an existing output file
1462
+ # @rbs format: String? -- webm, gif, or mp4
1463
+ # @rbs crop: Hash[Symbol, Numeric]? -- Viewport crop rectangle
1464
+ # @rbs scale: Numeric? -- Output scale
1465
+ # @rbs speed: Numeric? -- Playback speed
1466
+ # @rbs fps: Numeric? -- Output frame rate
1467
+ # @rbs loop: Numeric? -- GIF loop count
1468
+ # @rbs delay: Numeric? -- GIF loop delay
1469
+ # @rbs quality: Numeric? -- VP9 CRF value
1470
+ # @rbs colors: Numeric? -- GIF palette size
1471
+ # @rbs ffmpeg_path: String? -- FFmpeg executable path
1472
+ # @rbs return: Puppeteer::ScreenRecorder -- Active recorder
1473
+ def screencast(
1474
+ path: nil,
1475
+ overwrite: true,
1476
+ format: nil,
1477
+ crop: nil,
1478
+ scale: nil,
1479
+ speed: nil,
1480
+ fps: nil,
1481
+ loop: nil,
1482
+ delay: nil,
1483
+ quality: nil,
1484
+ colors: nil,
1485
+ ffmpeg_path: nil
1486
+ )
1487
+ raise ArgumentError.new('`speed` must be greater than 0.') if speed && speed <= 0
1488
+ raise ArgumentError.new('`scale` must be greater than 0.') if scale && scale <= 0
1489
+ width, height, device_pixel_ratio = native_pixel_dimensions
1490
+ normalized_crop = normalize_screencast_crop(crop, width, height, device_pixel_ratio)
1491
+ options = {
1492
+ path: path,
1493
+ overwrite: overwrite,
1494
+ format: format,
1495
+ crop: normalized_crop,
1496
+ scale: scale,
1497
+ speed: speed,
1498
+ fps: fps,
1499
+ loop: loop,
1500
+ delay: delay,
1501
+ quality: quality,
1502
+ colors: colors,
1503
+ ffmpeg_path: ffmpeg_path,
1504
+ }.compact
1505
+ recorder = Puppeteer::ScreenRecorder.new(self, width, height, options)
1506
+ begin
1507
+ _start_screencast
1508
+ rescue
1509
+ recorder.stop
1510
+ raise
1511
+ end
1512
+ recorder
1513
+ end
1514
+
1515
+ # @rbs return: void -- Start a shared CDP screencast session
1516
+ def _start_screencast
1517
+ start_promise, first_session = @screencast_mutex.synchronize do
1518
+ @screencast_session_count += 1
1519
+ next [@screencast_start_promise, false] if @screencast_start_promise
1520
+
1521
+ @screencast_start_promise = Async::Promise.new
1522
+ [@screencast_start_promise, true]
1523
+ end
1524
+ if first_session
1525
+ first_frame = Async::Promise.new
1526
+ client = main_frame.client
1527
+ listener_id = client.once('Page.screencastFrame') do
1528
+ first_frame.resolve(nil) unless first_frame.resolved?
1529
+ end
1530
+ begin
1531
+ client.send_message('Page.startScreencast', format: 'png')
1532
+ first_frame.wait
1533
+ start_promise.resolve(nil)
1534
+ rescue => error
1535
+ start_promise.reject(error) unless start_promise.resolved?
1536
+ ensure
1537
+ client.remove_event_listener(listener_id) if listener_id
1538
+ end
1539
+ end
1540
+ start_promise.wait
1541
+ end
1542
+
1543
+ # @rbs return: void -- Stop a shared CDP screencast session
1544
+ def _stop_screencast
1545
+ should_stop = @screencast_mutex.synchronize do
1546
+ @screencast_session_count -= 1 if @screencast_session_count.positive?
1547
+ next false unless @screencast_start_promise
1548
+
1549
+ @screencast_start_promise = nil
1550
+ @screencast_session_count.zero?
1551
+ end
1552
+ main_frame.client.send_message('Page.stopScreencast') if should_stop
1553
+ end
1554
+
1555
+ private def native_pixel_dimensions
1556
+ original_viewport = @viewport
1557
+ if original_viewport && original_viewport.device_scale_factor != 0
1558
+ self.viewport = original_viewport.merge(device_scale_factor: 0)
1559
+ end
1560
+ main_frame.puppeteer_world.evaluate(<<~JAVASCRIPT)
1561
+ () => [
1562
+ window.visualViewport.width * window.devicePixelRatio,
1563
+ window.visualViewport.height * window.devicePixelRatio,
1564
+ window.devicePixelRatio,
1565
+ ]
1566
+ JAVASCRIPT
1567
+ ensure
1568
+ self.viewport = original_viewport if original_viewport && @viewport != original_viewport
1569
+ end
1570
+
1571
+ private def normalize_screencast_crop(crop, width, height, device_pixel_ratio)
1572
+ return nil unless crop
1573
+
1574
+ value = ->(name) do
1575
+ crop.respond_to?(name) ? crop.public_send(name) : crop[name] || crop[name.to_s]
1576
+ end
1577
+ x = value.call(:x).floor
1578
+ y = value.call(:y).floor
1579
+ crop_width = value.call(:width).ceil
1580
+ crop_height = value.call(:height).ceil
1581
+ if x.negative? || y.negative?
1582
+ raise ArgumentError.new('`crop.x` and `crop.y` must be greater than or equal to 0.')
1583
+ end
1584
+ if crop_width <= 0 || crop_height <= 0
1585
+ raise ArgumentError.new(
1586
+ '`crop.height` and `crop.width` must be greater than or equal to 0.',
1587
+ )
1588
+ end
1589
+ viewport_width = width / device_pixel_ratio
1590
+ viewport_height = height / device_pixel_ratio
1591
+ if x + crop_width > viewport_width
1592
+ raise ArgumentError.new("`crop.width` cannot be larger than the viewport width (#{viewport_width}).")
1593
+ end
1594
+ if y + crop_height > viewport_height
1595
+ raise ArgumentError.new("`crop.height` cannot be larger than the viewport height (#{viewport_height}).")
1596
+ end
1597
+ {
1598
+ x: x * device_pixel_ratio,
1599
+ y: y * device_pixel_ratio,
1600
+ width: crop_width * device_pixel_ratio,
1601
+ height: crop_height * device_pixel_ratio,
1602
+ }
1603
+ end
1604
+
1372
1605
  # @rbs page_function: String -- page_function parameter
1373
1606
  # @rbs args: Array[untyped] -- args parameter
1374
1607
  # @rbs return: untyped -- Result
@@ -1619,13 +1852,7 @@ class Puppeteer::Page
1619
1852
  @client.send_message('Page.close')
1620
1853
  else
1621
1854
  @client.connection.send_message('Target.closeTarget', targetId: @target.target_id)
1622
- @target.is_closed_promise.wait
1623
-
1624
- # @closed sometimes remains false, so wait for @closed = true with 100ms timeout.
1625
- 25.times do
1626
- break if @closed
1627
- Puppeteer::AsyncUtils.sleep_seconds(0.004)
1628
- end
1855
+ @tab_target.is_closed_promise.wait
1629
1856
  end
1630
1857
  rescue Puppeteer::Connection::ProtocolError => err
1631
1858
  raise unless err.message.match?(/Target closed/i)
@@ -1644,11 +1871,10 @@ class Puppeteer::Page
1644
1871
  # @rbs selector: String -- CSS selector
1645
1872
  # @rbs delay: Numeric? -- Delay between down and up (ms)
1646
1873
  # @rbs button: String? -- Mouse button
1647
- # @rbs click_count: Integer? -- Deprecated: use count (click_count only sets clickCount)
1648
1874
  # @rbs count: Integer? -- Number of clicks to perform
1649
1875
  # @rbs return: void -- No return value
1650
- def click(selector, delay: nil, button: nil, click_count: nil, count: nil)
1651
- main_frame.click(selector, delay: delay, button: button, click_count: click_count, count: count)
1876
+ def click(selector, delay: nil, button: nil, count: nil)
1877
+ main_frame.click(selector, delay: delay, button: button, count: count)
1652
1878
  end
1653
1879
 
1654
1880
  define_async_method :async_click