chromate-rb 0.0.1.pre → 0.0.3.pre

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/CHANGELOG.md +72 -3
  4. data/README.md +33 -6
  5. data/Rakefile +48 -16
  6. data/docker_root/Gemfile +4 -0
  7. data/docker_root/Gemfile.lock +28 -0
  8. data/docker_root/TestInDocker.gif +0 -0
  9. data/docker_root/app.rb +87 -0
  10. data/dockerfiles/Dockerfile +21 -7
  11. data/dockerfiles/README.md +49 -0
  12. data/docs/BOT_BROWSER.md +74 -0
  13. data/docs/README.md +74 -0
  14. data/docs/browser.md +124 -102
  15. data/docs/client.md +126 -0
  16. data/docs/element.md +365 -0
  17. data/docs/elements/checkbox.md +69 -0
  18. data/docs/elements/radio.md +57 -0
  19. data/lib/bot_browser/downloader.rb +64 -0
  20. data/lib/bot_browser/installer.rb +99 -0
  21. data/lib/bot_browser.rb +43 -0
  22. data/lib/chromate/actions/dom.rb +35 -27
  23. data/lib/chromate/actions/navigate.rb +7 -5
  24. data/lib/chromate/actions/screenshot.rb +71 -14
  25. data/lib/chromate/actions/stealth.rb +62 -0
  26. data/lib/chromate/binary.rb +83 -0
  27. data/lib/chromate/browser.rb +120 -24
  28. data/lib/chromate/c_logger.rb +8 -0
  29. data/lib/chromate/client.rb +65 -26
  30. data/lib/chromate/configuration.rb +31 -14
  31. data/lib/chromate/element.rb +119 -16
  32. data/lib/chromate/elements/checkbox.rb +40 -0
  33. data/lib/chromate/elements/option.rb +43 -0
  34. data/lib/chromate/elements/radio.rb +37 -0
  35. data/lib/chromate/elements/select.rb +50 -6
  36. data/lib/chromate/elements/tags.rb +29 -0
  37. data/lib/chromate/exceptions.rb +2 -0
  38. data/lib/chromate/files/agents.json +11 -0
  39. data/lib/chromate/files/stealth.js +199 -0
  40. data/lib/chromate/hardwares/keyboard_controller.rb +45 -0
  41. data/lib/chromate/hardwares/keyboards/virtual_controller.rb +65 -0
  42. data/lib/chromate/hardwares/mouse_controller.rb +55 -11
  43. data/lib/chromate/hardwares/mouses/linux_controller.rb +124 -21
  44. data/lib/chromate/hardwares/mouses/mac_os_controller.rb +6 -6
  45. data/lib/chromate/hardwares/mouses/virtual_controller.rb +95 -7
  46. data/lib/chromate/hardwares/mouses/x11.rb +36 -0
  47. data/lib/chromate/hardwares.rb +19 -3
  48. data/lib/chromate/helpers.rb +22 -15
  49. data/lib/chromate/user_agent.rb +41 -15
  50. data/lib/chromate/version.rb +1 -1
  51. data/lib/chromate.rb +2 -0
  52. data/logo.png +0 -0
  53. data/results/bot.png +0 -0
  54. data/results/brotector.png +0 -0
  55. data/results/cloudflare.png +0 -0
  56. data/results/headers.png +0 -0
  57. data/results/pixelscan.png +0 -0
  58. metadata +45 -2
@@ -2,23 +2,27 @@
2
2
 
3
3
  require 'websocket-client-simple'
4
4
  require 'chromate/helpers'
5
+ require 'chromate/exceptions'
5
6
 
6
7
  module Chromate
7
8
  class Client
8
9
  include Helpers
9
10
 
11
+ # @return [Array<Proc>]
10
12
  def self.listeners
11
13
  @@listeners ||= [] # rubocop:disable Style/ClassVars
12
14
  end
13
15
 
14
16
  attr_reader :port, :ws, :browser
15
17
 
18
+ # @param [Chromate::Browser] browser
16
19
  def initialize(browser)
17
- @browser = browser
18
- options = browser.options
19
- @port = options[:port] || find_available_port
20
+ @browser = browser
21
+ options = browser.options
22
+ @port = options[:port] || find_available_port
20
23
  end
21
24
 
25
+ # @return [self]
22
26
  def start
23
27
  @ws_url = fetch_websocket_debug_url
24
28
  @ws = WebSocket::Client::Simple.connect(@ws_url)
@@ -29,7 +33,7 @@ module Chromate
29
33
 
30
34
  @ws.on :message do |msg|
31
35
  message = JSON.parse(msg.data)
32
- client_self.handle_message(message)
36
+ client_self.send(:handle_message, message)
33
37
 
34
38
  Client.listeners.each do |listener|
35
39
  listener.call(message)
@@ -37,79 +41,114 @@ module Chromate
37
41
  end
38
42
 
39
43
  @ws.on :open do
40
- puts "Connexion WebSocket établie avec #{@ws_url}"
44
+ Chromate::CLogger.log('Successfully connected to WebSocket', level: :debug)
41
45
  end
42
46
 
43
47
  @ws.on :error do |e|
44
- puts "Erreur WebSocket : #{e.message}"
48
+ Chromate::CLogger.log("WebSocket error: #{e.message}", level: :error)
45
49
  end
46
50
 
47
51
  @ws.on :close do |_e|
48
- puts 'Connexion WebSocket fermée'
52
+ Chromate::CLogger.log('WebSocket connection closed', level: :debug)
49
53
  end
50
54
 
51
- sleep 0.2
55
+ sleep 0.2 # Wait for the connection to be established
52
56
  client_self.send_message('Target.setDiscoverTargets', { discover: true })
57
+
53
58
  client_self
54
59
  end
55
60
 
61
+ # @return [self]
56
62
  def stop
57
63
  @ws&.close
64
+
65
+ self
58
66
  end
59
67
 
68
+ # @param [String] method
69
+ # @param [Hash] params
70
+ # @return [Hash]
60
71
  def send_message(method, params = {})
61
72
  @id += 1
62
73
  message = { id: @id, method: method, params: params }
63
- puts "Envoi du message : #{message}"
74
+ Chromate::CLogger.log("Sending WebSocket message: #{message}", level: :debug)
64
75
 
65
76
  begin
66
77
  @ws.send(message.to_json)
67
78
  @callbacks[@id] = Queue.new
68
79
  result = @callbacks[@id].pop
69
- puts "Réponse reçue pour le message #{message[:id]} : #{result}"
80
+ Chromate::CLogger.log("Response received for message #{message[:id]}: #{result}", level: :debug)
70
81
  result
71
82
  rescue StandardError => e
72
- puts "Erreur WebSocket lors de l'envoi du message : #{e.message}"
83
+ Chromate::CLogger.log("Error sending WebSocket message: #{e.message}", level: :error)
73
84
  reconnect
74
85
  retry
75
86
  end
76
87
  end
77
88
 
89
+ # @return [self]
78
90
  def reconnect
79
91
  @ws_url = fetch_websocket_debug_url
80
92
  @ws = WebSocket::Client::Simple.connect(@ws_url)
81
- puts 'Reconnexion WebSocket réussie'
93
+ Chromate::CLogger.log('Successfully reconnected to WebSocket')
94
+
95
+ self
82
96
  end
83
97
 
98
+ # Allowing different parts to subscribe to WebSocket messages
99
+ # @yieldparam [Hash] message
100
+ # @return [void]
101
+ def on_message(&block)
102
+ Client.listeners << block
103
+ end
104
+
105
+ private
106
+
107
+ # @param [Hash] message
108
+ # @return [self]
84
109
  def handle_message(message)
85
- puts "Message reçu : #{message}"
110
+ Chromate::CLogger.log("Message received: #{message}", level: :debug)
86
111
  return unless message['id'] && @callbacks[message['id']]
87
112
 
88
113
  @callbacks[message['id']].push(message['result'])
89
114
  @callbacks.delete(message['id'])
90
- end
91
115
 
92
- # Allowing different parts to subscribe to WebSocket messages
93
- def on_message(&block)
94
- Client.listeners << block
116
+ self
95
117
  end
96
118
 
119
+ # @return [String]
97
120
  def fetch_websocket_debug_url
98
- uri = URI("http://localhost:#{@port}/json/list")
99
- response = Net::HTTP.get(uri)
100
- targets = JSON.parse(response)
121
+ retries = 0
122
+ max_retries = 5
123
+ base_delay = 0.5
101
124
 
102
- page_target = targets.find { |target| target['type'] == 'page' }
125
+ begin
126
+ uri = URI("http://localhost:#{@port}/json/list")
127
+ response = Net::HTTP.get(uri)
128
+ targets = JSON.parse(response)
129
+
130
+ page_target = targets.find { |target| target['type'] == 'page' }
131
+ websocket_url = if page_target
132
+ page_target['webSocketDebuggerUrl']
133
+ else
134
+ create_new_page_target
135
+ end
136
+ raise Exceptions::DebugURLError, 'Can\'t get WebSocket URL' if websocket_url.nil?
137
+
138
+ websocket_url
139
+ rescue StandardError => e
140
+ retries += 1
141
+ raise Exceptions::ConnectionTimeoutError, "Can't get WebSocket URL after #{max_retries} retries" if retries >= max_retries
103
142
 
104
- if page_target
105
- page_target['webSocketDebuggerUrl']
106
- else
107
- create_new_page_target
143
+ delay = base_delay * (2**retries) # Exponential delay: 0.5s, 1s, 2s, 4s, 8s
144
+ Chromate::CLogger.log("Attempting to reconnect in #{delay} seconds, #{e.message}", level: :debug)
145
+ sleep delay
146
+ retry
108
147
  end
109
148
  end
110
149
 
150
+ # @return [String]
111
151
  def create_new_page_target
112
- # Créer une nouvelle page
113
152
  uri = URI("http://localhost:#{@port}/json/new")
114
153
  response = Net::HTTP.get(uri)
115
154
  new_target = JSON.parse(response)
@@ -9,25 +9,26 @@ module Chromate
9
9
  include Helpers
10
10
  include Exceptions
11
11
  DEFAULT_ARGS = [
12
- '--no-first-run',
13
- '--no-default-browser-check',
14
- '--disable-blink-features=AutomationControlled',
15
- '--disable-extensions',
16
- '--disable-infobars',
17
- '--no-sandbox',
18
- '--disable-popup-blocking',
19
- '--ignore-certificate-errors',
20
- '--disable-gpu',
21
- '--disable-dev-shm-usage',
12
+ '--no-first-run', # Skip the first run wizard
13
+ '--no-default-browser-check', # Disable the default browser check
14
+ '--disable-blink-features=AutomationControlled', # Disable the AutomationControlled feature
15
+ '--disable-extensions', # Disable extensions
16
+ '--disable-infobars', # Disable the infobar that asks if you want to install Chrome
17
+ '--no-sandbox', # Required for chrome devtools to work
18
+ '--test-type', # Remove the not allowed message for --no-sandbox flag
19
+ '--disable-dev-shm-usage', # Disable /dev/shm usage
20
+ '--disable-popup-blocking', # Disable popup blocking
21
+ '--ignore-certificate-errors', # Ignore certificate errors
22
22
  '--window-size=1920,1080', # TODO: Make this automatic
23
- '--hide-crash-restore-bubble'
23
+ '--hide-crash-restore-bubble' # Hide the crash restore bubble
24
24
  ].freeze
25
25
  HEADLESS_ARGS = [
26
26
  '--headless=new',
27
27
  '--window-position=2400,2400'
28
28
  ].freeze
29
29
  XVFB_ARGS = [
30
- '--window-position=0,0'
30
+ '--window-position=0,0',
31
+ '--start-fullscreen'
31
32
  ].freeze
32
33
  DISABLED_FEATURES = %w[
33
34
  Translate
@@ -45,14 +46,16 @@ module Chromate
45
46
  enable-automation
46
47
  ].freeze
47
48
 
48
- attr_accessor :user_data_dir, :headless, :xfvb, :native_control, :args, :headless_args, :xfvb_args, :exclude_switches, :proxy,
49
- :disable_features
49
+ attr_accessor :user_data_dir, :headless, :xfvb, :native_control, :startup_patch,
50
+ :args, :headless_args, :xfvb_args, :exclude_switches, :proxy, :disable_features,
51
+ :mouse_controller, :keyboard_controller
50
52
 
51
53
  def initialize
52
54
  @user_data_dir = File.expand_path('~/.config/google-chrome/Default')
53
55
  @headless = true
54
56
  @xfvb = false
55
57
  @native_control = false
58
+ @startup_patch = true
56
59
  @proxy = nil
57
60
  @args = [] + DEFAULT_ARGS
58
61
  @headless_args = [] + HEADLESS_ARGS
@@ -63,18 +66,27 @@ module Chromate
63
66
  @args << '--use-angle=metal' if mac?
64
67
  end
65
68
 
69
+ # @return [Chromate::Configuration]
66
70
  def self.config
67
71
  @config ||= Configuration.new
68
72
  end
69
73
 
74
+ # @yield [Chromate::Configuration]
70
75
  def self.configure
71
76
  yield(config)
72
77
  end
73
78
 
79
+ # @return [Chromate::Configuration]
74
80
  def config
75
81
  self.class.config
76
82
  end
77
83
 
84
+ # @return [Boolean]
85
+ def patch?
86
+ @startup_patch
87
+ end
88
+
89
+ # @return [String]
78
90
  def chrome_path
79
91
  return ENV['CHROME_BIN'] if ENV['CHROME_BIN']
80
92
 
@@ -89,6 +101,10 @@ module Chromate
89
101
  end
90
102
  end
91
103
 
104
+ # @option [Boolean] headless
105
+ # @option [Boolean] xfvb
106
+ # @option [Hash] proxy
107
+ # @option [Array<String>] disable_features
92
108
  def generate_arguments(headless: @headless, xfvb: @xfvb, proxy: @proxy, disable_features: @disable_features, **_args)
93
109
  dynamic_args = []
94
110
 
@@ -100,6 +116,7 @@ module Chromate
100
116
  @args + dynamic_args
101
117
  end
102
118
 
119
+ # @return [Hash]
103
120
  def options
104
121
  {
105
122
  chrome_path: chrome_path,
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'chromate/elements/tags'
4
+
3
5
  module Chromate
4
6
  class Element
7
+ include Elements::Tags
8
+
5
9
  class NotFoundError < StandardError
6
10
  def initialize(selector, root_id)
7
11
  super("Element not found with selector: #{selector} under root_id: #{root_id}")
@@ -13,7 +17,7 @@ module Chromate
13
17
  super("Unable to resolve element with selector: #{selector}")
14
18
  end
15
19
  end
16
- attr_reader :selector, :client, :mouse
20
+ attr_reader :selector, :client, :root_id, :object_id, :node_id
17
21
 
18
22
  # @param [String] selector
19
23
  # @param [Chromate::Client] client
@@ -25,37 +29,51 @@ module Chromate
25
29
  @client = client
26
30
  @object_id = object_id
27
31
  @node_id = node_id
28
- @object_id, @node_id = find(selector, root_id) unless @object_id && @node_id
29
- @root_id = root_id || document['root']['nodeId']
30
- @mouse = Hardwares.mouse(client: client, element: self)
32
+ @object_id, @node_id = magick_find(selector, root_id) unless @object_id && @node_id
33
+ @root_id = root_id || document['root']['nodeId']
34
+ end
35
+
36
+ # @return [Chromate::Hardwares::MouseController]
37
+ def mouse
38
+ Chromate.configuration.mouse_controller.set_element(self)
39
+ end
40
+
41
+ # @return [Chromate::Hardwares::KeyboardController]
42
+ def keyboard
43
+ Chromate.configuration.keyboard_controller.set_element(self)
31
44
  end
32
45
 
46
+ # @return [String]
33
47
  def inspect
34
48
  value = selector.length > 20 ? "#{selector[0..20]}..." : selector
35
49
  "#<Chromate::Element:#{value}>"
36
50
  end
37
51
 
52
+ # @return [String]
38
53
  def text
39
- return @text if @text
54
+ evaluate_script('function() { return this.innerText; }')
55
+ end
40
56
 
41
- result = client.send_message('Runtime.callFunctionOn', functionDeclaration: 'function() { return this.innerText; }', objectId: @object_id)
42
- @text = result['result']['value']
57
+ # @return [String]
58
+ def value
59
+ evaluate_script('function() { return this.value; }')
43
60
  end
44
61
 
45
62
  # @return [String]
46
63
  def html
47
- return @html if @html
48
-
49
- @html = client.send_message('DOM.getOuterHTML', objectId: @object_id)
50
- @html = @html['outerHTML']
64
+ html = client.send_message('DOM.getOuterHTML', objectId: @object_id)
65
+ html['outerHTML']
51
66
  end
52
67
 
53
68
  # @return [Hash]
54
69
  def attributes
55
- return @attributes if @attributes
56
-
57
70
  result = client.send_message('DOM.getAttributes', nodeId: @node_id)
58
- @attributes = Hash[*result['attributes']]
71
+ Hash[*result['attributes']]
72
+ end
73
+
74
+ # @return [String]
75
+ def tag_name
76
+ evaluate_script('function() { return this.tagName.toLowerCase(); }')
59
77
  end
60
78
 
61
79
  # @param [String] name
@@ -94,6 +112,13 @@ module Chromate
94
112
  bounding_box['height']
95
113
  end
96
114
 
115
+ # @return [self]
116
+ def focus
117
+ client.send_message('DOM.focus', nodeId: @node_id)
118
+
119
+ self
120
+ end
121
+
97
122
  # @return [self]
98
123
  def click
99
124
  mouse.click
@@ -109,9 +134,24 @@ module Chromate
109
134
  end
110
135
 
111
136
  # @param [String] text
137
+ # @return [self]
112
138
  def type(text)
113
- client.send_message('Runtime.callFunctionOn', functionDeclaration: "function(value) { this.value = value; this.dispatchEvent(new Event('input')); }",
114
- objectId: @object_id, arguments: [{ value: text }])
139
+ focus
140
+ keyboard.type(text)
141
+
142
+ self
143
+ end
144
+
145
+ # @return [self]
146
+ def press_enter
147
+ keyboard.press_key('Enter')
148
+ submit_parent_form
149
+
150
+ self
151
+ end
152
+
153
+ def drop_to(element)
154
+ mouse.drag_and_drop_to(element)
115
155
 
116
156
  self
117
157
  end
@@ -169,8 +209,23 @@ module Chromate
169
209
  end
170
210
  end
171
211
 
212
+ # @param [String] script
213
+ # @return [String]
214
+ def evaluate_script(script, options = {})
215
+ result = client.send_message(
216
+ 'Runtime.callFunctionOn',
217
+ functionDeclaration: script,
218
+ objectId: @object_id,
219
+ returnByValue: true,
220
+ **options
221
+ )
222
+ result['result']['value']
223
+ end
224
+
172
225
  private
173
226
 
227
+ # @param [String] event
228
+ # @return [void]
174
229
  def dispatch_event(event)
175
230
  client.send_message('DOM.dispatchEvent', nodeId: @node_id, type: event)
176
231
  end
@@ -187,8 +242,56 @@ module Chromate
187
242
  [node_info['object']['objectId'], result['nodeId']]
188
243
  end
189
244
 
245
+ # @param [String] selector
246
+ # @option [Integer] root_id
247
+ # @return [Chromate::Element, nil]
248
+ def magick_find(selector, root_id = nil)
249
+ find(selector, root_id)
250
+ rescue NotFoundError, InvalidSelectorError
251
+ el = find_in_shadow_recursively(selector)
252
+ raise NotFoundError.new(selector, @root_id) unless el
253
+
254
+ el
255
+ end
256
+
257
+ # @param [String] selector
258
+ # @return [Chromate::Element, nil]
259
+ def find_in_shadow_recursively(selector)
260
+ shadow_children = find_shadow_children('*')
261
+ shadow_children.each do |child|
262
+ found_element = child.find_element(selector) || child.find_in_shadow_recursively(selector)
263
+ return found_element if found_element
264
+ end
265
+
266
+ nil
267
+ end
268
+
269
+ # @return [Hash]
190
270
  def document
191
271
  @document ||= client.send_message('DOM.getDocument')
192
272
  end
273
+
274
+ # Allows to submit the parent form of the element
275
+ # can be used to submit a form
276
+ #
277
+ # @return [void]
278
+ def submit_parent_form
279
+ script = <<~JAVASCRIPT
280
+ function() {
281
+ const form = this.closest('form');
282
+ if (form) {
283
+ const submitEvent = new Event('submit', {
284
+ bubbles: true,
285
+ cancelable: true
286
+ });
287
+ if (form.dispatchEvent(submitEvent)) {
288
+ form.submit();
289
+ }
290
+ }
291
+ }
292
+ JAVASCRIPT
293
+
294
+ evaluate_script(script)
295
+ end
193
296
  end
194
297
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chromate/element'
4
+
5
+ module Chromate
6
+ module Elements
7
+ class Checkbox < Element
8
+ def initialize(selector, client, **options)
9
+ super
10
+ raise InvalidSelectorError, selector unless checkbox?
11
+ end
12
+
13
+ # @return [Boolean]
14
+ def checked?
15
+ attributes['checked'] == 'true'
16
+ end
17
+
18
+ # @return [self]
19
+ def check
20
+ click unless checked?
21
+
22
+ self
23
+ end
24
+
25
+ # @return [self]
26
+ def uncheck
27
+ click if checked?
28
+
29
+ self
30
+ end
31
+
32
+ # @return [self]
33
+ def toggle
34
+ click
35
+
36
+ self
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chromate/element'
4
+
5
+ module Chromate
6
+ module Elements
7
+ class Option < Element
8
+ attr_reader :value
9
+
10
+ # @param [String] value
11
+ def initialize(value, client, node_id: nil, object_id: nil, root_id: nil)
12
+ super("option[value='#{value}']", client, node_id: node_id, object_id: object_id, root_id: root_id)
13
+
14
+ @value = value
15
+ end
16
+
17
+ def bounding_box
18
+ script = <<~JAVASCRIPT
19
+ function() {
20
+ const select = this.closest('select');
21
+ const rect = select.getBoundingClientRect();
22
+ return {
23
+ x: rect.x,
24
+ y: rect.y,
25
+ width: rect.width,
26
+ height: rect.height
27
+ };
28
+ }
29
+ JAVASCRIPT
30
+
31
+ result = evaluate_script(script)
32
+ # TODO: fix this
33
+ # The offset is due to the fact that the option return the wrong coordinates
34
+ # can be fixed by mesuring an option and use the offset multiply by the index of the option
35
+ {
36
+ 'content' => [result['x'] + 100, result['y'] + 100],
37
+ 'width' => result['width'],
38
+ 'height' => result['height']
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chromate/element'
4
+
5
+ module Chromate
6
+ module Elements
7
+ class Radio < Element
8
+ def initialize(selector = nil, client = nil, **options)
9
+ if selector
10
+ super
11
+ raise InvalidSelectorError, selector unless radio?
12
+ else
13
+ super(**options)
14
+ end
15
+ end
16
+
17
+ # @return [Boolean]
18
+ def checked?
19
+ attributes['checked'] == 'true'
20
+ end
21
+
22
+ # @return [self]
23
+ def check
24
+ click unless checked?
25
+
26
+ self
27
+ end
28
+
29
+ # @return [self]
30
+ def uncheck
31
+ click if checked?
32
+
33
+ self
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,18 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'chromate/element'
4
+ require 'chromate/elements/option'
4
5
 
5
6
  module Chromate
6
7
  module Elements
7
8
  class Select < Element
8
- # @param [String] selector
9
+ # @param [String] value
10
+ # @return [self]
9
11
  def select_option(value)
10
12
  click
11
- opt = find_elements('option').find do |option|
12
- option.attributes['value'] == value
13
- end
14
- opt.set_attribute('selected', 'true')
15
- click
13
+
14
+ evaluate_script(javascript, arguments: [{ value: value }]) unless Chromate.configuration.native_control
15
+
16
+ Option.new(value, client).click
17
+
18
+ self
19
+ end
20
+
21
+ # @return [String|nil]
22
+ def selected_value
23
+ evaluate_script('function() { return this.value; }')
24
+ end
25
+
26
+ # @return [String|nil]
27
+ def selected_text
28
+ evaluate_script('function() {
29
+ const option = this.options[this.selectedIndex];
30
+ return option ? option.textContent.trim() : null;
31
+ }')
32
+ end
33
+
34
+ private
35
+
36
+ # @return [String]
37
+ def javascript
38
+ <<~JAVASCRIPT
39
+ function() {
40
+ this.focus();
41
+ this.dispatchEvent(new MouseEvent('mousedown'));
42
+
43
+ const options = Array.from(this.options);
44
+ const option = options.find(opt =>#{" "}
45
+ opt.value === arguments[0] || opt.textContent.trim() === arguments[0]
46
+ );
47
+
48
+ if (!option) {
49
+ throw new Error(`Option '${arguments[0]}' not found in select`);
50
+ }
51
+
52
+ this.value = option.value;
53
+
54
+ this.dispatchEvent(new Event('change', { bubbles: true }));
55
+ this.dispatchEvent(new Event('input', { bubbles: true }));
56
+
57
+ this.blur();
58
+ }
59
+ JAVASCRIPT
16
60
  end
17
61
  end
18
62
  end