capybara-playwright-driver 0.0.0.pre.r1
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/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +43 -0
- data/Rakefile +3 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/capybara-playwright.gemspec +38 -0
- data/lib/capybara-playwright-driver.rb +2 -0
- data/lib/capybara/playwright.rb +13 -0
- data/lib/capybara/playwright/browser.rb +319 -0
- data/lib/capybara/playwright/browser_options.rb +32 -0
- data/lib/capybara/playwright/dialog_event_handler.rb +58 -0
- data/lib/capybara/playwright/driver.rb +102 -0
- data/lib/capybara/playwright/node.rb +839 -0
- data/lib/capybara/playwright/page.rb +180 -0
- data/lib/capybara/playwright/page_options.rb +44 -0
- data/lib/capybara/playwright/version.rb +7 -0
- metadata +216 -0
@@ -0,0 +1,32 @@
|
|
1
|
+
module Capybara
|
2
|
+
module Playwright
|
3
|
+
class BrowserOptions
|
4
|
+
def initialize(options)
|
5
|
+
@options = options
|
6
|
+
end
|
7
|
+
|
8
|
+
LAUNCH_PARAMS = {
|
9
|
+
args: nil,
|
10
|
+
channel: nil,
|
11
|
+
chromiumSandbox: nil,
|
12
|
+
devtools: nil,
|
13
|
+
downloadsPath: nil,
|
14
|
+
env: nil,
|
15
|
+
executablePath: nil,
|
16
|
+
firefoxUserPrefs: nil,
|
17
|
+
handleSIGHUP: nil,
|
18
|
+
handleSIGINT: nil,
|
19
|
+
handleSIGTERM: nil,
|
20
|
+
headless: nil,
|
21
|
+
ignoreDefaultArgs: nil,
|
22
|
+
proxy: nil,
|
23
|
+
slowMo: nil,
|
24
|
+
timeout: nil,
|
25
|
+
}.keys
|
26
|
+
|
27
|
+
def value
|
28
|
+
@options.select { |k, _| LAUNCH_PARAMS.include?(k) }
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
|
3
|
+
module Capybara
|
4
|
+
module Playwright
|
5
|
+
# LILO event handler
|
6
|
+
class DialogEventHandler
|
7
|
+
class Item
|
8
|
+
def initialize(dialog_proc)
|
9
|
+
@id = SecureRandom.uuid
|
10
|
+
@proc = dialog_proc
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_reader :id
|
14
|
+
|
15
|
+
def call(dialog)
|
16
|
+
@proc.call(dialog)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def initialize
|
21
|
+
@handlers = []
|
22
|
+
@mutex = Mutex.new
|
23
|
+
end
|
24
|
+
|
25
|
+
attr_writer :default_handler
|
26
|
+
|
27
|
+
def add_handler(callable)
|
28
|
+
item = Item.new(callable)
|
29
|
+
@mutex.synchronize {
|
30
|
+
@handlers << item
|
31
|
+
}
|
32
|
+
item.id
|
33
|
+
end
|
34
|
+
|
35
|
+
def remove_handler(id)
|
36
|
+
@mutex.synchronize {
|
37
|
+
@handlers.reject! { |item| item.id == id }
|
38
|
+
}
|
39
|
+
end
|
40
|
+
|
41
|
+
def with_handler(callable, &block)
|
42
|
+
id = add_handler(callable)
|
43
|
+
begin
|
44
|
+
block.call
|
45
|
+
ensure
|
46
|
+
remove_handler(id)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def handle_dialog(dialog)
|
51
|
+
handler = @mutex.synchronize {
|
52
|
+
@handlers.pop || @default_handler
|
53
|
+
}
|
54
|
+
handler&.call(dialog)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
module Capybara
|
2
|
+
module Playwright
|
3
|
+
class Driver < ::Capybara::Driver::Base
|
4
|
+
extend Forwardable
|
5
|
+
|
6
|
+
def initialize(app, **options)
|
7
|
+
@playwright_cli_executable_path = options[:playwright_cli_executable_path] || 'npx playwright'
|
8
|
+
@browser_type = options[:browser_type] || :chromium
|
9
|
+
unless %i(chromium firefox webkit).include?(@browser_type)
|
10
|
+
raise ArgumentError.new("Unknown browser_type: #{@browser_type}")
|
11
|
+
end
|
12
|
+
|
13
|
+
@browser_options = BrowserOptions.new(options)
|
14
|
+
@page_options = PageOptions.new(options)
|
15
|
+
end
|
16
|
+
|
17
|
+
def wait?; true; end
|
18
|
+
def needs_server?; true; end
|
19
|
+
|
20
|
+
def browser
|
21
|
+
@browser ||= create_browser
|
22
|
+
end
|
23
|
+
|
24
|
+
private def create_browser
|
25
|
+
main = Process.pid
|
26
|
+
at_exit do
|
27
|
+
# Store the exit status of the test run since it goes away after calling the at_exit proc...
|
28
|
+
@exit_status = $ERROR_INFO.status if $ERROR_INFO.is_a?(SystemExit)
|
29
|
+
quit if Process.pid == main
|
30
|
+
exit @exit_status if @exit_status # Force exit with stored status
|
31
|
+
end
|
32
|
+
|
33
|
+
@execution = execute_playwright
|
34
|
+
::Capybara::Playwright::Browser.new(
|
35
|
+
playwright: @execution.playwright,
|
36
|
+
driver: self,
|
37
|
+
browser_type: @browser_type,
|
38
|
+
browser_options: @browser_options.value,
|
39
|
+
page_options: @page_options.value,
|
40
|
+
)
|
41
|
+
end
|
42
|
+
|
43
|
+
private def execute_playwright
|
44
|
+
::Playwright.create(playwright_cli_executable_path: @playwright_cli_executable_path)
|
45
|
+
end
|
46
|
+
|
47
|
+
private def quit
|
48
|
+
@browser&.quit
|
49
|
+
@execution&.stop
|
50
|
+
end
|
51
|
+
|
52
|
+
def reset!
|
53
|
+
quit
|
54
|
+
@browser = nil
|
55
|
+
end
|
56
|
+
|
57
|
+
def invalid_element_errors
|
58
|
+
@invalid_element_errors ||= [
|
59
|
+
Node::NotActionableError,
|
60
|
+
Node::StaleReferenceError,
|
61
|
+
].freeze
|
62
|
+
end
|
63
|
+
|
64
|
+
def no_such_window_error
|
65
|
+
Browser::NoSuchWindowError
|
66
|
+
end
|
67
|
+
|
68
|
+
# ref: https://github.com/teamcapybara/capybara/blob/master/lib/capybara/driver/base.rb
|
69
|
+
def_delegator(:browser, :current_url)
|
70
|
+
def_delegator(:browser, :visit)
|
71
|
+
def_delegator(:browser, :refresh)
|
72
|
+
def_delegator(:browser, :find_xpath)
|
73
|
+
def_delegator(:browser, :find_css)
|
74
|
+
def_delegator(:browser, :title)
|
75
|
+
def_delegator(:browser, :html)
|
76
|
+
def_delegator(:browser, :go_back)
|
77
|
+
def_delegator(:browser, :go_forward)
|
78
|
+
def_delegator(:browser, :execute_script)
|
79
|
+
def_delegator(:browser, :evaluate_script)
|
80
|
+
def_delegator(:browser, :evaluate_async_script)
|
81
|
+
def_delegator(:browser, :save_screenshot)
|
82
|
+
def_delegator(:browser, :response_headers)
|
83
|
+
def_delegator(:browser, :status_code)
|
84
|
+
def_delegator(:browser, :send_keys)
|
85
|
+
def_delegator(:browser, :switch_to_frame)
|
86
|
+
def_delegator(:browser, :current_window_handle)
|
87
|
+
def_delegator(:browser, :window_size)
|
88
|
+
def_delegator(:browser, :resize_window_to)
|
89
|
+
def_delegator(:browser, :maximize_window)
|
90
|
+
def_delegator(:browser, :fullscreen_window)
|
91
|
+
def_delegator(:browser, :close_window)
|
92
|
+
def_delegator(:browser, :window_handles)
|
93
|
+
def_delegator(:browser, :open_new_window)
|
94
|
+
def_delegator(:browser, :switch_to_window)
|
95
|
+
def_delegator(:browser, :accept_modal)
|
96
|
+
def_delegator(:browser, :dismiss_modal)
|
97
|
+
|
98
|
+
# capybara-playwright-driver specific methods
|
99
|
+
def_delegator(:browser, :with_playwright_page)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,839 @@
|
|
1
|
+
module Capybara
|
2
|
+
module ElementClickOptionPatch
|
3
|
+
def perform_click_action(keys, **options)
|
4
|
+
# Expose `wait` value to the block given to perform_click_action.
|
5
|
+
if options[:wait].is_a?(Numeric)
|
6
|
+
options[:_playwright_wait] = options[:wait]
|
7
|
+
end
|
8
|
+
|
9
|
+
# Playwright has own auto-waiting feature.
|
10
|
+
# So disable Capybara's retry logic.
|
11
|
+
options[:wait] = 0
|
12
|
+
super
|
13
|
+
end
|
14
|
+
end
|
15
|
+
Node::Element.prepend(ElementClickOptionPatch)
|
16
|
+
|
17
|
+
module WithElementHandlePatch
|
18
|
+
def with_playwright_element_handle(&block)
|
19
|
+
raise ArgumentError.new('block must be given') unless block
|
20
|
+
|
21
|
+
if native.is_a?(::Playwright::ElementHandle)
|
22
|
+
block.call(native)
|
23
|
+
else
|
24
|
+
raise "#{native.inspect} is not a Playwirght::ElementHandle"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
Node::Element.prepend(WithElementHandlePatch)
|
29
|
+
|
30
|
+
module CapybaraObscuredPatch
|
31
|
+
# ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/lib/capybara/selenium/node.rb#L523
|
32
|
+
OBSCURED_OR_OFFSET_SCRIPT = <<~JAVASCRIPT
|
33
|
+
(el, [x, y]) => {
|
34
|
+
var box = el.getBoundingClientRect();
|
35
|
+
if (!x && x != 0) x = box.width/2;
|
36
|
+
if (!y && y != 0) y = box.height/2;
|
37
|
+
var px = box.left + x,
|
38
|
+
py = box.top + y,
|
39
|
+
e = document.elementFromPoint(px, py);
|
40
|
+
if (!el.contains(e))
|
41
|
+
return true;
|
42
|
+
return { x: px, y: py };
|
43
|
+
}
|
44
|
+
JAVASCRIPT
|
45
|
+
|
46
|
+
def capybara_obscured?(x: nil, y: nil)
|
47
|
+
res = evaluate(OBSCURED_OR_OFFSET_SCRIPT, arg: [x, y])
|
48
|
+
return true if res == true
|
49
|
+
|
50
|
+
# ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/lib/capybara/selenium/driver.rb#L182
|
51
|
+
frame = owner_frame
|
52
|
+
return false unless frame.parent_frame
|
53
|
+
|
54
|
+
frame.frame_element.capybara_obscured?(x: res['x'], y: res['y'])
|
55
|
+
end
|
56
|
+
end
|
57
|
+
::Playwright::ElementHandle.prepend(CapybaraObscuredPatch)
|
58
|
+
|
59
|
+
module Playwright
|
60
|
+
# Selector and checking methods are derived from twapole/apparition
|
61
|
+
# Action methods (click, select_option, ...) uses playwright.
|
62
|
+
#
|
63
|
+
# ref:
|
64
|
+
# selenium: https://github.com/teamcapybara/capybara/blob/master/lib/capybara/selenium/node.rb
|
65
|
+
# apparition: https://github.com/twalpole/apparition/blob/master/lib/capybara/apparition/node.rb
|
66
|
+
class Node < ::Capybara::Driver::Node
|
67
|
+
def initialize(driver, page, element)
|
68
|
+
super(driver, element)
|
69
|
+
@page = page
|
70
|
+
@element = element
|
71
|
+
end
|
72
|
+
|
73
|
+
protected def element
|
74
|
+
@element
|
75
|
+
end
|
76
|
+
|
77
|
+
private def assert_element_not_stale
|
78
|
+
# Playwright checks the staled state only when
|
79
|
+
# actionable methods. (click, select_option, hover, ...)
|
80
|
+
# Capybara expects stale checking also when getting inner text, and so on.
|
81
|
+
@element.enabled?
|
82
|
+
rescue ::Playwright::Error => err
|
83
|
+
case err.message
|
84
|
+
when /Element is not attached to the DOM/
|
85
|
+
raise StaleReferenceError.new(err)
|
86
|
+
when /Cannot find context with specified id/
|
87
|
+
raise StaleReferenceError.new(err)
|
88
|
+
when /Unable to adopt element handle from a different document/ # for WebKit.
|
89
|
+
raise StaleReferenceError.new(err)
|
90
|
+
when /error in channel "content::page": exception while running method "adoptNode"/ # for Firefox
|
91
|
+
raise StaleReferenceError.new(err)
|
92
|
+
else
|
93
|
+
raise
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
private def capybara_default_wait_time
|
98
|
+
Capybara.default_max_wait_time * 1100 # with 10% buffer for allowing overhead.
|
99
|
+
end
|
100
|
+
|
101
|
+
class NotActionableError < StandardError ; end
|
102
|
+
class StaleReferenceError < StandardError ; end
|
103
|
+
|
104
|
+
def all_text
|
105
|
+
assert_element_not_stale
|
106
|
+
|
107
|
+
text = @element.text_content
|
108
|
+
text.to_s.gsub(/[\u200b\u200e\u200f]/, '')
|
109
|
+
.gsub(/[\ \n\f\t\v\u2028\u2029]+/, ' ')
|
110
|
+
.gsub(/\A[[:space:]&&[^\u00a0]]+/, '')
|
111
|
+
.gsub(/[[:space:]&&[^\u00a0]]+\z/, '')
|
112
|
+
.tr("\u00a0", ' ')
|
113
|
+
end
|
114
|
+
|
115
|
+
def visible_text
|
116
|
+
assert_element_not_stale
|
117
|
+
|
118
|
+
return '' unless visible?
|
119
|
+
|
120
|
+
text = @element.evaluate(<<~JAVASCRIPT)
|
121
|
+
function(el){
|
122
|
+
if (el.nodeName == 'TEXTAREA'){
|
123
|
+
return el.textContent;
|
124
|
+
} else if (el instanceof SVGElement) {
|
125
|
+
return el.textContent;
|
126
|
+
} else {
|
127
|
+
return el.innerText;
|
128
|
+
}
|
129
|
+
}
|
130
|
+
JAVASCRIPT
|
131
|
+
text.to_s.gsub(/\A[[:space:]&&[^\u00a0]]+/, '')
|
132
|
+
.gsub(/[[:space:]&&[^\u00a0]]+\z/, '')
|
133
|
+
.gsub(/\n+/, "\n")
|
134
|
+
.tr("\u00a0", ' ')
|
135
|
+
end
|
136
|
+
|
137
|
+
def [](name)
|
138
|
+
assert_element_not_stale
|
139
|
+
|
140
|
+
property(name) || attribute(name)
|
141
|
+
end
|
142
|
+
|
143
|
+
private def property(name)
|
144
|
+
value = @element.get_property(name)
|
145
|
+
value.evaluate("value => ['object', 'function'].includes(typeof value) ? null : value")
|
146
|
+
end
|
147
|
+
|
148
|
+
private def attribute(name)
|
149
|
+
@element.get_attribute(name)
|
150
|
+
end
|
151
|
+
|
152
|
+
def value
|
153
|
+
assert_element_not_stale
|
154
|
+
|
155
|
+
# ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/lib/capybara/selenium/node.rb#L31
|
156
|
+
# ref: https://github.com/twalpole/apparition/blob/11aca464b38b77585191b7e302be2e062bdd369d/lib/capybara/apparition/node.rb#L728
|
157
|
+
if tag_name == 'select' && @element.evaluate('el => el.multiple')
|
158
|
+
@element.query_selector_all('option:checked').map do |option|
|
159
|
+
option.evaluate('el => el.value')
|
160
|
+
end
|
161
|
+
else
|
162
|
+
@element.evaluate('el => el.value')
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def style(styles)
|
167
|
+
# Capybara provides default implementation.
|
168
|
+
# ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/lib/capybara/node/element.rb#L92
|
169
|
+
raise NotImplementedError
|
170
|
+
end
|
171
|
+
|
172
|
+
# @param value [String, Array] Array is only allowed if node has 'multiple' attribute
|
173
|
+
# @param options [Hash] Driver specific options for how to set a value on a node
|
174
|
+
def set(value, **options)
|
175
|
+
settable_class =
|
176
|
+
case tag_name
|
177
|
+
when 'input'
|
178
|
+
case attribute('type')
|
179
|
+
when 'radio'
|
180
|
+
RadioButton
|
181
|
+
when 'checkbox'
|
182
|
+
Checkbox
|
183
|
+
when 'file'
|
184
|
+
FileUpload
|
185
|
+
when 'date'
|
186
|
+
DateInput
|
187
|
+
when 'time'
|
188
|
+
TimeInput
|
189
|
+
when 'datetime-local'
|
190
|
+
DateTimeInput
|
191
|
+
when 'color'
|
192
|
+
JSValueInput
|
193
|
+
when 'range'
|
194
|
+
JSValueInput
|
195
|
+
else
|
196
|
+
TextInput
|
197
|
+
end
|
198
|
+
when 'textarea'
|
199
|
+
TextInput
|
200
|
+
else
|
201
|
+
if @element.editable?
|
202
|
+
TextInput
|
203
|
+
else
|
204
|
+
raise NotSupportedByDriverError
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
settable_class.new(@element, capybara_default_wait_time).set(value, **options)
|
209
|
+
rescue ::Playwright::TimeoutError => err
|
210
|
+
raise NotActionableError.new(err)
|
211
|
+
end
|
212
|
+
|
213
|
+
class Settable
|
214
|
+
def initialize(element, timeout)
|
215
|
+
@element = element
|
216
|
+
@timeout = timeout
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
class RadioButton < Settable
|
221
|
+
def set(_, **options)
|
222
|
+
@element.check(timeout: @timeout)
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
class Checkbox < Settable
|
227
|
+
def set(value, **options)
|
228
|
+
if value
|
229
|
+
@element.check(timeout: @timeout)
|
230
|
+
else
|
231
|
+
@element.uncheck(timeout: @timeout)
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
class TextInput < Settable
|
237
|
+
def set(value, **options)
|
238
|
+
@element.fill(value.to_s, timeout: @timeout)
|
239
|
+
rescue ::Playwright::TimeoutError
|
240
|
+
raise if @element.editable?
|
241
|
+
|
242
|
+
puts "[INFO] Node#set: element is not editable. #{@element}"
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
class FileUpload < Settable
|
247
|
+
def set(value, **options)
|
248
|
+
@element.set_input_files(value, timeout: @timeout)
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
module UpdateValueJS
|
253
|
+
def update_value_js(element, value)
|
254
|
+
# ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/lib/capybara/selenium/node.rb#L343
|
255
|
+
js = <<~JAVASCRIPT
|
256
|
+
(el, value) => {
|
257
|
+
if (el.readOnly) { return };
|
258
|
+
if (document.activeElement !== el){
|
259
|
+
el.focus();
|
260
|
+
}
|
261
|
+
if (el.value != value) {
|
262
|
+
el.value = value;
|
263
|
+
el.dispatchEvent(new InputEvent('input'));
|
264
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
265
|
+
}
|
266
|
+
}
|
267
|
+
JAVASCRIPT
|
268
|
+
element.evaluate(js, arg: value)
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
class DateInput < Settable
|
273
|
+
include UpdateValueJS
|
274
|
+
|
275
|
+
def set(value, **options)
|
276
|
+
if !value.is_a?(String) && value.respond_to?(:to_date)
|
277
|
+
update_value_js(@element, value.to_date.iso8601)
|
278
|
+
else
|
279
|
+
@element.fill(value.to_s, timeout: @timeout)
|
280
|
+
end
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
class TimeInput < Settable
|
285
|
+
include UpdateValueJS
|
286
|
+
|
287
|
+
def set(value, **options)
|
288
|
+
if !value.is_a?(String) && value.respond_to?(:to_time)
|
289
|
+
update_value_js(@element, value.to_time.strftime('%H:%M'))
|
290
|
+
else
|
291
|
+
@element.fill(value.to_s, timeout: @timeout)
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
|
296
|
+
class DateTimeInput < Settable
|
297
|
+
include UpdateValueJS
|
298
|
+
|
299
|
+
def set(value, **options)
|
300
|
+
if !value.is_a?(String) && value.respond_to?(:to_time)
|
301
|
+
update_value_js(@element, value.to_time.strftime('%Y-%m-%dT%H:%M'))
|
302
|
+
else
|
303
|
+
@element.fill(value.to_s, timeout: @timeout)
|
304
|
+
end
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
class JSValueInput < Settable
|
309
|
+
include UpdateValueJS
|
310
|
+
|
311
|
+
def set(value, **options)
|
312
|
+
update_value_js(@element, value)
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
def select_option
|
317
|
+
return false if disabled?
|
318
|
+
|
319
|
+
select_element = parent_select_element
|
320
|
+
if select_element.evaluate('el => el.multiple')
|
321
|
+
selected_options = select_element.query_selector_all('option:checked')
|
322
|
+
selected_options << @element
|
323
|
+
select_element.select_option(element: selected_options, timeout: capybara_default_wait_time)
|
324
|
+
else
|
325
|
+
select_element.select_option(element: @element, timeout: capybara_default_wait_time)
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
def unselect_option
|
330
|
+
if parent_select_element.evaluate('el => el.multiple')
|
331
|
+
return false if disabled?
|
332
|
+
|
333
|
+
@element.evaluate('el => el.selected = false')
|
334
|
+
else
|
335
|
+
raise Capybara::UnselectNotAllowed, 'Cannot unselect option from single select box.'
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
private def parent_select_element
|
340
|
+
@element.query_selector('xpath=ancestor::select')
|
341
|
+
end
|
342
|
+
|
343
|
+
def click(keys = [], **options)
|
344
|
+
click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time)
|
345
|
+
@element.click(**click_options.as_params)
|
346
|
+
end
|
347
|
+
|
348
|
+
def right_click(keys = [], **options)
|
349
|
+
click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time)
|
350
|
+
params = click_options.as_params
|
351
|
+
params[:button] = 'right'
|
352
|
+
@element.click(**params)
|
353
|
+
end
|
354
|
+
|
355
|
+
def double_click(keys = [], **options)
|
356
|
+
click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time)
|
357
|
+
@element.dblclick(**click_options.as_params)
|
358
|
+
end
|
359
|
+
|
360
|
+
class ClickOptions
|
361
|
+
def initialize(element, keys, options, default_timeout)
|
362
|
+
@element = element
|
363
|
+
@modifiers = keys.map do |key|
|
364
|
+
MODIFIERS[key.to_sym] or raise ArgumentError.new("Unknown modifier key: #{key}")
|
365
|
+
end
|
366
|
+
if options[:x] && options[:y]
|
367
|
+
@coords = {
|
368
|
+
x: options[:x],
|
369
|
+
y: options[:y],
|
370
|
+
}
|
371
|
+
@offset_center = options[:offset] == :center
|
372
|
+
end
|
373
|
+
@wait = options[:_playwright_wait]
|
374
|
+
@delay = options[:delay]
|
375
|
+
@default_timeout = default_timeout
|
376
|
+
end
|
377
|
+
|
378
|
+
def as_params
|
379
|
+
{
|
380
|
+
delay: delay_ms,
|
381
|
+
modifiers: modifiers,
|
382
|
+
position: position,
|
383
|
+
timeout: timeout + delay_ms.to_i,
|
384
|
+
}.compact
|
385
|
+
end
|
386
|
+
|
387
|
+
private def timeout
|
388
|
+
if @wait
|
389
|
+
if @wait <= 0
|
390
|
+
raise NotSupportedByDriverError.new("wait should be > 0 (wait = 0 is not supported on this driver)")
|
391
|
+
end
|
392
|
+
|
393
|
+
@wait * 1000
|
394
|
+
else
|
395
|
+
@default_timeout
|
396
|
+
end
|
397
|
+
end
|
398
|
+
|
399
|
+
private def delay_ms
|
400
|
+
if @delay && @delay > 0
|
401
|
+
@delay * 1000
|
402
|
+
else
|
403
|
+
nil
|
404
|
+
end
|
405
|
+
end
|
406
|
+
|
407
|
+
MODIFIERS = {
|
408
|
+
alt: 'Alt',
|
409
|
+
control: 'Control',
|
410
|
+
meta: 'Meta',
|
411
|
+
shift: 'Shift',
|
412
|
+
}.freeze
|
413
|
+
|
414
|
+
private def modifiers
|
415
|
+
if @modifiers.empty?
|
416
|
+
nil
|
417
|
+
else
|
418
|
+
@modifiers
|
419
|
+
end
|
420
|
+
end
|
421
|
+
|
422
|
+
private def position
|
423
|
+
if @offset_center
|
424
|
+
box = @element.bounding_box
|
425
|
+
|
426
|
+
{
|
427
|
+
x: @coords[:x] + box['width'] / 2,
|
428
|
+
y: @coords[:y] + box['height'] / 2,
|
429
|
+
}
|
430
|
+
else
|
431
|
+
@coords
|
432
|
+
end
|
433
|
+
end
|
434
|
+
end
|
435
|
+
|
436
|
+
def send_keys(*args)
|
437
|
+
SendKeys.new(@element, args).execute
|
438
|
+
end
|
439
|
+
|
440
|
+
class SendKeys
|
441
|
+
MODIFIERS = {
|
442
|
+
alt: 'Alt',
|
443
|
+
control: 'Control',
|
444
|
+
meta: 'Meta',
|
445
|
+
shift: 'Shift',
|
446
|
+
}.freeze
|
447
|
+
|
448
|
+
KEYS = {
|
449
|
+
cancel: 'Cancel',
|
450
|
+
help: 'Help',
|
451
|
+
backspace: 'Backspace',
|
452
|
+
tab: 'Tab',
|
453
|
+
clear: 'Clear',
|
454
|
+
return: 'Enter',
|
455
|
+
enter: 'Enter',
|
456
|
+
shift: 'Shift',
|
457
|
+
control: 'Control',
|
458
|
+
alt: 'Alt',
|
459
|
+
pause: 'Pause',
|
460
|
+
escape: 'Escape',
|
461
|
+
space: 'Space',
|
462
|
+
page_up: 'PageUp',
|
463
|
+
page_down: 'PageDown',
|
464
|
+
end: 'End',
|
465
|
+
home: 'Home',
|
466
|
+
left: 'ArrowLeft',
|
467
|
+
up: 'ArrowUp',
|
468
|
+
right: 'ArrowRight',
|
469
|
+
down: 'ArrowDown',
|
470
|
+
insert: 'Insert',
|
471
|
+
delete: 'Delete',
|
472
|
+
semicolon: 'Semicolon',
|
473
|
+
equals: 'Equal',
|
474
|
+
numpad0: 'Numpad0',
|
475
|
+
numpad1: 'Numpad1',
|
476
|
+
numpad2: 'Numpad2',
|
477
|
+
numpad3: 'Numpad3',
|
478
|
+
numpad4: 'Numpad4',
|
479
|
+
numpad5: 'Numpad5',
|
480
|
+
numpad6: 'Numpad6',
|
481
|
+
numpad7: 'Numpad7',
|
482
|
+
numpad8: 'Numpad8',
|
483
|
+
numpad9: 'Numpad9',
|
484
|
+
multiply: 'NumpadMultiply',
|
485
|
+
add: 'NumpadAdd',
|
486
|
+
separator: 'NumpadDecimal',
|
487
|
+
subtract: 'NumpadSubtract',
|
488
|
+
decimal: 'NumpadDecimal',
|
489
|
+
divide: 'NumpadDivide',
|
490
|
+
f1: 'F1',
|
491
|
+
f2: 'F2',
|
492
|
+
f3: 'F3',
|
493
|
+
f4: 'F4',
|
494
|
+
f5: 'F5',
|
495
|
+
f6: 'F6',
|
496
|
+
f7: 'F7',
|
497
|
+
f8: 'F8',
|
498
|
+
f9: 'F9',
|
499
|
+
f10: 'F10',
|
500
|
+
f11: 'F11',
|
501
|
+
f12: 'F12',
|
502
|
+
meta: 'Meta',
|
503
|
+
command: 'Meta',
|
504
|
+
}
|
505
|
+
|
506
|
+
def initialize(element_or_keyboard, keys)
|
507
|
+
@element_or_keyboard = element_or_keyboard
|
508
|
+
|
509
|
+
holding_keys = []
|
510
|
+
@executables = keys.each_with_object([]) do |key, executables|
|
511
|
+
if MODIFIERS[key]
|
512
|
+
holding_keys << key
|
513
|
+
else
|
514
|
+
if holding_keys.empty?
|
515
|
+
case key
|
516
|
+
when String
|
517
|
+
executables << TypeText.new(key)
|
518
|
+
when Symbol
|
519
|
+
executables << PressKey.new(
|
520
|
+
key: key_for(key),
|
521
|
+
modifiers: [],
|
522
|
+
)
|
523
|
+
when Array
|
524
|
+
_key = key.last
|
525
|
+
code =
|
526
|
+
if _key.is_a?(String) && _key.length == 1
|
527
|
+
_key.upcase
|
528
|
+
elsif _key.is_a?(Symbol)
|
529
|
+
key_for(_key)
|
530
|
+
else
|
531
|
+
raise ArgumentError.new("invalid key: #{_key}. Symbol of 1-length String is expected.")
|
532
|
+
end
|
533
|
+
modifiers = key.first(key.size - 1).map { |k| modifier_for(k) }
|
534
|
+
executables << PressKey.new(
|
535
|
+
key: code,
|
536
|
+
modifiers: modifiers,
|
537
|
+
)
|
538
|
+
end
|
539
|
+
else
|
540
|
+
modifiers = holding_keys.map { |k| modifier_for(k) }
|
541
|
+
|
542
|
+
case key
|
543
|
+
when String
|
544
|
+
key.each_char do |char|
|
545
|
+
executables << PressKey.new(
|
546
|
+
key: char.upcase,
|
547
|
+
modifiers: modifiers,
|
548
|
+
)
|
549
|
+
end
|
550
|
+
when Symbol
|
551
|
+
executables << PressKey.new(
|
552
|
+
key: key_for(key),
|
553
|
+
modifiers: modifiers
|
554
|
+
)
|
555
|
+
else
|
556
|
+
raise ArgumentError.new("#{key} cannot be handled with holding key #{holding_keys}")
|
557
|
+
end
|
558
|
+
end
|
559
|
+
end
|
560
|
+
end
|
561
|
+
end
|
562
|
+
|
563
|
+
private def modifier_for(modifier)
|
564
|
+
MODIFIERS[modifier] or raise ArgumentError.new("invalid modifier specified: #{modifier}")
|
565
|
+
end
|
566
|
+
|
567
|
+
private def key_for(key)
|
568
|
+
KEYS[key] or raise ArgumentError.new("invalid key specified: #{key}")
|
569
|
+
end
|
570
|
+
|
571
|
+
def execute
|
572
|
+
@executables.each do |executable|
|
573
|
+
executable.execute_for(@element_or_keyboard)
|
574
|
+
end
|
575
|
+
end
|
576
|
+
|
577
|
+
class PressKey
|
578
|
+
def initialize(key:, modifiers:)
|
579
|
+
puts "PressKey: key=#{key} modifiers: #{modifiers}"
|
580
|
+
if modifiers.empty?
|
581
|
+
@key = key
|
582
|
+
else
|
583
|
+
@key = (modifiers + [key]).join('+')
|
584
|
+
end
|
585
|
+
end
|
586
|
+
|
587
|
+
def execute_for(element)
|
588
|
+
element.press(@key)
|
589
|
+
end
|
590
|
+
end
|
591
|
+
|
592
|
+
class TypeText
|
593
|
+
def initialize(text)
|
594
|
+
@text = text
|
595
|
+
end
|
596
|
+
|
597
|
+
def execute_for(element)
|
598
|
+
element.type(@text)
|
599
|
+
end
|
600
|
+
end
|
601
|
+
end
|
602
|
+
|
603
|
+
def hover
|
604
|
+
@element.hover(timeout: capybara_default_wait_time)
|
605
|
+
end
|
606
|
+
|
607
|
+
def drag_to(element, **options)
|
608
|
+
raise NotImplementedError
|
609
|
+
end
|
610
|
+
|
611
|
+
def drop(*args)
|
612
|
+
raise NotImplementedError
|
613
|
+
end
|
614
|
+
|
615
|
+
def scroll_by(x, y)
|
616
|
+
js = <<~JAVASCRIPT
|
617
|
+
(el, [x, y]) => {
|
618
|
+
if (el.scrollBy){
|
619
|
+
el.scrollBy(x, y);
|
620
|
+
} else {
|
621
|
+
el.scrollTop = el.scrollTop + y;
|
622
|
+
el.scrollLeft = el.scrollLeft + x;
|
623
|
+
}
|
624
|
+
}
|
625
|
+
JAVASCRIPT
|
626
|
+
|
627
|
+
@element.evaluate(js, arg: [x, y])
|
628
|
+
end
|
629
|
+
|
630
|
+
def scroll_to(element, location, position = nil)
|
631
|
+
# location, element = element, nil if element.is_a? Symbol
|
632
|
+
if element.is_a? Capybara::Playwright::Node
|
633
|
+
scroll_element_to_location(element, location)
|
634
|
+
elsif location.is_a? Symbol
|
635
|
+
scroll_to_location(location)
|
636
|
+
else
|
637
|
+
scroll_to_coords(*position)
|
638
|
+
end
|
639
|
+
|
640
|
+
self
|
641
|
+
end
|
642
|
+
|
643
|
+
private def scroll_element_to_location(element, location)
|
644
|
+
scroll_opts =
|
645
|
+
case location
|
646
|
+
when :top
|
647
|
+
'true'
|
648
|
+
when :bottom
|
649
|
+
'false'
|
650
|
+
when :center
|
651
|
+
"{behavior: 'instant', block: 'center'}"
|
652
|
+
else
|
653
|
+
raise ArgumentError, "Invalid scroll_to location: #{location}"
|
654
|
+
end
|
655
|
+
|
656
|
+
element.native.evaluate("(el) => { el.scrollIntoView(#{scroll_opts}) }")
|
657
|
+
end
|
658
|
+
|
659
|
+
SCROLL_POSITIONS = {
|
660
|
+
top: '0',
|
661
|
+
bottom: 'el.scrollHeight',
|
662
|
+
center: '(el.scrollHeight - el.clientHeight)/2'
|
663
|
+
}.freeze
|
664
|
+
|
665
|
+
private def scroll_to_location(location)
|
666
|
+
position = SCROLL_POSITIONS[location]
|
667
|
+
|
668
|
+
@element.evaluate(<<~JAVASCRIPT)
|
669
|
+
(el) => {
|
670
|
+
if (el.scrollTo){
|
671
|
+
el.scrollTo(0, #{position});
|
672
|
+
} else {
|
673
|
+
el.scrollTop = #{position};
|
674
|
+
}
|
675
|
+
}
|
676
|
+
JAVASCRIPT
|
677
|
+
end
|
678
|
+
|
679
|
+
private def scroll_to_coords(x, y)
|
680
|
+
js = <<~JAVASCRIPT
|
681
|
+
(el, [x, y]) => {
|
682
|
+
if (el.scrollTo){
|
683
|
+
el.scrollTo(x, y);
|
684
|
+
} else {
|
685
|
+
el.scrollTop = y;
|
686
|
+
el.scrollLeft = x;
|
687
|
+
}
|
688
|
+
}
|
689
|
+
JAVASCRIPT
|
690
|
+
|
691
|
+
@element.evaluate(js, arg: [x, y])
|
692
|
+
end
|
693
|
+
|
694
|
+
def tag_name
|
695
|
+
@tag_name ||= @element.evaluate('e => e.tagName.toLowerCase()')
|
696
|
+
end
|
697
|
+
|
698
|
+
def visible?
|
699
|
+
# if an area element, check visibility of relevant image
|
700
|
+
@element.evaluate(<<~JAVASCRIPT)
|
701
|
+
function(el) {
|
702
|
+
if (el.tagName == 'AREA'){
|
703
|
+
const map_name = document.evaluate('./ancestor::map/@name', el, null, XPathResult.STRING_TYPE, null).stringValue;
|
704
|
+
el = document.querySelector(`img[usemap='#${map_name}']`);
|
705
|
+
if (!el){
|
706
|
+
return false;
|
707
|
+
}
|
708
|
+
}
|
709
|
+
var forced_visible = false;
|
710
|
+
while (el) {
|
711
|
+
const style = window.getComputedStyle(el);
|
712
|
+
if (style.visibility == 'visible')
|
713
|
+
forced_visible = true;
|
714
|
+
if ((style.display == 'none') ||
|
715
|
+
((style.visibility == 'hidden') && !forced_visible) ||
|
716
|
+
(parseFloat(style.opacity) == 0)) {
|
717
|
+
return false;
|
718
|
+
}
|
719
|
+
var parent = el.parentElement;
|
720
|
+
if (parent && (parent.tagName == 'DETAILS') && !parent.open && (el.tagName != 'SUMMARY')) {
|
721
|
+
return false;
|
722
|
+
}
|
723
|
+
el = parent;
|
724
|
+
}
|
725
|
+
return true;
|
726
|
+
}
|
727
|
+
JAVASCRIPT
|
728
|
+
end
|
729
|
+
|
730
|
+
def obscured?
|
731
|
+
@element.capybara_obscured?
|
732
|
+
end
|
733
|
+
|
734
|
+
def checked?
|
735
|
+
@element.evaluate('el => !!el.checked')
|
736
|
+
end
|
737
|
+
|
738
|
+
def selected?
|
739
|
+
@element.evaluate('el => !!el.selected')
|
740
|
+
end
|
741
|
+
|
742
|
+
def disabled?
|
743
|
+
@element.evaluate(<<~JAVASCRIPT)
|
744
|
+
function(el) {
|
745
|
+
const xpath = 'parent::optgroup[@disabled] | \
|
746
|
+
ancestor::select[@disabled] | \
|
747
|
+
parent::fieldset[@disabled] | \
|
748
|
+
ancestor::*[not(self::legend) or preceding-sibling::legend][parent::fieldset[@disabled]]';
|
749
|
+
return el.disabled || document.evaluate(xpath, el, null, XPathResult.BOOLEAN_TYPE, null).booleanValue
|
750
|
+
}
|
751
|
+
JAVASCRIPT
|
752
|
+
end
|
753
|
+
|
754
|
+
def readonly?
|
755
|
+
!@element.editable?
|
756
|
+
end
|
757
|
+
|
758
|
+
def multiple?
|
759
|
+
@element.evaluate('el => el.multiple')
|
760
|
+
end
|
761
|
+
|
762
|
+
def rect
|
763
|
+
assert_element_not_stale
|
764
|
+
|
765
|
+
@element.evaluate(<<~JAVASCRIPT)
|
766
|
+
function(el){
|
767
|
+
const rects = [...el.getClientRects()]
|
768
|
+
const rect = rects.find(r => (r.height && r.width)) || el.getBoundingClientRect();
|
769
|
+
return rect.toJSON();
|
770
|
+
}
|
771
|
+
JAVASCRIPT
|
772
|
+
end
|
773
|
+
|
774
|
+
def path
|
775
|
+
assert_element_not_stale
|
776
|
+
|
777
|
+
@element.evaluate(<<~JAVASCRIPT)
|
778
|
+
(el) => {
|
779
|
+
var xml = document;
|
780
|
+
var xpath = '';
|
781
|
+
var pos, tempitem2;
|
782
|
+
if (el.getRootNode && el.getRootNode() instanceof ShadowRoot) {
|
783
|
+
return "(: Shadow DOM element - no XPath :)";
|
784
|
+
};
|
785
|
+
while(el !== xml.documentElement) {
|
786
|
+
pos = 0;
|
787
|
+
tempitem2 = el;
|
788
|
+
while(tempitem2) {
|
789
|
+
if (tempitem2.nodeType === 1 && tempitem2.nodeName === el.nodeName) { // If it is ELEMENT_NODE of the same name
|
790
|
+
pos += 1;
|
791
|
+
}
|
792
|
+
tempitem2 = tempitem2.previousSibling;
|
793
|
+
}
|
794
|
+
if (el.namespaceURI != xml.documentElement.namespaceURI) {
|
795
|
+
xpath = "*[local-name()='"+el.nodeName+"' and namespace-uri()='"+(el.namespaceURI===null?'':el.namespaceURI)+"']["+pos+']'+'/'+xpath;
|
796
|
+
} else {
|
797
|
+
xpath = el.nodeName.toUpperCase()+"["+pos+"]/"+xpath;
|
798
|
+
}
|
799
|
+
el = el.parentNode;
|
800
|
+
}
|
801
|
+
xpath = '/'+xml.documentElement.nodeName.toUpperCase()+'/'+xpath;
|
802
|
+
xpath = xpath.replace(/\\/$/, '');
|
803
|
+
return xpath;
|
804
|
+
}
|
805
|
+
JAVASCRIPT
|
806
|
+
end
|
807
|
+
|
808
|
+
def trigger(event)
|
809
|
+
@element.dispatch_event(event)
|
810
|
+
end
|
811
|
+
|
812
|
+
def inspect
|
813
|
+
%(#<#{self.class} tag="#{tag_name}" path="#{path}">)
|
814
|
+
end
|
815
|
+
|
816
|
+
def ==(other)
|
817
|
+
return false unless other.is_a?(Node)
|
818
|
+
|
819
|
+
@element.evaluate('(self, other) => self == other', arg: other.element)
|
820
|
+
end
|
821
|
+
|
822
|
+
def find_xpath(query, **options)
|
823
|
+
assert_element_not_stale
|
824
|
+
|
825
|
+
@element.query_selector_all("xpath=#{query}").map do |el|
|
826
|
+
Node.new(@driver, @page, el)
|
827
|
+
end
|
828
|
+
end
|
829
|
+
|
830
|
+
def find_css(query, **options)
|
831
|
+
assert_element_not_stale
|
832
|
+
|
833
|
+
@element.query_selector_all(query).map do |el|
|
834
|
+
Node.new(@driver, @page, el)
|
835
|
+
end
|
836
|
+
end
|
837
|
+
end
|
838
|
+
end
|
839
|
+
end
|