capybara-puppeteer-driver 0.1.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.
- checksums.yaml +7 -0
- data/.github/workflows/deploy.yml +31 -0
- data/.github/workflows/rspec.yml +18 -0
- data/.gitignore +16 -0
- data/.rspec +3 -0
- data/.travis.yml +7 -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 +12 -0
- data/bin/setup +8 -0
- data/capybara-puppeteer-driver.gemspec +36 -0
- data/lib/capybara/puppeteer.rb +11 -0
- data/lib/capybara/puppeteer/browser.rb +302 -0
- data/lib/capybara/puppeteer/browser_options.rb +20 -0
- data/lib/capybara/puppeteer/dialog_event_handler.rb +58 -0
- data/lib/capybara/puppeteer/driver.rb +158 -0
- data/lib/capybara/puppeteer/node.rb +1120 -0
- data/lib/capybara/puppeteer/page.rb +183 -0
- data/lib/capybara/puppeteer/version.rb +7 -0
- metadata +204 -0
@@ -0,0 +1,20 @@
|
|
1
|
+
module Capybara
|
2
|
+
module Puppeteer
|
3
|
+
class BrowserOptions
|
4
|
+
def initialize(options)
|
5
|
+
@options = options
|
6
|
+
end
|
7
|
+
|
8
|
+
LAUNCH_PARAMS = {
|
9
|
+
executable_path: nil,
|
10
|
+
headless: nil,
|
11
|
+
}.keys
|
12
|
+
|
13
|
+
def value
|
14
|
+
@options.select { |k, _| LAUNCH_PARAMS.include?(k) }.tap do |result|
|
15
|
+
result[:default_viewport] ||= ::Puppeteer::Viewport.new(width: 1280, height: 720)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
|
3
|
+
module Capybara
|
4
|
+
module Puppeteer
|
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,158 @@
|
|
1
|
+
require_relative './browser_options'
|
2
|
+
require 'fileutils'
|
3
|
+
require 'tmpdir'
|
4
|
+
|
5
|
+
module Capybara
|
6
|
+
module Puppeteer
|
7
|
+
module BrowserExtension
|
8
|
+
class Download
|
9
|
+
def initialize(guid, url:, download_dir:, suggested_filename:)
|
10
|
+
@guid = guid
|
11
|
+
@url = url
|
12
|
+
@download_dir = download_dir
|
13
|
+
@suggested_filename = suggested_filename
|
14
|
+
end
|
15
|
+
|
16
|
+
def complete
|
17
|
+
src = File.join(@download_dir, @suggested_filename)
|
18
|
+
dest = File.join(Capybara.save_path, @suggested_filename)
|
19
|
+
FileUtils.mkdir_p(Capybara.save_path)
|
20
|
+
FileUtils.mv(src, dest)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def set_download_behavior(behavior:, download_path:, events_enabled:)
|
25
|
+
@connection.send_message('Browser.setDownloadBehavior',
|
26
|
+
behavior: behavior,
|
27
|
+
downloadPath: download_path,
|
28
|
+
eventsEnabled: events_enabled,
|
29
|
+
)
|
30
|
+
@capybara_download_dir = download_path
|
31
|
+
@capybara_downloads = {}
|
32
|
+
|
33
|
+
@connection.on_event('Browser.downloadWillBegin') do |event|
|
34
|
+
guid = event['guid']
|
35
|
+
@capybara_downloads[guid] = Download.new(guid,
|
36
|
+
url: event['url'],
|
37
|
+
download_dir: @capybara_download_dir,
|
38
|
+
suggested_filename: event['suggestedFilename'])
|
39
|
+
end
|
40
|
+
@connection.on_event('Browser.downloadProgress') do |event|
|
41
|
+
guid = event['guid']
|
42
|
+
case event['state']
|
43
|
+
when 'completed'
|
44
|
+
@capybara_downloads.delete(guid).complete
|
45
|
+
when 'canceled'
|
46
|
+
@capybara_downloads.delete(guid)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
end
|
52
|
+
::Puppeteer::Browser.prepend(BrowserExtension)
|
53
|
+
|
54
|
+
class Driver < ::Capybara::Driver::Base
|
55
|
+
extend Forwardable
|
56
|
+
|
57
|
+
def initialize(app, options = {})
|
58
|
+
@browser_options = BrowserOptions.new(options)
|
59
|
+
end
|
60
|
+
|
61
|
+
def wait?; true; end
|
62
|
+
def needs_server?; true; end
|
63
|
+
|
64
|
+
private def browser
|
65
|
+
@browser ||= Browser.new(
|
66
|
+
driver: self,
|
67
|
+
puppeteer_browser: puppeteer_browser,
|
68
|
+
)
|
69
|
+
end
|
70
|
+
|
71
|
+
private def puppeteer_browser
|
72
|
+
@puppeteer_browser ||= create_puppeteer_browser
|
73
|
+
end
|
74
|
+
|
75
|
+
private def create_puppeteer_browser
|
76
|
+
main = Process.pid
|
77
|
+
at_exit do
|
78
|
+
if @tmpdir_for_download
|
79
|
+
FileUtils.remove_entry(@tmpdir_for_download, true)
|
80
|
+
@tmpdir_for_download = nil
|
81
|
+
end
|
82
|
+
# Store the exit status of the test run since it goes away after calling the at_exit proc...
|
83
|
+
@exit_status = $ERROR_INFO.status if $ERROR_INFO.is_a?(SystemExit)
|
84
|
+
quit if Process.pid == main
|
85
|
+
exit @exit_status if @exit_status # Force exit with stored status
|
86
|
+
end
|
87
|
+
|
88
|
+
browser_options = @browser_options.value
|
89
|
+
::Puppeteer.launch(**browser_options).tap do |browser|
|
90
|
+
# allow File downloading manually.
|
91
|
+
# ref: https://github.com/puppeteer/puppeteer/issues/7337#issuecomment-866295829
|
92
|
+
browser.set_download_behavior(
|
93
|
+
behavior: 'allow',
|
94
|
+
download_path: tmpdir_for_download,
|
95
|
+
events_enabled: true,
|
96
|
+
)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
private def tmpdir_for_download
|
101
|
+
@tmpdir_for_download ||= Dir.mktmpdir
|
102
|
+
end
|
103
|
+
|
104
|
+
|
105
|
+
private def quit
|
106
|
+
@puppeteer_browser&.close
|
107
|
+
@puppeteer_browser = nil
|
108
|
+
end
|
109
|
+
|
110
|
+
def reset!
|
111
|
+
@puppeteer_browser&.close
|
112
|
+
@puppeteer_browser = nil
|
113
|
+
@browser = nil
|
114
|
+
end
|
115
|
+
|
116
|
+
def invalid_element_errors
|
117
|
+
@invalid_element_errors ||= [
|
118
|
+
Node::NotActionableError,
|
119
|
+
Node::StaleReferenceError,
|
120
|
+
].freeze
|
121
|
+
end
|
122
|
+
|
123
|
+
def no_such_window_error
|
124
|
+
Browser::NoSuchWindowError
|
125
|
+
end
|
126
|
+
|
127
|
+
# ref: https://github.com/teamcapybara/capybara/blob/master/lib/capybara/driver/base.rb
|
128
|
+
def_delegator(:browser, :current_url)
|
129
|
+
def_delegator(:browser, :visit)
|
130
|
+
def_delegator(:browser, :refresh)
|
131
|
+
def_delegator(:browser, :find_xpath)
|
132
|
+
def_delegator(:browser, :find_css)
|
133
|
+
def_delegator(:browser, :title)
|
134
|
+
def_delegator(:browser, :html)
|
135
|
+
def_delegator(:browser, :go_back)
|
136
|
+
def_delegator(:browser, :go_forward)
|
137
|
+
def_delegator(:browser, :execute_script)
|
138
|
+
def_delegator(:browser, :evaluate_script)
|
139
|
+
def_delegator(:browser, :evaluate_async_script)
|
140
|
+
def_delegator(:browser, :save_screenshot)
|
141
|
+
def_delegator(:browser, :response_headers)
|
142
|
+
def_delegator(:browser, :status_code)
|
143
|
+
def_delegator(:browser, :send_keys)
|
144
|
+
def_delegator(:browser, :switch_to_frame)
|
145
|
+
def_delegator(:browser, :current_window_handle)
|
146
|
+
def_delegator(:browser, :window_size)
|
147
|
+
def_delegator(:browser, :resize_window_to)
|
148
|
+
def_delegator(:browser, :maximize_window)
|
149
|
+
def_delegator(:browser, :fullscreen_window)
|
150
|
+
def_delegator(:browser, :close_window)
|
151
|
+
def_delegator(:browser, :window_handles)
|
152
|
+
def_delegator(:browser, :open_new_window)
|
153
|
+
def_delegator(:browser, :switch_to_window)
|
154
|
+
def_delegator(:browser, :accept_modal)
|
155
|
+
def_delegator(:browser, :dismiss_modal)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
@@ -0,0 +1,1120 @@
|
|
1
|
+
module Capybara
|
2
|
+
module Puppeteer
|
3
|
+
module FramePatch
|
4
|
+
def frame_element(frame_id)
|
5
|
+
result = @client.send_message('DOM.getFrameOwner', frameId: frame_id)
|
6
|
+
execution_context.adopt_backend_node_id(result['backendNodeId'])
|
7
|
+
end
|
8
|
+
end
|
9
|
+
::Puppeteer::Frame.prepend(FramePatch)
|
10
|
+
|
11
|
+
module ElementHandlePatch
|
12
|
+
def select_all
|
13
|
+
evaluate(<<~JAVASCRIPT)
|
14
|
+
element => {
|
15
|
+
if (element.select) {
|
16
|
+
element.select();
|
17
|
+
} else {
|
18
|
+
const range = document.createRange();
|
19
|
+
range.selectNodeContents(element);
|
20
|
+
window.getSelection().removeAllRanges();
|
21
|
+
window.getSelection().addRange(range);
|
22
|
+
}
|
23
|
+
}
|
24
|
+
JAVASCRIPT
|
25
|
+
end
|
26
|
+
|
27
|
+
def click(position: nil, delay: nil, button: nil, click_count: nil)
|
28
|
+
if position
|
29
|
+
click_with_offset(
|
30
|
+
x: position[:x],
|
31
|
+
y: position[:y],
|
32
|
+
delay: delay,
|
33
|
+
button: button,
|
34
|
+
click_count: click_count,
|
35
|
+
)
|
36
|
+
else
|
37
|
+
super(
|
38
|
+
delay: delay,
|
39
|
+
button: button,
|
40
|
+
click_count: click_count,
|
41
|
+
)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def click_with_offset(x:, y:, delay: nil, button: nil, click_count: nil)
|
46
|
+
scroll_into_view_if_needed
|
47
|
+
box = bounding_box
|
48
|
+
# FIXME: consider border.
|
49
|
+
# https://github.com/microsoft/playwright/blob/af18b314730fbcb387be62d2bbf757b5cdda5f96/src/server/dom.ts#L278
|
50
|
+
@page.mouse.click(box.x + x, box.y + y, delay: delay, button: button, click_count: click_count)
|
51
|
+
end
|
52
|
+
|
53
|
+
# likely to type_text, except for overwriting the input instead of inserting.
|
54
|
+
def fill_text(text, delay: nil)
|
55
|
+
click # #focus is not enough for executing selectAll against ContentEditable.
|
56
|
+
select_all
|
57
|
+
if !text || text.empty?
|
58
|
+
@page.keyboard.press('Delete', delay: delay)
|
59
|
+
else
|
60
|
+
@page.keyboard.type_text(text, delay: delay)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def owner_frame
|
65
|
+
doc_handle = evaluate_handle(<<~JAVASCRIPT)
|
66
|
+
node => {
|
67
|
+
if (node.documentElement && node.documentElement.ownerDocument === node)
|
68
|
+
return node.documentElement;
|
69
|
+
else
|
70
|
+
return node.ownerDocument ? node.ownerDocument.documentElement : null;
|
71
|
+
}
|
72
|
+
JAVASCRIPT
|
73
|
+
|
74
|
+
frame = doc_handle&.content_frame
|
75
|
+
doc_handle&.dispose
|
76
|
+
frame
|
77
|
+
end
|
78
|
+
|
79
|
+
# ref: https://github.com/twalpole/apparition/blob/11aca464b38b77585191b7e302be2e062bdd369d/lib/capybara/apparition/node.rb#L774
|
80
|
+
VISIBLE_JS = <<~JAVASCRIPT
|
81
|
+
function(el) {
|
82
|
+
if (el.tagName == 'AREA'){
|
83
|
+
const map_name = document.evaluate('./ancestor::map/@name', el, null, XPathResult.STRING_TYPE, null).stringValue;
|
84
|
+
el = document.querySelector(`img[usemap='#${map_name}']`);
|
85
|
+
if (!el){
|
86
|
+
return false;
|
87
|
+
}
|
88
|
+
}
|
89
|
+
var forced_visible = false;
|
90
|
+
while (el) {
|
91
|
+
const style = window.getComputedStyle(el);
|
92
|
+
if (style.visibility == 'visible')
|
93
|
+
forced_visible = true;
|
94
|
+
if ((style.display == 'none') ||
|
95
|
+
((style.visibility == 'hidden') && !forced_visible) ||
|
96
|
+
(parseFloat(style.opacity) == 0)) {
|
97
|
+
return false;
|
98
|
+
}
|
99
|
+
var parent = el.parentElement;
|
100
|
+
if (parent && (parent.tagName == 'DETAILS') && !parent.open && (el.tagName != 'SUMMARY')) {
|
101
|
+
return false;
|
102
|
+
}
|
103
|
+
el = parent;
|
104
|
+
}
|
105
|
+
return true;
|
106
|
+
}
|
107
|
+
JAVASCRIPT
|
108
|
+
|
109
|
+
def capybara_visible?
|
110
|
+
# if an area element, check visibility of relevant image
|
111
|
+
evaluate(VISIBLE_JS)
|
112
|
+
end
|
113
|
+
|
114
|
+
# ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/lib/capybara/selenium/node.rb#L523
|
115
|
+
OBSCURED_OR_OFFSET_SCRIPT = <<~JAVASCRIPT
|
116
|
+
(el, x, y) => {
|
117
|
+
var box = el.getBoundingClientRect();
|
118
|
+
if (!x && x != 0) x = box.width / 2;
|
119
|
+
if (!y && y != 0) y = box.height / 2;
|
120
|
+
var px = box.left + x,
|
121
|
+
py = box.top + y,
|
122
|
+
e = document.elementFromPoint(px, py);
|
123
|
+
if (!el.contains(e))
|
124
|
+
return true;
|
125
|
+
return { x: px, y: py };
|
126
|
+
}
|
127
|
+
JAVASCRIPT
|
128
|
+
|
129
|
+
def capybara_obscured?(x: nil, y: nil)
|
130
|
+
res = evaluate(OBSCURED_OR_OFFSET_SCRIPT, x, y)
|
131
|
+
return true if res == true
|
132
|
+
|
133
|
+
# ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/lib/capybara/selenium/driver.rb#L182
|
134
|
+
frame = owner_frame
|
135
|
+
return false unless frame&.parent_frame
|
136
|
+
frame.parent_frame.frame_element(frame.id).capybara_obscured?(x: res['x'], y: res['y'])
|
137
|
+
end
|
138
|
+
end
|
139
|
+
::Puppeteer::ElementHandle.prepend(ElementHandlePatch)
|
140
|
+
|
141
|
+
# ref:
|
142
|
+
# selenium: https://github.com/teamcapybara/capybara/blob/master/lib/capybara/selenium/node.rb
|
143
|
+
# apparition: https://github.com/twalpole/apparition/blob/master/lib/capybara/apparition/node.rb
|
144
|
+
class Node < ::Capybara::Driver::Node
|
145
|
+
class StaleReferenceError < StandardError ; end
|
146
|
+
|
147
|
+
def initialize(driver, page, element)
|
148
|
+
super(driver, element)
|
149
|
+
@page = page
|
150
|
+
@element = element
|
151
|
+
end
|
152
|
+
|
153
|
+
private def assert_element_not_stale
|
154
|
+
unless @element.evaluate('el => el.isConnected')
|
155
|
+
raise StaleReferenceError.new('Node is already detached from document.')
|
156
|
+
end
|
157
|
+
rescue ::Puppeteer::Connection::ProtocolError => err
|
158
|
+
# Navigation occured during finding Node.
|
159
|
+
if err.message =~ /Cannot find context with specified id/
|
160
|
+
raise StaleReferenceError.new('Node is already detached.')
|
161
|
+
end
|
162
|
+
|
163
|
+
raise
|
164
|
+
end
|
165
|
+
|
166
|
+
def all_text
|
167
|
+
assert_element_not_stale
|
168
|
+
|
169
|
+
text = @element.evaluate('(el) => el.textContent')
|
170
|
+
text.to_s.gsub(/[\u200b\u200e\u200f]/, '')
|
171
|
+
.gsub(/[\ \n\f\t\v\u2028\u2029]+/, ' ')
|
172
|
+
.gsub(/\A[[:space:]&&[^\u00a0]]+/, '')
|
173
|
+
.gsub(/[[:space:]&&[^\u00a0]]+\z/, '')
|
174
|
+
.tr("\u00a0", ' ')
|
175
|
+
end
|
176
|
+
|
177
|
+
def visible_text
|
178
|
+
assert_element_not_stale
|
179
|
+
|
180
|
+
return '' unless @element.capybara_visible?
|
181
|
+
|
182
|
+
text = @element.evaluate(<<~JAVASCRIPT)
|
183
|
+
function(el){
|
184
|
+
if (el.nodeName == 'TEXTAREA'){
|
185
|
+
return el.textContent;
|
186
|
+
} else if (el instanceof SVGElement) {
|
187
|
+
return el.textContent;
|
188
|
+
} else {
|
189
|
+
return el.innerText;
|
190
|
+
}
|
191
|
+
}
|
192
|
+
JAVASCRIPT
|
193
|
+
text.to_s.gsub(/\A[[:space:]&&[^\u00a0]]+/, '')
|
194
|
+
.gsub(/[[:space:]&&[^\u00a0]]+\z/, '')
|
195
|
+
.gsub(/\n+/, "\n")
|
196
|
+
.tr("\u00a0", ' ')
|
197
|
+
end
|
198
|
+
|
199
|
+
def [](name)
|
200
|
+
assert_element_not_stale
|
201
|
+
|
202
|
+
property(name) || attribute(name)
|
203
|
+
end
|
204
|
+
|
205
|
+
private def property(name)
|
206
|
+
js = <<~JAVASCRIPT
|
207
|
+
(el, name) => {
|
208
|
+
const value = el[name];
|
209
|
+
if (['object', 'function'].includes(typeof value)) {
|
210
|
+
return null;
|
211
|
+
} else {
|
212
|
+
return value;
|
213
|
+
}
|
214
|
+
}
|
215
|
+
JAVASCRIPT
|
216
|
+
|
217
|
+
@element.evaluate(js, name)
|
218
|
+
end
|
219
|
+
|
220
|
+
private def attribute(name)
|
221
|
+
@element.evaluate('(el, name) => el.getAttribute(name)', name)
|
222
|
+
end
|
223
|
+
|
224
|
+
def value
|
225
|
+
assert_element_not_stale
|
226
|
+
|
227
|
+
# ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/lib/capybara/selenium/node.rb#L31
|
228
|
+
# ref: https://github.com/twalpole/apparition/blob/11aca464b38b77585191b7e302be2e062bdd369d/lib/capybara/apparition/node.rb#L728
|
229
|
+
if tag_name == 'select' && @element.evaluate('el => el.multiple')
|
230
|
+
@element.query_selector_all('option:checked').map do |option|
|
231
|
+
option.evaluate('el => el.value')
|
232
|
+
end
|
233
|
+
else
|
234
|
+
@element.evaluate('el => el.value')
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
def style(styles)
|
239
|
+
raise NotImplementedError
|
240
|
+
end
|
241
|
+
|
242
|
+
class NotActionableError < StandardError ; end
|
243
|
+
|
244
|
+
# @param value [String, Array] Array is only allowed if node has 'multiple' attribute
|
245
|
+
# @param options [Hash] Driver specific options for how to set a value on a node
|
246
|
+
def set(value, **options)
|
247
|
+
assert_element_not_stale
|
248
|
+
|
249
|
+
settable_class =
|
250
|
+
case tag_name
|
251
|
+
when 'input'
|
252
|
+
case attribute('type')
|
253
|
+
when 'radio'
|
254
|
+
RadioButton
|
255
|
+
when 'checkbox'
|
256
|
+
Checkbox
|
257
|
+
when 'file'
|
258
|
+
FileUpload
|
259
|
+
when 'date'
|
260
|
+
DateInput
|
261
|
+
when 'time'
|
262
|
+
TimeInput
|
263
|
+
when 'datetime-local'
|
264
|
+
DateTimeInput
|
265
|
+
when 'color'
|
266
|
+
JSValueInput
|
267
|
+
when 'range'
|
268
|
+
JSValueInput
|
269
|
+
else
|
270
|
+
TextInput
|
271
|
+
end
|
272
|
+
when 'textarea'
|
273
|
+
TextInput
|
274
|
+
else
|
275
|
+
if @element['isContentEditable']
|
276
|
+
TextInput
|
277
|
+
else
|
278
|
+
raise NotSupportedByDriverError
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
settable_class.new(@element, capybara_default_wait_time).set(value, **options)
|
283
|
+
rescue ::Puppeteer::ElementHandle::ElementNotVisibleError => err
|
284
|
+
raise NotActionableError.new(err)
|
285
|
+
end
|
286
|
+
|
287
|
+
class Settable
|
288
|
+
def initialize(element, timeout)
|
289
|
+
@element = element
|
290
|
+
@timeout = timeout
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
class RadioButton < Settable
|
295
|
+
def set(_, **options)
|
296
|
+
@element.click
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
class Checkbox < Settable
|
301
|
+
def set(value, **options)
|
302
|
+
checked = @element.evaluate('el => !!el.checked')
|
303
|
+
|
304
|
+
if value && !checked
|
305
|
+
# check
|
306
|
+
@element.click
|
307
|
+
elsif !value && checked
|
308
|
+
# uncheck
|
309
|
+
@element.click
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|
313
|
+
|
314
|
+
class TextInput < Settable
|
315
|
+
def set(value, **options)
|
316
|
+
@element.fill_text(value.to_s)
|
317
|
+
end
|
318
|
+
end
|
319
|
+
|
320
|
+
class FileUpload < Settable
|
321
|
+
def set(value, **options)
|
322
|
+
files = Array(value)
|
323
|
+
@element.upload_file(*files)
|
324
|
+
end
|
325
|
+
end
|
326
|
+
|
327
|
+
module UpdateValueJS
|
328
|
+
def update_value_js(element, value)
|
329
|
+
# ref: https://github.com/teamcapybara/capybara/blob/f7ab0b5cd5da86185816c2d5c30d58145fe654ed/lib/capybara/selenium/node.rb#L343
|
330
|
+
js = <<~JAVASCRIPT
|
331
|
+
(el, value) => {
|
332
|
+
if (el.readOnly) { return };
|
333
|
+
if (document.activeElement !== el){
|
334
|
+
el.focus();
|
335
|
+
}
|
336
|
+
if (el.value != value) {
|
337
|
+
el.value = value;
|
338
|
+
el.dispatchEvent(new InputEvent('input'));
|
339
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
340
|
+
}
|
341
|
+
}
|
342
|
+
JAVASCRIPT
|
343
|
+
element.evaluate(js, value)
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
class DateInput < Settable
|
348
|
+
include UpdateValueJS
|
349
|
+
|
350
|
+
def set(value, **options)
|
351
|
+
if !value.is_a?(String) && value.respond_to?(:to_date)
|
352
|
+
update_value_js(@element, value.to_date.iso8601)
|
353
|
+
else
|
354
|
+
@element.fill_text(value.to_s, timeout: @timeout)
|
355
|
+
end
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
class TimeInput < Settable
|
360
|
+
include UpdateValueJS
|
361
|
+
|
362
|
+
def set(value, **options)
|
363
|
+
if !value.is_a?(String) && value.respond_to?(:to_time)
|
364
|
+
update_value_js(@element, value.to_time.strftime('%H:%M'))
|
365
|
+
else
|
366
|
+
@element.fill_text(value.to_s, timeout: @timeout)
|
367
|
+
end
|
368
|
+
end
|
369
|
+
end
|
370
|
+
|
371
|
+
class DateTimeInput < Settable
|
372
|
+
include UpdateValueJS
|
373
|
+
|
374
|
+
def set(value, **options)
|
375
|
+
if !value.is_a?(String) && value.respond_to?(:to_time)
|
376
|
+
update_value_js(@element, value.to_time.strftime('%Y-%m-%dT%H:%M'))
|
377
|
+
else
|
378
|
+
@element.fill_text(value.to_s, timeout: @timeout)
|
379
|
+
end
|
380
|
+
end
|
381
|
+
end
|
382
|
+
|
383
|
+
class JSValueInput < Settable
|
384
|
+
include UpdateValueJS
|
385
|
+
|
386
|
+
def set(value, **options)
|
387
|
+
update_value_js(@element, value)
|
388
|
+
end
|
389
|
+
end
|
390
|
+
|
391
|
+
private def parent_select_element
|
392
|
+
@element.Sx('ancestor::select').first
|
393
|
+
end
|
394
|
+
|
395
|
+
def select_option
|
396
|
+
assert_element_not_stale
|
397
|
+
|
398
|
+
return false if disabled?
|
399
|
+
|
400
|
+
selected_options = []
|
401
|
+
|
402
|
+
select_element = parent_select_element
|
403
|
+
if select_element && select_element.evaluate('el => el.multiple')
|
404
|
+
selected_options = select_element.query_selector_all('option:checked')
|
405
|
+
return false if selected_options.any? { |option_element| option_element == @element }
|
406
|
+
end
|
407
|
+
|
408
|
+
@element.evaluate('option => option.selected = true')
|
409
|
+
select_element.evaluate(<<~JAVASCRIPT)
|
410
|
+
select => {
|
411
|
+
select.dispatchEvent(new Event('input', { 'bubbles': true }));
|
412
|
+
select.dispatchEvent(new Event('change', { 'bubbles': true }));
|
413
|
+
}
|
414
|
+
JAVASCRIPT
|
415
|
+
|
416
|
+
true
|
417
|
+
end
|
418
|
+
|
419
|
+
def unselect_option
|
420
|
+
assert_element_not_stale
|
421
|
+
|
422
|
+
if parent_select_element.evaluate('el => el.multiple')
|
423
|
+
return false if disabled?
|
424
|
+
|
425
|
+
@element.evaluate('el => el.selected = false')
|
426
|
+
else
|
427
|
+
raise Capybara::UnselectNotAllowed, 'Cannot unselect option from single select box.'
|
428
|
+
end
|
429
|
+
end
|
430
|
+
|
431
|
+
def click(keys = [], **options)
|
432
|
+
assert_element_not_stale
|
433
|
+
|
434
|
+
click_options = ClickOptions.new(@element, keys, options)
|
435
|
+
params = click_options.as_params
|
436
|
+
click_options.with_modifiers_pressing(@page.keyboard) do
|
437
|
+
@element.click(**params)
|
438
|
+
end
|
439
|
+
rescue ::Puppeteer::ElementHandle::ElementNotVisibleError => err
|
440
|
+
raise NotActionableError.new(err)
|
441
|
+
end
|
442
|
+
|
443
|
+
def right_click(keys = [], **options)
|
444
|
+
assert_element_not_stale
|
445
|
+
|
446
|
+
click_options = ClickOptions.new(@element, keys, options)
|
447
|
+
params = click_options.as_params
|
448
|
+
params[:button] = 'right'
|
449
|
+
click_options.with_modifiers_pressing(@page.keyboard) do
|
450
|
+
@element.click(**params)
|
451
|
+
end
|
452
|
+
rescue ::Puppeteer::ElementHandle::ElementNotVisibleError => err
|
453
|
+
raise NotActionableError.new(err)
|
454
|
+
end
|
455
|
+
|
456
|
+
def double_click(keys = [], **options)
|
457
|
+
assert_element_not_stale
|
458
|
+
|
459
|
+
click_options = ClickOptions.new(@element, keys, options)
|
460
|
+
params = click_options.as_params
|
461
|
+
params[:click_count] = 2
|
462
|
+
click_options.with_modifiers_pressing(@page.keyboard) do
|
463
|
+
@element.click(**params)
|
464
|
+
end
|
465
|
+
rescue ::Puppeteer::ElementHandle::ElementNotVisibleError => err
|
466
|
+
raise NotActionableError.new(err)
|
467
|
+
end
|
468
|
+
|
469
|
+
class ClickOptions
|
470
|
+
MODIFIERS = {
|
471
|
+
alt: 'Alt',
|
472
|
+
ctrl: 'Control',
|
473
|
+
control: 'Control',
|
474
|
+
meta: 'Meta',
|
475
|
+
command: 'Meta',
|
476
|
+
cmd: 'Meta',
|
477
|
+
shift: 'Shift',
|
478
|
+
}.freeze
|
479
|
+
|
480
|
+
def initialize(element, keys, options)
|
481
|
+
@element = element
|
482
|
+
@modifiers = keys.map do |key|
|
483
|
+
MODIFIERS[key.to_sym] or raise ArgumentError.new("Unknown modifier key: #{key}")
|
484
|
+
end
|
485
|
+
if options[:x] && options[:y]
|
486
|
+
@coords = {
|
487
|
+
x: options[:x],
|
488
|
+
y: options[:y],
|
489
|
+
}
|
490
|
+
@offset_center = options[:offset] == :center
|
491
|
+
end
|
492
|
+
@delay = options[:delay]
|
493
|
+
end
|
494
|
+
|
495
|
+
def as_params
|
496
|
+
{
|
497
|
+
delay: delay_ms,
|
498
|
+
position: position,
|
499
|
+
}.compact
|
500
|
+
end
|
501
|
+
|
502
|
+
def with_modifiers_pressing(keyboard, &block)
|
503
|
+
@modifiers.each { |key| keyboard.down(key) }
|
504
|
+
block.call
|
505
|
+
@modifiers.each { |key| keyboard.up(key) }
|
506
|
+
end
|
507
|
+
|
508
|
+
private def delay_ms
|
509
|
+
if @delay && @delay > 0
|
510
|
+
@delay * 1000
|
511
|
+
else
|
512
|
+
nil
|
513
|
+
end
|
514
|
+
end
|
515
|
+
|
516
|
+
private def position
|
517
|
+
if @offset_center
|
518
|
+
box = @element.bounding_box
|
519
|
+
|
520
|
+
{
|
521
|
+
x: @coords[:x] + box.width / 2,
|
522
|
+
y: @coords[:y] + box.height / 2,
|
523
|
+
}
|
524
|
+
else
|
525
|
+
@coords
|
526
|
+
end
|
527
|
+
end
|
528
|
+
end
|
529
|
+
|
530
|
+
def send_keys(*args)
|
531
|
+
assert_element_not_stale
|
532
|
+
|
533
|
+
@element.click
|
534
|
+
SendKeys.new(@element, @page.keyboard, args).execute
|
535
|
+
end
|
536
|
+
|
537
|
+
class SendKeys
|
538
|
+
MODIFIERS = {
|
539
|
+
alt: 'Alt',
|
540
|
+
ctrl: 'Control',
|
541
|
+
control: 'Control',
|
542
|
+
meta: 'Meta',
|
543
|
+
command: 'Meta',
|
544
|
+
cmd: 'Meta',
|
545
|
+
shift: 'Shift',
|
546
|
+
}.freeze
|
547
|
+
|
548
|
+
KEYS = {
|
549
|
+
cancel: 'Cancel',
|
550
|
+
help: 'Help',
|
551
|
+
backspace: 'Backspace',
|
552
|
+
tab: 'Tab',
|
553
|
+
clear: 'Clear',
|
554
|
+
return: 'Enter',
|
555
|
+
enter: 'Enter',
|
556
|
+
shift: 'Shift',
|
557
|
+
control: 'Control',
|
558
|
+
alt: 'Alt',
|
559
|
+
pause: 'Pause',
|
560
|
+
escape: 'Escape',
|
561
|
+
space: 'Space',
|
562
|
+
page_up: 'PageUp',
|
563
|
+
page_down: 'PageDown',
|
564
|
+
end: 'End',
|
565
|
+
home: 'Home',
|
566
|
+
left: 'ArrowLeft',
|
567
|
+
up: 'ArrowUp',
|
568
|
+
right: 'ArrowRight',
|
569
|
+
down: 'ArrowDown',
|
570
|
+
insert: 'Insert',
|
571
|
+
delete: 'Delete',
|
572
|
+
semicolon: 'Semicolon',
|
573
|
+
equals: 'Equal',
|
574
|
+
numpad0: 'Numpad0',
|
575
|
+
numpad1: 'Numpad1',
|
576
|
+
numpad2: 'Numpad2',
|
577
|
+
numpad3: 'Numpad3',
|
578
|
+
numpad4: 'Numpad4',
|
579
|
+
numpad5: 'Numpad5',
|
580
|
+
numpad6: 'Numpad6',
|
581
|
+
numpad7: 'Numpad7',
|
582
|
+
numpad8: 'Numpad8',
|
583
|
+
numpad9: 'Numpad9',
|
584
|
+
multiply: 'NumpadMultiply',
|
585
|
+
add: 'NumpadAdd',
|
586
|
+
separator: 'NumpadDecimal',
|
587
|
+
subtract: 'NumpadSubtract',
|
588
|
+
decimal: 'NumpadDecimal',
|
589
|
+
divide: 'NumpadDivide',
|
590
|
+
f1: 'F1',
|
591
|
+
f2: 'F2',
|
592
|
+
f3: 'F3',
|
593
|
+
f4: 'F4',
|
594
|
+
f5: 'F5',
|
595
|
+
f6: 'F6',
|
596
|
+
f7: 'F7',
|
597
|
+
f8: 'F8',
|
598
|
+
f9: 'F9',
|
599
|
+
f10: 'F10',
|
600
|
+
f11: 'F11',
|
601
|
+
f12: 'F12',
|
602
|
+
meta: 'Meta',
|
603
|
+
command: 'Meta',
|
604
|
+
}
|
605
|
+
|
606
|
+
def initialize(element_or_keyboard, keyboard, keys)
|
607
|
+
@element_or_keyboard = element_or_keyboard
|
608
|
+
@keyboard = keyboard
|
609
|
+
|
610
|
+
holding_keys = []
|
611
|
+
@executables = keys.each_with_object([]) do |key, executables|
|
612
|
+
if MODIFIERS[key]
|
613
|
+
holding_keys << key
|
614
|
+
else
|
615
|
+
if holding_keys.empty?
|
616
|
+
case key
|
617
|
+
when String
|
618
|
+
executables << TypeText.new(key)
|
619
|
+
when Symbol
|
620
|
+
executables << PressKey.new(
|
621
|
+
keyboard: @keyboard,
|
622
|
+
key: key_for(key),
|
623
|
+
modifiers: [],
|
624
|
+
)
|
625
|
+
when Array
|
626
|
+
_key = key.last
|
627
|
+
code =
|
628
|
+
if _key.is_a?(String) && _key.length == 1
|
629
|
+
char_key_for(_key)
|
630
|
+
elsif _key.is_a?(Symbol)
|
631
|
+
key_for(_key)
|
632
|
+
else
|
633
|
+
raise ArgumentError.new("invalid key: #{_key}. Symbol of 1-length String is expected.")
|
634
|
+
end
|
635
|
+
modifiers = key.first(key.size - 1).map { |k| modifier_for(k) }
|
636
|
+
executables << PressKey.new(
|
637
|
+
keyboard: @keyboard,
|
638
|
+
key: code,
|
639
|
+
modifiers: modifiers,
|
640
|
+
)
|
641
|
+
end
|
642
|
+
else
|
643
|
+
modifiers = holding_keys.map { |k| modifier_for(k) }
|
644
|
+
|
645
|
+
case key
|
646
|
+
when String
|
647
|
+
key.each_char do |char|
|
648
|
+
executables << PressKey.new(
|
649
|
+
keyboard: @keyboard,
|
650
|
+
key: char_key_for(char),
|
651
|
+
modifiers: modifiers,
|
652
|
+
)
|
653
|
+
end
|
654
|
+
when Symbol
|
655
|
+
executables << PressKey.new(
|
656
|
+
keyboard: @keyboard,
|
657
|
+
key: key_for(key),
|
658
|
+
modifiers: modifiers
|
659
|
+
)
|
660
|
+
else
|
661
|
+
raise ArgumentError.new("#{key} cannot be handled with holding key #{holding_keys}")
|
662
|
+
end
|
663
|
+
end
|
664
|
+
end
|
665
|
+
end
|
666
|
+
end
|
667
|
+
|
668
|
+
private def modifier_for(modifier)
|
669
|
+
MODIFIERS[modifier] or raise ArgumentError.new("invalid modifier specified: #{modifier}")
|
670
|
+
end
|
671
|
+
|
672
|
+
private def key_for(key)
|
673
|
+
KEYS[key] or raise ArgumentError.new("invalid key specified: #{key}")
|
674
|
+
end
|
675
|
+
|
676
|
+
private def char_key_for(key)
|
677
|
+
if key =~ /^[A-Z]$/
|
678
|
+
"Key#{key}"
|
679
|
+
elsif key =~ /^[a-z]$/
|
680
|
+
"Key#{key.upcase}"
|
681
|
+
else
|
682
|
+
key
|
683
|
+
end
|
684
|
+
end
|
685
|
+
|
686
|
+
def execute
|
687
|
+
@executables.each do |executable|
|
688
|
+
executable.execute_for(@element_or_keyboard)
|
689
|
+
end
|
690
|
+
end
|
691
|
+
|
692
|
+
class PressKey
|
693
|
+
def initialize(keyboard:, key:, modifiers:)
|
694
|
+
# puts "PressKey: key=#{key} modifiers: #{modifiers}"
|
695
|
+
@keyboard = keyboard
|
696
|
+
@key = key
|
697
|
+
@modifiers = modifiers
|
698
|
+
end
|
699
|
+
|
700
|
+
def execute_for(element)
|
701
|
+
with_modifiers_pressing do
|
702
|
+
element.press(@key)
|
703
|
+
end
|
704
|
+
end
|
705
|
+
|
706
|
+
private def with_modifiers_pressing(&block)
|
707
|
+
@modifiers.each { |key| @keyboard.down(key) }
|
708
|
+
block.call
|
709
|
+
@modifiers.each { |key| @keyboard.up(key) }
|
710
|
+
end
|
711
|
+
end
|
712
|
+
|
713
|
+
class TypeText
|
714
|
+
def initialize(text)
|
715
|
+
@text = text
|
716
|
+
end
|
717
|
+
|
718
|
+
def execute_for(element)
|
719
|
+
element.type_text(@text)
|
720
|
+
end
|
721
|
+
end
|
722
|
+
end
|
723
|
+
|
724
|
+
def hover
|
725
|
+
assert_element_not_stale
|
726
|
+
|
727
|
+
@element.hover
|
728
|
+
end
|
729
|
+
|
730
|
+
def drag_to(element, **options)
|
731
|
+
assert_element_not_stale
|
732
|
+
|
733
|
+
DragTo.new(@page, @element, element.native, options).execute
|
734
|
+
end
|
735
|
+
|
736
|
+
class DragTo
|
737
|
+
MODIFIERS = {
|
738
|
+
alt: 'Alt',
|
739
|
+
ctrl: 'Control',
|
740
|
+
control: 'Control',
|
741
|
+
meta: 'Meta',
|
742
|
+
command: 'Meta',
|
743
|
+
cmd: 'Meta',
|
744
|
+
shift: 'Shift',
|
745
|
+
}.freeze
|
746
|
+
|
747
|
+
# @param page [Puppeteer::Page]
|
748
|
+
# @param source [Puppeteer::ElementHandle]
|
749
|
+
# @param target [Puppeteer::ElementHandle]
|
750
|
+
def initialize(page, source, target, options)
|
751
|
+
@page = page
|
752
|
+
@source = source
|
753
|
+
@target = target
|
754
|
+
@options = options
|
755
|
+
end
|
756
|
+
|
757
|
+
def execute
|
758
|
+
@source.scroll_into_view_if_needed
|
759
|
+
|
760
|
+
# down
|
761
|
+
position_from = center_of(@source)
|
762
|
+
@page.mouse.move(*position_from)
|
763
|
+
@page.mouse.down
|
764
|
+
|
765
|
+
@target.scroll_into_view_if_needed
|
766
|
+
|
767
|
+
# move and up
|
768
|
+
sleep_delay
|
769
|
+
position_to = center_of(@target)
|
770
|
+
with_key_pressing(drop_modifiers) do
|
771
|
+
@page.mouse.move(*position_to, steps: 6)
|
772
|
+
sleep_delay
|
773
|
+
@page.mouse.up
|
774
|
+
end
|
775
|
+
sleep_delay
|
776
|
+
end
|
777
|
+
|
778
|
+
# @param element [Puppeteer::ElementHandle]
|
779
|
+
private def center_of(element)
|
780
|
+
box = element.bounding_box
|
781
|
+
[box.x + box.width / 2, box.y + box.height / 2]
|
782
|
+
end
|
783
|
+
|
784
|
+
private def with_key_pressing(keys, &block)
|
785
|
+
keys.each { |key| @page.keyboard.down(key) }
|
786
|
+
block.call
|
787
|
+
keys.each { |key| @page.keyboard.up(key) }
|
788
|
+
end
|
789
|
+
|
790
|
+
# @returns Array<String>
|
791
|
+
private def drop_modifiers
|
792
|
+
return [] unless @options[:drop_modifiers]
|
793
|
+
|
794
|
+
Array(@options[:drop_modifiers]).map do |key|
|
795
|
+
MODIFIERS[key.to_sym] or raise ArgumentError.new("Unknown modifier key: #{key}")
|
796
|
+
end
|
797
|
+
end
|
798
|
+
|
799
|
+
private def sleep_delay
|
800
|
+
return unless @options[:delay]
|
801
|
+
|
802
|
+
sleep @options[:delay]
|
803
|
+
end
|
804
|
+
end
|
805
|
+
|
806
|
+
def drop(*args)
|
807
|
+
raise NotImplementedError
|
808
|
+
end
|
809
|
+
|
810
|
+
def scroll_by(x, y)
|
811
|
+
assert_element_not_stale
|
812
|
+
|
813
|
+
js = <<~JAVASCRIPT
|
814
|
+
(el, x, y) => {
|
815
|
+
if (el.scrollBy){
|
816
|
+
el.scrollBy(x, y);
|
817
|
+
} else {
|
818
|
+
el.scrollTop = el.scrollTop + y;
|
819
|
+
el.scrollLeft = el.scrollLeft + x;
|
820
|
+
}
|
821
|
+
}
|
822
|
+
JAVASCRIPT
|
823
|
+
|
824
|
+
@element.evaluate(js, x, y)
|
825
|
+
end
|
826
|
+
|
827
|
+
def scroll_to(element, location, position = nil)
|
828
|
+
assert_element_not_stale
|
829
|
+
|
830
|
+
# location, element = element, nil if element.is_a? Symbol
|
831
|
+
if element.is_a?(Capybara::Puppeteer::Node)
|
832
|
+
scroll_element_to_location(element, location)
|
833
|
+
elsif location.is_a?(Symbol)
|
834
|
+
scroll_to_location(location)
|
835
|
+
else
|
836
|
+
scroll_to_coords(*position)
|
837
|
+
end
|
838
|
+
|
839
|
+
self
|
840
|
+
end
|
841
|
+
|
842
|
+
private def scroll_element_to_location(element, location)
|
843
|
+
scroll_opts =
|
844
|
+
case location
|
845
|
+
when :top
|
846
|
+
'true'
|
847
|
+
when :bottom
|
848
|
+
'false'
|
849
|
+
when :center
|
850
|
+
"{behavior: 'instant', block: 'center'}"
|
851
|
+
else
|
852
|
+
raise ArgumentError, "Invalid scroll_to location: #{location}"
|
853
|
+
end
|
854
|
+
|
855
|
+
element.native.evaluate("(el) => { el.scrollIntoView(#{scroll_opts}) }")
|
856
|
+
end
|
857
|
+
|
858
|
+
SCROLL_POSITIONS = {
|
859
|
+
top: '0',
|
860
|
+
bottom: 'el.scrollHeight',
|
861
|
+
center: '(el.scrollHeight - el.clientHeight)/2'
|
862
|
+
}.freeze
|
863
|
+
|
864
|
+
private def scroll_to_location(location)
|
865
|
+
position = SCROLL_POSITIONS[location]
|
866
|
+
|
867
|
+
@element.evaluate(<<~JAVASCRIPT)
|
868
|
+
(el) => {
|
869
|
+
if (el.scrollTo){
|
870
|
+
el.scrollTo(0, #{position});
|
871
|
+
} else {
|
872
|
+
el.scrollTop = #{position};
|
873
|
+
}
|
874
|
+
}
|
875
|
+
JAVASCRIPT
|
876
|
+
end
|
877
|
+
|
878
|
+
private def scroll_to_coords(x, y)
|
879
|
+
js = <<~JAVASCRIPT
|
880
|
+
(el, x, y) => {
|
881
|
+
if (el.scrollTo){
|
882
|
+
el.scrollTo(x, y);
|
883
|
+
} else {
|
884
|
+
el.scrollTop = y;
|
885
|
+
el.scrollLeft = x;
|
886
|
+
}
|
887
|
+
}
|
888
|
+
JAVASCRIPT
|
889
|
+
|
890
|
+
@element.evaluate(js, x, y)
|
891
|
+
end
|
892
|
+
|
893
|
+
def tag_name
|
894
|
+
@tag_name ||= @element.evaluate('e => e.tagName.toLowerCase()')
|
895
|
+
end
|
896
|
+
|
897
|
+
def visible?
|
898
|
+
assert_element_not_stale
|
899
|
+
|
900
|
+
@element.capybara_visible?
|
901
|
+
end
|
902
|
+
|
903
|
+
def obscured?
|
904
|
+
assert_element_not_stale
|
905
|
+
|
906
|
+
@element.capybara_obscured?
|
907
|
+
end
|
908
|
+
|
909
|
+
def checked?
|
910
|
+
assert_element_not_stale
|
911
|
+
|
912
|
+
@element.evaluate('el => !!el.checked')
|
913
|
+
end
|
914
|
+
|
915
|
+
def selected?
|
916
|
+
assert_element_not_stale
|
917
|
+
|
918
|
+
@element.evaluate('el => !!el.selected')
|
919
|
+
end
|
920
|
+
|
921
|
+
def disabled?
|
922
|
+
assert_element_not_stale
|
923
|
+
|
924
|
+
@element.evaluate(<<~JAVASCRIPT)
|
925
|
+
function(el) {
|
926
|
+
const xpath = 'parent::optgroup[@disabled] | \
|
927
|
+
ancestor::select[@disabled] | \
|
928
|
+
parent::fieldset[@disabled] | \
|
929
|
+
ancestor::*[not(self::legend) or preceding-sibling::legend][parent::fieldset[@disabled]]';
|
930
|
+
return el.disabled || document.evaluate(xpath, el, null, XPathResult.BOOLEAN_TYPE, null).booleanValue
|
931
|
+
}
|
932
|
+
JAVASCRIPT
|
933
|
+
end
|
934
|
+
|
935
|
+
def readonly?
|
936
|
+
assert_element_not_stale
|
937
|
+
|
938
|
+
@element.evaluate('el => el.readOnly')
|
939
|
+
end
|
940
|
+
|
941
|
+
def multiple?
|
942
|
+
assert_element_not_stale
|
943
|
+
|
944
|
+
@element.evaluate('el => el.multiple')
|
945
|
+
end
|
946
|
+
|
947
|
+
def rect
|
948
|
+
assert_element_not_stale
|
949
|
+
|
950
|
+
@element.evaluate(<<~JAVASCRIPT)
|
951
|
+
function(el){
|
952
|
+
const rects = [...el.getClientRects()]
|
953
|
+
const rect = rects.find(r => (r.height && r.width)) || el.getBoundingClientRect();
|
954
|
+
return rect.toJSON();
|
955
|
+
}
|
956
|
+
JAVASCRIPT
|
957
|
+
end
|
958
|
+
|
959
|
+
def path
|
960
|
+
assert_element_not_stale
|
961
|
+
|
962
|
+
@element.evaluate(<<~JAVASCRIPT)
|
963
|
+
(el) => {
|
964
|
+
var xml = document;
|
965
|
+
var xpath = '';
|
966
|
+
var pos, tempitem2;
|
967
|
+
if (el.getRootNode && el.getRootNode() instanceof ShadowRoot) {
|
968
|
+
return "(: Shadow DOM element - no XPath :)";
|
969
|
+
};
|
970
|
+
while(el !== xml.documentElement) {
|
971
|
+
pos = 0;
|
972
|
+
tempitem2 = el;
|
973
|
+
while(tempitem2) {
|
974
|
+
if (tempitem2.nodeType === 1 && tempitem2.nodeName === el.nodeName) { // If it is ELEMENT_NODE of the same name
|
975
|
+
pos += 1;
|
976
|
+
}
|
977
|
+
tempitem2 = tempitem2.previousSibling;
|
978
|
+
}
|
979
|
+
if (el.namespaceURI != xml.documentElement.namespaceURI) {
|
980
|
+
xpath = "*[local-name()='"+el.nodeName+"' and namespace-uri()='"+(el.namespaceURI===null?'':el.namespaceURI)+"']["+pos+']'+'/'+xpath;
|
981
|
+
} else {
|
982
|
+
xpath = el.nodeName.toUpperCase()+"["+pos+"]/"+xpath;
|
983
|
+
}
|
984
|
+
el = el.parentNode;
|
985
|
+
}
|
986
|
+
xpath = '/'+xml.documentElement.nodeName.toUpperCase()+'/'+xpath;
|
987
|
+
xpath = xpath.replace(/\\/$/, '');
|
988
|
+
return xpath;
|
989
|
+
}
|
990
|
+
JAVASCRIPT
|
991
|
+
end
|
992
|
+
|
993
|
+
def trigger(event)
|
994
|
+
assert_element_not_stale
|
995
|
+
|
996
|
+
Trigger.new(@element, event).execute
|
997
|
+
end
|
998
|
+
|
999
|
+
class Trigger
|
1000
|
+
# https://github.com/microsoft/playwright/blob/837ee08a53f205325825874bbed8b30e3eb02dd5/src/server/injected/injectedScript.ts#L705
|
1001
|
+
EVENT_TYPES = [
|
1002
|
+
['auxclick', 'mouse'],
|
1003
|
+
['click', 'mouse'],
|
1004
|
+
['dblclick', 'mouse'],
|
1005
|
+
['mousedown','mouse'],
|
1006
|
+
['mouseeenter', 'mouse'],
|
1007
|
+
['mouseleave', 'mouse'],
|
1008
|
+
['mousemove', 'mouse'],
|
1009
|
+
['mouseout', 'mouse'],
|
1010
|
+
['mouseover', 'mouse'],
|
1011
|
+
['mouseup', 'mouse'],
|
1012
|
+
['mouseleave', 'mouse'],
|
1013
|
+
['mousewheel', 'mouse'],
|
1014
|
+
|
1015
|
+
['keydown', 'keyboard'],
|
1016
|
+
['keyup', 'keyboard'],
|
1017
|
+
['keypress', 'keyboard'],
|
1018
|
+
['textInput', 'keyboard'],
|
1019
|
+
|
1020
|
+
['touchstart', 'touch'],
|
1021
|
+
['touchmove', 'touch'],
|
1022
|
+
['touchend', 'touch'],
|
1023
|
+
['touchcancel', 'touch'],
|
1024
|
+
|
1025
|
+
['pointerover', 'pointer'],
|
1026
|
+
['pointerout', 'pointer'],
|
1027
|
+
['pointerenter', 'pointer'],
|
1028
|
+
['pointerleave', 'pointer'],
|
1029
|
+
['pointerdown', 'pointer'],
|
1030
|
+
['pointerup', 'pointer'],
|
1031
|
+
['pointermove', 'pointer'],
|
1032
|
+
['pointercancel', 'pointer'],
|
1033
|
+
['gotpointercapture', 'pointer'],
|
1034
|
+
['lostpointercapture', 'pointer'],
|
1035
|
+
|
1036
|
+
['focus', 'focus'],
|
1037
|
+
['blur', 'focus'],
|
1038
|
+
|
1039
|
+
['drag', 'drag'],
|
1040
|
+
['dragstart', 'drag'],
|
1041
|
+
['dragend', 'drag'],
|
1042
|
+
['dragover', 'drag'],
|
1043
|
+
['dragenter', 'drag'],
|
1044
|
+
['dragleave', 'drag'],
|
1045
|
+
['dragexit', 'drag'],
|
1046
|
+
['drop', 'drag'],
|
1047
|
+
].to_h
|
1048
|
+
|
1049
|
+
def initialize(element, event)
|
1050
|
+
# https://github.com/microsoft/playwright/blob/837ee08a53f205325825874bbed8b30e3eb02dd5/src/server/injected/injectedScript.ts#L629
|
1051
|
+
@element = element
|
1052
|
+
@event_type = event
|
1053
|
+
@event = EVENT_TYPES[event.to_s]
|
1054
|
+
raise ArgumentError.new("Unknown event type: '#{event}'") unless @event
|
1055
|
+
end
|
1056
|
+
|
1057
|
+
def execute
|
1058
|
+
js = <<~JAVASCRIPT
|
1059
|
+
(el, eventType) => {
|
1060
|
+
const eventInit = { bubbles: true, cancelable: true, composed: true };
|
1061
|
+
const event = new #{event_class}(eventType, eventInit);
|
1062
|
+
el.dispatchEvent(event);
|
1063
|
+
}
|
1064
|
+
JAVASCRIPT
|
1065
|
+
|
1066
|
+
@element.evaluate(js, @event_type)
|
1067
|
+
end
|
1068
|
+
|
1069
|
+
private def event_class
|
1070
|
+
case @event
|
1071
|
+
when 'mouse'
|
1072
|
+
:MouseEvent
|
1073
|
+
when 'keyboard'
|
1074
|
+
:KeyboardEvent
|
1075
|
+
when 'touch'
|
1076
|
+
:TouchEvent
|
1077
|
+
when 'pointer'
|
1078
|
+
:PointerEvent
|
1079
|
+
when 'focus'
|
1080
|
+
:FocusEvent
|
1081
|
+
when 'drag'
|
1082
|
+
:DragEvent
|
1083
|
+
else
|
1084
|
+
:Event
|
1085
|
+
end
|
1086
|
+
end
|
1087
|
+
end
|
1088
|
+
|
1089
|
+
def inspect
|
1090
|
+
%(#<#{self.class} tag="#{tag_name}" path="#{path}">)
|
1091
|
+
end
|
1092
|
+
|
1093
|
+
def ==(other)
|
1094
|
+
return false unless other.is_a?(Node)
|
1095
|
+
|
1096
|
+
@element.evaluate('(self, other) => self == other', other.native)
|
1097
|
+
end
|
1098
|
+
|
1099
|
+
def find_xpath(query, **options)
|
1100
|
+
assert_element_not_stale
|
1101
|
+
|
1102
|
+
@element.Sx(query).map do |el|
|
1103
|
+
Node.new(@driver, @page, el)
|
1104
|
+
end
|
1105
|
+
end
|
1106
|
+
|
1107
|
+
def find_css(query, **options)
|
1108
|
+
assert_element_not_stale
|
1109
|
+
|
1110
|
+
@element.query_selector_all(query).map do |el|
|
1111
|
+
Node.new(@driver, @page, el)
|
1112
|
+
end
|
1113
|
+
end
|
1114
|
+
|
1115
|
+
private def capybara_default_wait_time
|
1116
|
+
Capybara.default_max_wait_time * 1000
|
1117
|
+
end
|
1118
|
+
end
|
1119
|
+
end
|
1120
|
+
end
|