puppeteer-ruby 0.52.1 → 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 (61) 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 +1 -0
  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/connection.rb +19 -0
  10. data/lib/puppeteer/coverage.rb +5 -0
  11. data/lib/puppeteer/css_coverage.rb +4 -0
  12. data/lib/puppeteer/dialog.rb +5 -0
  13. data/lib/puppeteer/element_handle.rb +2 -3
  14. data/lib/puppeteer/emulation_manager.rb +70 -9
  15. data/lib/puppeteer/events.rb +2 -0
  16. data/lib/puppeteer/execution_context.rb +7 -0
  17. data/lib/puppeteer/extension.rb +20 -6
  18. data/lib/puppeteer/frame.rb +7 -3
  19. data/lib/puppeteer/frame_manager.rb +71 -26
  20. data/lib/puppeteer/http_response.rb +15 -1
  21. data/lib/puppeteer/isolated_world.rb +10 -4
  22. data/lib/puppeteer/js_coverage.rb +4 -0
  23. data/lib/puppeteer/keyboard.rb +6 -0
  24. data/lib/puppeteer/launcher/browser_options.rb +13 -1
  25. data/lib/puppeteer/launcher/chrome.rb +48 -4
  26. data/lib/puppeteer/lifecycle_watcher.rb +1 -6
  27. data/lib/puppeteer/locators.rb +54 -26
  28. data/lib/puppeteer/mouse.rb +12 -24
  29. data/lib/puppeteer/network_manager.rb +16 -4
  30. data/lib/puppeteer/page.rb +259 -33
  31. data/lib/puppeteer/puppeteer.rb +6 -5
  32. data/lib/puppeteer/screen_recorder.rb +269 -0
  33. data/lib/puppeteer/target.rb +7 -3
  34. data/lib/puppeteer/touch_screen.rb +6 -0
  35. data/lib/puppeteer/tracing.rb +4 -0
  36. data/lib/puppeteer/version.rb +2 -2
  37. data/lib/puppeteer/wait_task.rb +1 -2
  38. data/lib/puppeteer/web_mcp.rb +227 -0
  39. data/lib/puppeteer/web_worker.rb +135 -9
  40. data/lib/puppeteer.rb +11 -1
  41. data/puppeteer-ruby.gemspec +1 -0
  42. data/sig/_supplementary.rbs +33 -1
  43. data/sig/puppeteer/accessibility.rbs +73 -0
  44. data/sig/puppeteer/browser.rbs +27 -3
  45. data/sig/puppeteer/cdp_session.rbs +4 -0
  46. data/sig/puppeteer/dialog.rbs +3 -0
  47. data/sig/puppeteer/element_handle.rbs +1 -2
  48. data/sig/puppeteer/execution_context.rbs +5 -0
  49. data/sig/puppeteer/extension.rbs +4 -0
  50. data/sig/puppeteer/frame.rbs +4 -2
  51. data/sig/puppeteer/http_response.rbs +4 -0
  52. data/sig/puppeteer/keyboard.rbs +4 -0
  53. data/sig/puppeteer/locators.rbs +3 -4
  54. data/sig/puppeteer/mouse.rbs +5 -6
  55. data/sig/puppeteer/page.rbs +48 -4
  56. data/sig/puppeteer/puppeteer.rbs +4 -5
  57. data/sig/puppeteer/screen_recorder.rbs +48 -0
  58. data/sig/puppeteer/touch_screen.rbs +4 -0
  59. data/sig/puppeteer/web_mcp.rbs +112 -0
  60. data/sig/puppeteer/web_worker.rbs +39 -0
  61. metadata +22 -2
@@ -52,6 +52,11 @@ class Puppeteer::Frame
52
52
  @frame_manager.page
53
53
  end
54
54
 
55
+ # @rbs return: Puppeteer::Accessibility -- Accessibility tree for this frame
56
+ def accessibility
57
+ @accessibility ||= Puppeteer::Accessibility.new(self)
58
+ end
59
+
55
60
  # @rbs return: Numeric -- Default timeout in milliseconds
56
61
  def default_timeout
57
62
  @frame_manager.timeout_settings.timeout
@@ -284,11 +289,10 @@ class Puppeteer::Frame
284
289
  # @rbs selector: String -- CSS selector
285
290
  # @rbs delay: Numeric? -- Delay between down and up (ms)
286
291
  # @rbs button: String? -- Mouse button
287
- # @rbs click_count: Integer? -- Deprecated: use count (click_count only sets clickCount)
288
292
  # @rbs count: Integer? -- Number of clicks to perform
289
293
  # @rbs return: void -- No return value
290
- def click(selector, delay: nil, button: nil, click_count: nil, count: nil)
291
- @puppeteer_world.click(selector, delay: delay, button: button, click_count: click_count, count: count)
294
+ def click(selector, delay: nil, button: nil, count: nil)
295
+ @puppeteer_world.click(selector, delay: delay, button: button, count: count)
292
296
  end
293
297
 
294
298
  define_async_method :async_click
@@ -40,6 +40,7 @@ class Puppeteer::FrameManager
40
40
  @frame_tree_mutex = Mutex.new
41
41
 
42
42
  setup_listeners(@client)
43
+ setup_client_disconnect_listener(@client)
43
44
  end
44
45
 
45
46
  private def setup_listeners(client)
@@ -97,6 +98,69 @@ class Puppeteer::FrameManager
97
98
 
98
99
  attr_reader :client, :timeout_settings
99
100
 
101
+ # When the main frame is replaced by another main frame, preserve the
102
+ # existing Frame object while replacing its ID, client, and frame tree.
103
+ def swap_frame_tree(client)
104
+ @client = client
105
+ frame = @main_frame
106
+ if frame
107
+ target_id = client.target.target_id
108
+ @frame_naviigated_received << target_id
109
+ @frames.delete(frame.id)
110
+ frame.id = target_id
111
+ @frames[target_id] = frame
112
+ frame.send(:update_client, client)
113
+ end
114
+
115
+ setup_listeners(client)
116
+ setup_client_disconnect_listener(client)
117
+ init(client.target.target_id, client)
118
+ @network_manager.add_client(client)
119
+ emit_event(FrameManagerEmittedEvents::FrameSwappedByActivation, frame) if frame
120
+ end
121
+
122
+ def register_speculative_session(client)
123
+ @network_manager.add_client(client)
124
+ end
125
+
126
+ private def setup_client_disconnect_listener(client)
127
+ client.once(CDPSessionEmittedEvents::Disconnected) do
128
+ Async do
129
+ handle_client_disconnect(client)
130
+ rescue => err
131
+ debug_puts(err)
132
+ end
133
+ end
134
+ end
135
+
136
+ # A disconnected primary client can mean either that the page closed or
137
+ # that a prerendered target is about to replace it. Wait for one of those
138
+ # events instead of guessing with a timer.
139
+ private def handle_client_disconnect(client)
140
+ frame = @main_frame
141
+ return unless frame
142
+ return unless @client == client
143
+
144
+ unless @page.browser.connected? && !@page.closed?
145
+ remove_frame_recursively(frame)
146
+ return
147
+ end
148
+
149
+ frame.child_frames.each { |child| remove_frame_recursively(child) }
150
+ swapped = Async::Promise.new
151
+ swap_listener_id = add_event_listener(FrameManagerEmittedEvents::FrameSwappedByActivation) do |swapped_frame|
152
+ swapped.resolve(true) if swapped_frame == frame && !swapped.resolved?
153
+ end
154
+ close_listener_id = @page.add_event_listener(PageEmittedEvents::Close) do
155
+ swapped.resolve(false) unless swapped.resolved?
156
+ end
157
+
158
+ remove_frame_recursively(frame) unless swapped.wait
159
+ ensure
160
+ remove_event_listener(swap_listener_id) if swap_listener_id
161
+ @page.remove_event_listener(close_listener_id) if close_listener_id
162
+ end
163
+
100
164
  private def init(target_id, cdp_session = nil)
101
165
  @frames_pending_target_init[target_id] ||= Async::Promise.new
102
166
  client = cdp_session || @client
@@ -115,7 +179,6 @@ class Puppeteer::FrameManager
115
179
  client.async_send_message('Runtime.enable'),
116
180
  @page.browser.issues_enabled? ? client.async_send_message('Audits.enable') : nil,
117
181
  )
118
- maybe_setup_block_list(client)
119
182
  ensure_isolated_world(client, UTILITY_WORLD_NAME)
120
183
  @network_manager.init unless cdp_session
121
184
  rescue => err
@@ -143,6 +206,11 @@ class Puppeteer::FrameManager
143
206
  # @return [Puppeteer::HTTPResponse]
144
207
  def navigate_frame(frame, url, referer: nil, referrer_policy: nil, timeout: nil, wait_until: nil)
145
208
  assert_no_legacy_navigation_options(wait_until: wait_until)
209
+ unless @page.browser.url_allowed?(url)
210
+ raise Puppeteer::Error.new(
211
+ "Navigation to #{url} is blocked by blocklist/allowlist rules",
212
+ )
213
+ end
146
214
 
147
215
  referrer_policy ||= @network_manager.extra_http_headers['referer-policy']
148
216
  protocol_referrer_policy = referrer_policy_to_protocol(referrer_policy)
@@ -565,6 +633,7 @@ class Puppeteer::FrameManager
565
633
  context = @context_id_to_context[key]
566
634
  return unless context
567
635
  @context_id_to_context.delete(key)
636
+ context.dispose
568
637
  context.world&.delete_context(context)
569
638
  end
570
639
 
@@ -578,6 +647,7 @@ class Puppeteer::FrameManager
578
647
  if key_session_id != session_id
579
648
  true # keep
580
649
  else
650
+ context.dispose
581
651
  context.world&.delete_context(context)
582
652
  false # remove
583
653
  end
@@ -589,31 +659,6 @@ class Puppeteer::FrameManager
589
659
  @context_id_to_context[key] or raise "INTERNAL ERROR: missing context with id = #{context_id}"
590
660
  end
591
661
 
592
- private def maybe_setup_block_list(client)
593
- block_list = @page.browser.block_list
594
- return if block_list.nil? || block_list.empty?
595
-
596
- client.send_message('Network.enable')
597
- matched_network_conditions = block_list.map do |pattern|
598
- {
599
- urlPattern: pattern,
600
- latency: 0,
601
- downloadThroughput: -1,
602
- uploadThroughput: -1,
603
- }
604
- end
605
- client.send_message('Network.emulateNetworkConditionsByRule', {
606
- matchedNetworkConditions: matched_network_conditions,
607
- offline: true,
608
- })
609
- rescue Puppeteer::Connection::ProtocolError => err
610
- if err.message.include?('Method not available') || err.message.include?("wasn't found")
611
- client.send_message('Network.setBlockedURLs', urls: block_list)
612
- else
613
- raise
614
- end
615
- end
616
-
617
662
  # @param {!Frame} frame
618
663
  private def remove_frame_recursively(frame)
619
664
  frame.child_frames.each do |child|
@@ -54,7 +54,8 @@ class Puppeteer::HTTPResponse
54
54
  @headers = {}
55
55
  headers = extra_info ? extra_info['headers'] : response_payload['headers']
56
56
  headers.each do |key, value|
57
- @headers[key.downcase] = value
57
+ header_name = key.downcase
58
+ @headers[header_name] = normalize_header_value(header_name, value)
58
59
  end
59
60
  @security_details = if_present(response_payload['securityDetails']) do |security_payload|
60
61
  SecurityDetails.new(security_payload)
@@ -68,6 +69,19 @@ class Puppeteer::HTTPResponse
68
69
 
69
70
  attr_reader :remote_address, :url, :status, :status_text, :headers, :security_details, :request, :timing
70
71
 
72
+ # Multiline values represent duplicate fields in CDP. RFC 9110 allows
73
+ # comma-combining fields except Set-Cookie, whose lines must stay separate.
74
+ private def normalize_header_value(name, value)
75
+ return value unless value.include?("\n")
76
+
77
+ separator = name == 'set-cookie' ? "\n " : ', '
78
+ value
79
+ .split("\n")
80
+ .map(&:strip)
81
+ .reject(&:empty?)
82
+ .join(separator)
83
+ end
84
+
71
85
  def inspect
72
86
  values = %i[remote_address url status status_text headers security_details request].map do |sym|
73
87
  value = instance_variable_get(:"@#{sym}")
@@ -68,7 +68,7 @@ class Puppeteer::IsolaatedWorld
68
68
  @client.on_event('Runtime.bindingCalled', &method(:handle_binding_called))
69
69
  end
70
70
 
71
- attr_reader :frame, :task_manager, :origin, :world_id
71
+ attr_reader :frame, :task_manager, :origin, :world_id, :context
72
72
 
73
73
  # only used in Puppeteer::WaitTask#initialize
74
74
  private def _bound_functions
@@ -246,6 +246,13 @@ class Puppeteer::IsolaatedWorld
246
246
  # @param wait_until [String|Array<String>]
247
247
  def set_content(html, timeout: nil, wait_until: nil)
248
248
  option_wait_until = [wait_until || 'load'].flatten
249
+ unsupported_events = option_wait_until & ['networkidle0', 'networkidle2']
250
+ unless unsupported_events.empty?
251
+ raise ArgumentError.new(
252
+ "set_content does not support #{unsupported_events.join(', ')}; " \
253
+ 'compose set_content with wait_for_network_idle instead.',
254
+ )
255
+ end
249
256
  option_timeout = timeout || @timeout_settings.navigation_timeout
250
257
 
251
258
  # We rely upon the fact that document.open() will reset frame lifecycle with "init"
@@ -398,11 +405,10 @@ class Puppeteer::IsolaatedWorld
398
405
  # @param selector [String]
399
406
  # @param delay [Number]
400
407
  # @param button [String] "left"|"right"|"middle"
401
- # @param click_count [Number] Deprecated: use count (click_count only sets clickCount)
402
408
  # @param count [Number]
403
- def click(selector, delay: nil, button: nil, click_count: nil, count: nil)
409
+ def click(selector, delay: nil, button: nil, count: nil)
404
410
  handle = query_selector(selector) or raise ElementNotFoundError.new(selector)
405
- handle.click(delay: delay, button: button, click_count: click_count, count: count)
411
+ handle.click(delay: delay, button: button, count: count)
406
412
  handle.dispose
407
413
  end
408
414
 
@@ -29,6 +29,10 @@ class Puppeteer::JSCoverage
29
29
  @script_parsed_tasks = []
30
30
  end
31
31
 
32
+ def update_client(client)
33
+ @client = client
34
+ end
35
+
32
36
  def start(
33
37
  reset_on_navigation: nil,
34
38
  report_anonymous_scripts: nil,
@@ -16,6 +16,12 @@ class Puppeteer::Keyboard
16
16
 
17
17
  attr_reader :modifiers
18
18
 
19
+ # @rbs client: Puppeteer::CDPSession -- Replacement CDP session
20
+ # @rbs return: void -- No return value
21
+ def update_client(client)
22
+ @client = client
23
+ end
24
+
19
25
  # @rbs key: String -- Key name
20
26
  # @rbs text: String? -- Text to input
21
27
  # @rbs commands: Array[String]? -- Editing commands
@@ -38,6 +38,18 @@ module Puppeteer::Launcher
38
38
  if @block_list && !@block_list.is_a?(Array)
39
39
  raise ArgumentError.new('block_list must be an Array of URL patterns')
40
40
  end
41
+ @allow_list = options[:allow_list]
42
+ if @allow_list && !@allow_list.is_a?(Array)
43
+ raise ArgumentError.new('allow_list must be an Array of URL patterns')
44
+ end
45
+ if @block_list && @allow_list
46
+ raise ArgumentError.new('Cannot specify both blocklist and allowlist')
47
+ end
48
+ [*@block_list, *@allow_list].each do |pattern|
49
+ URLPattern::URLPattern.new(pattern)
50
+ rescue URLPattern::Error => error
51
+ raise ArgumentError.new("Invalid URLPattern #{pattern.inspect}: #{error.message}")
52
+ end
41
53
 
42
54
  # only for Puppeteer.connect
43
55
  @target_filter = options[:target_filter]
@@ -51,7 +63,7 @@ module Puppeteer::Launcher
51
63
  end
52
64
  end
53
65
 
54
- attr_reader :default_viewport, :slow_mo, :target_filter, :is_page_target, :network_enabled, :issues_enabled, :protocol_timeout, :block_list
66
+ attr_reader :default_viewport, :slow_mo, :target_filter, :is_page_target, :network_enabled, :issues_enabled, :protocol_timeout, :block_list, :allow_list
55
67
 
56
68
  def ignore_https_errors?
57
69
  @ignore_https_errors
@@ -3,6 +3,24 @@ require 'tmpdir'
3
3
  # https://github.com/puppeteer/puppeteer/blob/main/src/node/Launcher.ts
4
4
  module Puppeteer::Launcher
5
5
  class Chrome
6
+ def self.get_features(flag, options = [])
7
+ prefix = flag.end_with?('=') ? flag : "#{flag}="
8
+ options
9
+ .select { |option| option.start_with?(prefix) }
10
+ .flat_map do |option|
11
+ option[(option.index('=') + 1)..]
12
+ .strip
13
+ .split(',')
14
+ .map(&:strip)
15
+ end
16
+ .reject(&:empty?)
17
+ end
18
+
19
+ def self.remove_matching_flags(array, flag)
20
+ prefix = flag.end_with?('=') ? flag : "#{flag}="
21
+ array.delete_if { |option| option.start_with?(prefix) }
22
+ end
23
+
6
24
  def initialize(project_root:, preferred_revision:, is_puppeteer_core:)
7
25
  @project_root = project_root
8
26
  @preferred_revision = preferred_revision
@@ -89,6 +107,7 @@ module Puppeteer::Launcher
89
107
  network_enabled: @browser_options.network_enabled,
90
108
  issues_enabled: @browser_options.issues_enabled,
91
109
  block_list: @browser_options.block_list,
110
+ allow_list: @browser_options.allow_list,
92
111
  process: runner.proc,
93
112
  close_callback: -> { runner.close },
94
113
  target_filter_callback: nil,
@@ -120,6 +139,33 @@ module Puppeteer::Launcher
120
139
  # @param options [Launcher::ChromeArgOptions]
121
140
  def initialize(chrome_arg_options)
122
141
  # See https://github.com/GoogleChrome/chrome-launcher/blob/main/docs/chrome-flags-for-tools.md
142
+ user_disabled_features = Chrome.get_features('--disable-features', chrome_arg_options.args)
143
+ if user_disabled_features.any?
144
+ Chrome.remove_matching_flags(chrome_arg_options.args, '--disable-features')
145
+ end
146
+
147
+ user_enabled_features = Chrome.get_features('--enable-features', chrome_arg_options.args)
148
+ if user_enabled_features.any?
149
+ Chrome.remove_matching_flags(chrome_arg_options.args, '--enable-features')
150
+ end
151
+
152
+ enabled_features = [
153
+ 'NetworkServiceInProcess2',
154
+ *user_enabled_features,
155
+ ].reject(&:empty?)
156
+
157
+ disabled_features = [
158
+ 'Translate',
159
+ 'BackForwardCache',
160
+ 'AcceptCHFrame',
161
+ 'MediaRouter',
162
+ 'OptimizationHints',
163
+ 'IPH_ReadingModePageActionLabel',
164
+ 'ReadAnythingOmniboxChip',
165
+ 'WebUIReloadButton',
166
+ *user_disabled_features,
167
+ ].reject(&:empty?).reject { |feature| enabled_features.include?(feature) }
168
+
123
169
  chrome_arguments = [
124
170
  '--allow-pre-commit-input',
125
171
  '--disable-background-networking',
@@ -131,8 +177,7 @@ module Puppeteer::Launcher
131
177
  '--disable-component-update',
132
178
  '--disable-default-apps',
133
179
  '--disable-dev-shm-usage',
134
- # AcceptCHFrame disabled because of crbug.com/1348106.
135
- '--disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints,IPH_ReadingModePageActionLabel,ReadAnythingOmniboxChip',
180
+ "--disable-features=#{disabled_features.join(',')}",
136
181
  '--disable-hang-monitor',
137
182
  '--disable-ipc-flooding-protection',
138
183
  '--disable-popup-blocking',
@@ -143,7 +188,7 @@ module Puppeteer::Launcher
143
188
  # TODO(sadym): remove '--enable-blink-features=IdleDetection' once
144
189
  # IdleDetection is turned on by default.
145
190
  '--enable-blink-features=IdleDetection',
146
- '--enable-features=NetworkServiceInProcess2',
191
+ "--enable-features=#{enabled_features.join(',')}",
147
192
  '--export-tagged-pdf',
148
193
  '--force-color-profile=srgb',
149
194
  '--metrics-recording-only',
@@ -179,7 +224,6 @@ module Puppeteer::Launcher
179
224
  end
180
225
 
181
226
  if chrome_arg_options.enable_extensions
182
- chrome_arguments << '--enable-unsafe-extension-debugging'
183
227
  if chrome_arg_options.enable_extensions.is_a?(Array) && !chrome_arg_options.enable_extensions.empty?
184
228
  extension_paths = chrome_arg_options.enable_extensions.map do |path|
185
229
  File.expand_path(path)
@@ -69,9 +69,6 @@ class Puppeteer::LifecycleWatcher
69
69
  @timeout = timeout
70
70
 
71
71
  @listener_ids = {}
72
- @listener_ids['client'] = @frame_manager.client.add_event_listener(CDPSessionEmittedEvents::Disconnected) do
73
- terminate(TerminatedError.new('Navigation failed because browser has disconnected!'))
74
- end
75
72
  connection = @frame_manager.client.respond_to?(:connection) ? @frame_manager.client.connection : nil
76
73
  if connection
77
74
  @listener_ids['connection'] = connection.add_event_listener(ConnectionEmittedEvents::Disconnected) do
@@ -85,6 +82,7 @@ class Puppeteer::LifecycleWatcher
85
82
  @frame_manager.add_event_listener(FrameManagerEmittedEvents::FrameNavigatedWithinDocument, &method(:navigated_within_document)),
86
83
  @frame_manager.add_event_listener(FrameManagerEmittedEvents::FrameNavigated, &method(:navigated)),
87
84
  @frame_manager.add_event_listener(FrameManagerEmittedEvents::FrameSwapped, &method(:handle_frame_swapped)),
85
+ @frame_manager.add_event_listener(FrameManagerEmittedEvents::FrameSwappedByActivation, &method(:handle_frame_swapped)),
88
86
  @frame_manager.add_event_listener(FrameManagerEmittedEvents::FrameDetached, &method(:handle_frame_detached)),
89
87
  ]
90
88
  @listener_ids['network_manager'] = [
@@ -209,9 +207,6 @@ class Puppeteer::LifecycleWatcher
209
207
  end
210
208
 
211
209
  def dispose
212
- if_present(@listener_ids['client']) do |id|
213
- @frame_manager.client.remove_event_listener(id)
214
- end
215
210
  if_present(@listener_ids['frame_manager']) do |ids|
216
211
  @frame_manager.remove_event_listener(*ids)
217
212
  end
@@ -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