chromate-rb 0.0.1.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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +39 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +124 -0
- data/Rakefile +71 -0
- data/dockerfiles/Dockerfile +85 -0
- data/dockerfiles/docker-entrypoint.sh +15 -0
- data/docs/browser.md +163 -0
- data/lib/chromate/actions/dom.rb +39 -0
- data/lib/chromate/actions/navigate.rb +71 -0
- data/lib/chromate/actions/screenshot.rb +56 -0
- data/lib/chromate/browser.rb +113 -0
- data/lib/chromate/c_logger.rb +22 -0
- data/lib/chromate/client.rb +120 -0
- data/lib/chromate/configuration.rb +119 -0
- data/lib/chromate/element.rb +194 -0
- data/lib/chromate/elements/select.rb +19 -0
- data/lib/chromate/exceptions.rb +9 -0
- data/lib/chromate/hardwares/mouse_controller.rb +67 -0
- data/lib/chromate/hardwares/mouses/linux_controller.rb +79 -0
- data/lib/chromate/hardwares/mouses/mac_os_controller.rb +133 -0
- data/lib/chromate/hardwares/mouses/virtual_controller.rb +81 -0
- data/lib/chromate/hardwares.rb +33 -0
- data/lib/chromate/helpers.rb +23 -0
- data/lib/chromate/user_agent.rb +34 -0
- data/lib/chromate/version.rb +5 -0
- data/lib/chromate.rb +17 -0
- data/results/brotector.png +0 -0
- data/results/cloudflare.png +0 -0
- data/sig/chromate.rbs +4 -0
- metadata +111 -0
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Chromate
|
4
|
+
module Actions
|
5
|
+
module Dom
|
6
|
+
def find_element(selector)
|
7
|
+
Chromate::Element.new(selector, @client)
|
8
|
+
end
|
9
|
+
|
10
|
+
def click_element(selector)
|
11
|
+
find_element(selector).click
|
12
|
+
end
|
13
|
+
|
14
|
+
def hover_element(selector)
|
15
|
+
find_element(selector).hover
|
16
|
+
end
|
17
|
+
|
18
|
+
def type_text(selector, text)
|
19
|
+
find_element(selector).type(text)
|
20
|
+
end
|
21
|
+
|
22
|
+
def get_text(selector)
|
23
|
+
find_element(selector).text
|
24
|
+
end
|
25
|
+
|
26
|
+
def get_property(selector, property)
|
27
|
+
find_element(selector).attributes[property]
|
28
|
+
end
|
29
|
+
|
30
|
+
def select_option(selector, option)
|
31
|
+
Chromate::Elements::Select.new(selector, @client).select_option(option)
|
32
|
+
end
|
33
|
+
|
34
|
+
def evaluate_script(script)
|
35
|
+
@client.send_message('Runtime.evaluate', expression: script)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Chromate
|
4
|
+
module Actions
|
5
|
+
module Navigate
|
6
|
+
# @param [String] url
|
7
|
+
# @return [self]
|
8
|
+
def navigate_to(url)
|
9
|
+
@client.send_message('Page.enable')
|
10
|
+
@client.send_message('Page.navigate', url: url)
|
11
|
+
wait_for_page_load
|
12
|
+
end
|
13
|
+
|
14
|
+
# @return [self]
|
15
|
+
def wait_for_page_load # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
16
|
+
page_loaded = false
|
17
|
+
dom_content_loaded = false
|
18
|
+
frame_stopped_loading = false
|
19
|
+
|
20
|
+
# Utiliser un Mutex pour la synchronisation
|
21
|
+
mutex = Mutex.new
|
22
|
+
condition = ConditionVariable.new
|
23
|
+
|
24
|
+
# S'abonner aux messages WebSocket
|
25
|
+
listener = proc do |message|
|
26
|
+
mutex.synchronize do
|
27
|
+
case message['method']
|
28
|
+
when 'Page.domContentEventFired'
|
29
|
+
dom_content_loaded = true
|
30
|
+
condition.signal if dom_content_loaded && page_loaded && frame_stopped_loading
|
31
|
+
when 'Page.loadEventFired'
|
32
|
+
page_loaded = true
|
33
|
+
condition.signal if dom_content_loaded && page_loaded && frame_stopped_loading
|
34
|
+
when 'Page.frameStoppedLoading'
|
35
|
+
frame_stopped_loading = true
|
36
|
+
condition.signal if dom_content_loaded && page_loaded && frame_stopped_loading
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
@client.on_message(&listener)
|
42
|
+
|
43
|
+
# Attendre les trois événements (DOMContent, Load et FrameStoppedLoading) avec un timeout
|
44
|
+
Timeout.timeout(15) do
|
45
|
+
mutex.synchronize do
|
46
|
+
condition.wait(mutex) until dom_content_loaded && page_loaded && frame_stopped_loading
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Nettoyer l'écouteur WebSocket
|
51
|
+
@client.on_message { |msg| } # Supprime tous les anciens écouteurs en ajoutant un listener vide
|
52
|
+
|
53
|
+
self
|
54
|
+
end
|
55
|
+
|
56
|
+
# @return [self]
|
57
|
+
def refresh
|
58
|
+
@client.send_message('Page.reload')
|
59
|
+
wait_for_page_load
|
60
|
+
self
|
61
|
+
end
|
62
|
+
|
63
|
+
# @return [self]
|
64
|
+
def go_back
|
65
|
+
@client.send_message('Page.goBack')
|
66
|
+
wait_for_page_load
|
67
|
+
self
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Chromate
|
4
|
+
module Actions
|
5
|
+
module Screenshot
|
6
|
+
def screenshot_to_file(file_path, options = {})
|
7
|
+
image_data = screenshot(options)
|
8
|
+
File.binwrite(file_path, image_data)
|
9
|
+
true
|
10
|
+
end
|
11
|
+
|
12
|
+
def screenshot_full_page(file_path, options = {})
|
13
|
+
metrics = @client.send_message('Page.getLayoutMetrics')
|
14
|
+
|
15
|
+
content_size = metrics['contentSize']
|
16
|
+
width = content_size['width'].ceil
|
17
|
+
height = content_size['height'].ceil
|
18
|
+
|
19
|
+
@client.send_message('Emulation.setDeviceMetricsOverride', {
|
20
|
+
mobile: false,
|
21
|
+
width: width,
|
22
|
+
height: height,
|
23
|
+
deviceScaleFactor: 1
|
24
|
+
})
|
25
|
+
|
26
|
+
screenshot_to_file(file_path, options)
|
27
|
+
|
28
|
+
@client.send_message('Emulation.clearDeviceMetricsOverride')
|
29
|
+
true
|
30
|
+
end
|
31
|
+
|
32
|
+
def xvfb_screenshot(file_path)
|
33
|
+
display = ENV['DISPLAY'] || ':99'
|
34
|
+
system("xwd -root -display #{display} | convert xwd:- #{file_path}")
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def screenshot(options = {})
|
40
|
+
default_options = {
|
41
|
+
format: 'png',
|
42
|
+
fromSurface: true
|
43
|
+
}
|
44
|
+
|
45
|
+
params = default_options.merge(options)
|
46
|
+
|
47
|
+
@client.send_message('Page.enable')
|
48
|
+
|
49
|
+
result = @client.send_message('Page.captureScreenshot', params)
|
50
|
+
|
51
|
+
image_data = result['data']
|
52
|
+
Base64.decode64(image_data)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
require 'json'
|
5
|
+
require 'securerandom'
|
6
|
+
require 'net/http'
|
7
|
+
require 'websocket-client-simple'
|
8
|
+
require_relative 'helpers'
|
9
|
+
require_relative 'client'
|
10
|
+
require_relative 'element'
|
11
|
+
require_relative 'hardwares'
|
12
|
+
require_relative 'elements/select'
|
13
|
+
require_relative 'user_agent'
|
14
|
+
require_relative 'actions/navigate'
|
15
|
+
require_relative 'actions/screenshot'
|
16
|
+
require_relative 'actions/dom'
|
17
|
+
|
18
|
+
module Chromate
|
19
|
+
class Browser
|
20
|
+
attr_reader :client, :options
|
21
|
+
|
22
|
+
include Helpers
|
23
|
+
include Actions::Navigate
|
24
|
+
include Actions::Screenshot
|
25
|
+
include Actions::Dom
|
26
|
+
|
27
|
+
def initialize(options = {})
|
28
|
+
@options = config.options.merge(options)
|
29
|
+
@chrome_path = @options.fetch(:chrome_path)
|
30
|
+
@user_data_dir = @options.fetch(:user_data_dir, "/tmp/chromate_#{SecureRandom.hex}")
|
31
|
+
@headless = @options.fetch(:headless)
|
32
|
+
@xfvb = @options.fetch(:xfvb)
|
33
|
+
@native_control = @options.fetch(:native_control)
|
34
|
+
@record = @options.fetch(:record, false)
|
35
|
+
@process = nil
|
36
|
+
@xfvb_process = nil
|
37
|
+
@record_process = nil
|
38
|
+
@client = nil
|
39
|
+
@args = [
|
40
|
+
@chrome_path,
|
41
|
+
"--user-data-dir=#{@user_data_dir}",
|
42
|
+
"--lang=#{@options[:lang] || "fr-FR"}"
|
43
|
+
]
|
44
|
+
|
45
|
+
trap('INT') { stop_and_exit }
|
46
|
+
trap('TERM') { stop_and_exit }
|
47
|
+
|
48
|
+
at_exit { stop }
|
49
|
+
end
|
50
|
+
|
51
|
+
def start
|
52
|
+
build_args
|
53
|
+
@client = Client.new(self)
|
54
|
+
@args << "--remote-debugging-port=#{@client.port}"
|
55
|
+
|
56
|
+
start_video_recording if @record
|
57
|
+
|
58
|
+
if @xfvb
|
59
|
+
if ENV['DISPLAY'].nil?
|
60
|
+
ENV['DISPLAY'] = ':0' if mac? # XQuartz generally uses :0 on Mac
|
61
|
+
ENV['DISPLAY'] = ':99' if linux? # Xvfb generally uses :99 on Linux
|
62
|
+
end
|
63
|
+
@args << "--display=#{ENV.fetch("DISPLAY", nil)}"
|
64
|
+
end
|
65
|
+
|
66
|
+
@process = spawn(*@args, err: 'chrome_errors.log', out: 'chrome_output.log')
|
67
|
+
sleep 2
|
68
|
+
|
69
|
+
@client.start
|
70
|
+
self
|
71
|
+
end
|
72
|
+
|
73
|
+
def stop
|
74
|
+
Process.kill('TERM', @process) if @process
|
75
|
+
Process.kill('TERM', @record_process) if @record_process
|
76
|
+
Process.kill('TERM', @xfvb_process) if @xfvb_process
|
77
|
+
@client&.stop
|
78
|
+
end
|
79
|
+
|
80
|
+
def native_control?
|
81
|
+
@native_control
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
def start_video_recording
|
87
|
+
outfile = File.join(Dir.pwd, "output_video_#{Time.now.to_i}.mp4")
|
88
|
+
@record_process = spawn("ffmpeg -f x11grab -r 25 -s 1920x1080 -i #{ENV.fetch("DISPLAY", ":99")} -pix_fmt yuv420p -y #{outfile}")
|
89
|
+
end
|
90
|
+
|
91
|
+
def build_args
|
92
|
+
exclude_switches = config.exclude_switches || []
|
93
|
+
exclude_switches += @options[:exclude_switches] if @options[:exclude_switches]
|
94
|
+
|
95
|
+
@args += config.generate_arguments(**@options)
|
96
|
+
@args += @options[:options][:args] if @options.dig(:options, :args)
|
97
|
+
@args << "--user-agent=#{@options[:user_agent] || UserAgent.call}"
|
98
|
+
@args << "--exclude-switches=#{exclude_switches.join(",")}" if exclude_switches.any?
|
99
|
+
|
100
|
+
@args
|
101
|
+
end
|
102
|
+
|
103
|
+
def stop_and_exit
|
104
|
+
puts 'Stopping browser...'
|
105
|
+
stop
|
106
|
+
exit
|
107
|
+
end
|
108
|
+
|
109
|
+
def config
|
110
|
+
Chromate.configuration
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
|
5
|
+
module Chromate
|
6
|
+
class CLogger < Logger
|
7
|
+
def initialize(logdev, shift_age: 0, shift_size: 1_048_576)
|
8
|
+
super(logdev, shift_age, shift_size)
|
9
|
+
self.formatter = proc do |severity, datetime, _progname, msg|
|
10
|
+
"[Chromate] #{datetime.strftime("%Y-%m-%d %H:%M:%S")} #{severity}: #{msg}\n"
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.logger
|
15
|
+
@logger ||= new($stdout)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.log(message, level: :info)
|
19
|
+
logger.send(level, message)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'websocket-client-simple'
|
4
|
+
require 'chromate/helpers'
|
5
|
+
|
6
|
+
module Chromate
|
7
|
+
class Client
|
8
|
+
include Helpers
|
9
|
+
|
10
|
+
def self.listeners
|
11
|
+
@@listeners ||= [] # rubocop:disable Style/ClassVars
|
12
|
+
end
|
13
|
+
|
14
|
+
attr_reader :port, :ws, :browser
|
15
|
+
|
16
|
+
def initialize(browser)
|
17
|
+
@browser = browser
|
18
|
+
options = browser.options
|
19
|
+
@port = options[:port] || find_available_port
|
20
|
+
end
|
21
|
+
|
22
|
+
def start
|
23
|
+
@ws_url = fetch_websocket_debug_url
|
24
|
+
@ws = WebSocket::Client::Simple.connect(@ws_url)
|
25
|
+
@id = 0
|
26
|
+
@callbacks = {}
|
27
|
+
|
28
|
+
client_self = self
|
29
|
+
|
30
|
+
@ws.on :message do |msg|
|
31
|
+
message = JSON.parse(msg.data)
|
32
|
+
client_self.handle_message(message)
|
33
|
+
|
34
|
+
Client.listeners.each do |listener|
|
35
|
+
listener.call(message)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
@ws.on :open do
|
40
|
+
puts "Connexion WebSocket établie avec #{@ws_url}"
|
41
|
+
end
|
42
|
+
|
43
|
+
@ws.on :error do |e|
|
44
|
+
puts "Erreur WebSocket : #{e.message}"
|
45
|
+
end
|
46
|
+
|
47
|
+
@ws.on :close do |_e|
|
48
|
+
puts 'Connexion WebSocket fermée'
|
49
|
+
end
|
50
|
+
|
51
|
+
sleep 0.2
|
52
|
+
client_self.send_message('Target.setDiscoverTargets', { discover: true })
|
53
|
+
client_self
|
54
|
+
end
|
55
|
+
|
56
|
+
def stop
|
57
|
+
@ws&.close
|
58
|
+
end
|
59
|
+
|
60
|
+
def send_message(method, params = {})
|
61
|
+
@id += 1
|
62
|
+
message = { id: @id, method: method, params: params }
|
63
|
+
puts "Envoi du message : #{message}"
|
64
|
+
|
65
|
+
begin
|
66
|
+
@ws.send(message.to_json)
|
67
|
+
@callbacks[@id] = Queue.new
|
68
|
+
result = @callbacks[@id].pop
|
69
|
+
puts "Réponse reçue pour le message #{message[:id]} : #{result}"
|
70
|
+
result
|
71
|
+
rescue StandardError => e
|
72
|
+
puts "Erreur WebSocket lors de l'envoi du message : #{e.message}"
|
73
|
+
reconnect
|
74
|
+
retry
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def reconnect
|
79
|
+
@ws_url = fetch_websocket_debug_url
|
80
|
+
@ws = WebSocket::Client::Simple.connect(@ws_url)
|
81
|
+
puts 'Reconnexion WebSocket réussie'
|
82
|
+
end
|
83
|
+
|
84
|
+
def handle_message(message)
|
85
|
+
puts "Message reçu : #{message}"
|
86
|
+
return unless message['id'] && @callbacks[message['id']]
|
87
|
+
|
88
|
+
@callbacks[message['id']].push(message['result'])
|
89
|
+
@callbacks.delete(message['id'])
|
90
|
+
end
|
91
|
+
|
92
|
+
# Allowing different parts to subscribe to WebSocket messages
|
93
|
+
def on_message(&block)
|
94
|
+
Client.listeners << block
|
95
|
+
end
|
96
|
+
|
97
|
+
def fetch_websocket_debug_url
|
98
|
+
uri = URI("http://localhost:#{@port}/json/list")
|
99
|
+
response = Net::HTTP.get(uri)
|
100
|
+
targets = JSON.parse(response)
|
101
|
+
|
102
|
+
page_target = targets.find { |target| target['type'] == 'page' }
|
103
|
+
|
104
|
+
if page_target
|
105
|
+
page_target['webSocketDebuggerUrl']
|
106
|
+
else
|
107
|
+
create_new_page_target
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def create_new_page_target
|
112
|
+
# Créer une nouvelle page
|
113
|
+
uri = URI("http://localhost:#{@port}/json/new")
|
114
|
+
response = Net::HTTP.get(uri)
|
115
|
+
new_target = JSON.parse(response)
|
116
|
+
|
117
|
+
new_target['webSocketDebuggerUrl']
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'helpers'
|
4
|
+
require_relative 'exceptions'
|
5
|
+
require_relative 'c_logger'
|
6
|
+
|
7
|
+
module Chromate
|
8
|
+
class Configuration
|
9
|
+
include Helpers
|
10
|
+
include Exceptions
|
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',
|
22
|
+
'--window-size=1920,1080', # TODO: Make this automatic
|
23
|
+
'--hide-crash-restore-bubble'
|
24
|
+
].freeze
|
25
|
+
HEADLESS_ARGS = [
|
26
|
+
'--headless=new',
|
27
|
+
'--window-position=2400,2400'
|
28
|
+
].freeze
|
29
|
+
XVFB_ARGS = [
|
30
|
+
'--window-position=0,0'
|
31
|
+
].freeze
|
32
|
+
DISABLED_FEATURES = %w[
|
33
|
+
Translate
|
34
|
+
OptimizationHints
|
35
|
+
MediaRouter
|
36
|
+
DialMediaRouteProvider
|
37
|
+
CalculateNativeWinOcclusion
|
38
|
+
InterestFeedContentSuggestions
|
39
|
+
CertificateTransparencyComponentUpdater
|
40
|
+
AutofillServerCommunication
|
41
|
+
PrivacySandboxSettings4
|
42
|
+
AutomationControlled
|
43
|
+
].freeze
|
44
|
+
EXCLUDE_SWITCHES = %w[
|
45
|
+
enable-automation
|
46
|
+
].freeze
|
47
|
+
|
48
|
+
attr_accessor :user_data_dir, :headless, :xfvb, :native_control, :args, :headless_args, :xfvb_args, :exclude_switches, :proxy,
|
49
|
+
:disable_features
|
50
|
+
|
51
|
+
def initialize
|
52
|
+
@user_data_dir = File.expand_path('~/.config/google-chrome/Default')
|
53
|
+
@headless = true
|
54
|
+
@xfvb = false
|
55
|
+
@native_control = false
|
56
|
+
@proxy = nil
|
57
|
+
@args = [] + DEFAULT_ARGS
|
58
|
+
@headless_args = [] + HEADLESS_ARGS
|
59
|
+
@xfvb_args = [] + XVFB_ARGS
|
60
|
+
@disable_features = [] + DISABLED_FEATURES
|
61
|
+
@exclude_switches = [] + EXCLUDE_SWITCHES
|
62
|
+
|
63
|
+
@args << '--use-angle=metal' if mac?
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.config
|
67
|
+
@config ||= Configuration.new
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.configure
|
71
|
+
yield(config)
|
72
|
+
end
|
73
|
+
|
74
|
+
def config
|
75
|
+
self.class.config
|
76
|
+
end
|
77
|
+
|
78
|
+
def chrome_path
|
79
|
+
return ENV['CHROME_BIN'] if ENV['CHROME_BIN']
|
80
|
+
|
81
|
+
if mac?
|
82
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
83
|
+
elsif linux?
|
84
|
+
'/usr/bin/google-chrome-stable'
|
85
|
+
elsif windows?
|
86
|
+
'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe'
|
87
|
+
else
|
88
|
+
raise Exceptions::InvalidPlatformError, 'Unsupported platform'
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def generate_arguments(headless: @headless, xfvb: @xfvb, proxy: @proxy, disable_features: @disable_features, **_args)
|
93
|
+
dynamic_args = []
|
94
|
+
|
95
|
+
dynamic_args += @headless_args if headless
|
96
|
+
dynamic_args += @xfvb_args if xfvb
|
97
|
+
dynamic_args << "--proxy-server=#{proxy[:host]}:#{proxy[:port]}" if proxy && proxy[:host] && proxy[:port]
|
98
|
+
dynamic_args << "--disable-features=#{disable_features.join(",")}" unless disable_features.empty?
|
99
|
+
|
100
|
+
@args + dynamic_args
|
101
|
+
end
|
102
|
+
|
103
|
+
def options
|
104
|
+
{
|
105
|
+
chrome_path: chrome_path,
|
106
|
+
user_data_dir: @user_data_dir,
|
107
|
+
headless: @headless,
|
108
|
+
xfvb: @xfvb,
|
109
|
+
native_control: @native_control,
|
110
|
+
args: @args,
|
111
|
+
headless_args: @headless_args,
|
112
|
+
xfvb_args: @xfvb_args,
|
113
|
+
exclude_switches: @exclude_switches,
|
114
|
+
proxy: @proxy,
|
115
|
+
disable_features: @disable_features
|
116
|
+
}
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|