apparition 0.0.1
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/LICENSE +20 -0
- data/README.md +251 -0
- data/lib/capybara/apparition.rb +20 -0
- data/lib/capybara/apparition/browser.rb +532 -0
- data/lib/capybara/apparition/chrome_client.rb +235 -0
- data/lib/capybara/apparition/command.rb +21 -0
- data/lib/capybara/apparition/cookie.rb +51 -0
- data/lib/capybara/apparition/dev_tools_protocol/session.rb +29 -0
- data/lib/capybara/apparition/dev_tools_protocol/target.rb +52 -0
- data/lib/capybara/apparition/dev_tools_protocol/target_manager.rb +37 -0
- data/lib/capybara/apparition/driver.rb +505 -0
- data/lib/capybara/apparition/errors.rb +230 -0
- data/lib/capybara/apparition/frame.rb +90 -0
- data/lib/capybara/apparition/frame_manager.rb +81 -0
- data/lib/capybara/apparition/inspector.rb +49 -0
- data/lib/capybara/apparition/keyboard.rb +383 -0
- data/lib/capybara/apparition/launcher.rb +218 -0
- data/lib/capybara/apparition/mouse.rb +47 -0
- data/lib/capybara/apparition/network_traffic.rb +9 -0
- data/lib/capybara/apparition/network_traffic/error.rb +12 -0
- data/lib/capybara/apparition/network_traffic/request.rb +47 -0
- data/lib/capybara/apparition/network_traffic/response.rb +49 -0
- data/lib/capybara/apparition/node.rb +844 -0
- data/lib/capybara/apparition/page.rb +711 -0
- data/lib/capybara/apparition/utility.rb +15 -0
- data/lib/capybara/apparition/version.rb +7 -0
- data/lib/capybara/apparition/web_socket_client.rb +80 -0
- metadata +245 -0
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Capybara::Apparition
|
4
|
+
class Mouse
|
5
|
+
def initialize(page, keyboard)
|
6
|
+
@page = page
|
7
|
+
@keyboard = keyboard
|
8
|
+
end
|
9
|
+
|
10
|
+
def click_at(x:, y:, button: 'left', count: 1, modifiers: [])
|
11
|
+
move_to(x: x, y: y)
|
12
|
+
@keyboard.yield_with_keys(modifiers) do
|
13
|
+
down(x: x, y: y, button: button, count: count)
|
14
|
+
up(x: x, y: y, button: button, count: count)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def move_to(x:, y:, button: 'none')
|
19
|
+
@page.command('Input.dispatchMouseEvent',
|
20
|
+
type: 'mouseMoved',
|
21
|
+
button: button,
|
22
|
+
x: x,
|
23
|
+
y: y,
|
24
|
+
modifiers: @keyboard.modifiers)
|
25
|
+
end
|
26
|
+
|
27
|
+
def down(x:, y:, button: 'left', count: 1)
|
28
|
+
@page.command('Input.dispatchMouseEvent',
|
29
|
+
type: 'mousePressed',
|
30
|
+
button: button,
|
31
|
+
x: x,
|
32
|
+
y: y,
|
33
|
+
modifiers: @keyboard.modifiers,
|
34
|
+
clickCount: count)
|
35
|
+
end
|
36
|
+
|
37
|
+
def up(x:, y:, button: 'left', count: 1)
|
38
|
+
@page.command('Input.dispatchMouseEvent',
|
39
|
+
type: 'mouseReleased',
|
40
|
+
button: button,
|
41
|
+
x: x,
|
42
|
+
y: y,
|
43
|
+
modifiers: @keyboard.modifiers,
|
44
|
+
clickCount: count)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Capybara::Apparition
|
4
|
+
module NetworkTraffic
|
5
|
+
require 'capybara/apparition/network_traffic/request'
|
6
|
+
require 'capybara/apparition/network_traffic/response'
|
7
|
+
require 'capybara/apparition/network_traffic/error'
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Capybara::Apparition::NetworkTraffic
|
4
|
+
class Request
|
5
|
+
attr_reader :response_parts, :response
|
6
|
+
attr_writer :blocked_params
|
7
|
+
|
8
|
+
def initialize(data, response_parts = [])
|
9
|
+
@data = data
|
10
|
+
@response_parts = response_parts
|
11
|
+
@response = nil
|
12
|
+
@blocked_params = nil
|
13
|
+
end
|
14
|
+
|
15
|
+
def response=(response)
|
16
|
+
@response_parts.push response
|
17
|
+
end
|
18
|
+
|
19
|
+
def request_id
|
20
|
+
@data['requestId']
|
21
|
+
end
|
22
|
+
|
23
|
+
def url
|
24
|
+
@data.dig('request', 'url')
|
25
|
+
end
|
26
|
+
|
27
|
+
def method
|
28
|
+
@data.dig('request', 'method')
|
29
|
+
end
|
30
|
+
|
31
|
+
def headers
|
32
|
+
@data.dig('requst', 'headers')
|
33
|
+
end
|
34
|
+
|
35
|
+
def time
|
36
|
+
@data['timestamp'] && Time.parse(@data['timestamp'])
|
37
|
+
end
|
38
|
+
|
39
|
+
def blocked?
|
40
|
+
!@blocked_params.nil?
|
41
|
+
end
|
42
|
+
|
43
|
+
def error
|
44
|
+
response_parts.last&.error
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Capybara::Apparition::NetworkTraffic
|
4
|
+
class Response
|
5
|
+
def initialize(data)
|
6
|
+
@data = data
|
7
|
+
end
|
8
|
+
|
9
|
+
def url
|
10
|
+
@data['url']
|
11
|
+
end
|
12
|
+
|
13
|
+
def status
|
14
|
+
@data['status']
|
15
|
+
end
|
16
|
+
|
17
|
+
def status_text
|
18
|
+
@data['statusText']
|
19
|
+
end
|
20
|
+
|
21
|
+
def headers
|
22
|
+
@data['headers']
|
23
|
+
end
|
24
|
+
|
25
|
+
def redirect_url
|
26
|
+
@data['redirectURL']
|
27
|
+
end
|
28
|
+
|
29
|
+
def body_size
|
30
|
+
@data['bodySize']
|
31
|
+
end
|
32
|
+
|
33
|
+
def content_type
|
34
|
+
@data['contentType']
|
35
|
+
end
|
36
|
+
|
37
|
+
def from_cache?
|
38
|
+
@data['fromDiskCache'] == true
|
39
|
+
end
|
40
|
+
|
41
|
+
def time
|
42
|
+
@data['timestamp'] && Time.parse(@data['timestamp'])
|
43
|
+
end
|
44
|
+
|
45
|
+
def error
|
46
|
+
Error.new(url: url, code: status, description: status_text)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,844 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ostruct'
|
4
|
+
module Capybara::Apparition
|
5
|
+
class Node < Capybara::Driver::Node
|
6
|
+
attr_reader :page_id
|
7
|
+
|
8
|
+
def initialize(driver, page, remote_object)
|
9
|
+
super(driver, self)
|
10
|
+
@page = page
|
11
|
+
@remote_object = remote_object
|
12
|
+
end
|
13
|
+
|
14
|
+
def id
|
15
|
+
@remote_object
|
16
|
+
end
|
17
|
+
|
18
|
+
def browser
|
19
|
+
driver.browser
|
20
|
+
end
|
21
|
+
|
22
|
+
def parents
|
23
|
+
find('xpath', 'ancestor::*').reverse
|
24
|
+
end
|
25
|
+
|
26
|
+
def find(method, selector)
|
27
|
+
results = if method == :css
|
28
|
+
evaluate_on <<~JS, value: selector
|
29
|
+
function(selector){
|
30
|
+
return Array.from(this.querySelectorAll(selector));
|
31
|
+
}
|
32
|
+
JS
|
33
|
+
else
|
34
|
+
evaluate_on <<~JS, value: selector
|
35
|
+
function(selector){
|
36
|
+
const xpath = document.evaluate(selector, this, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
37
|
+
let results = [];
|
38
|
+
for (let i=0; i < xpath.snapshotLength; i++){
|
39
|
+
results.push(xpath.snapshotItem(i));
|
40
|
+
}
|
41
|
+
return results;
|
42
|
+
}
|
43
|
+
JS
|
44
|
+
end
|
45
|
+
|
46
|
+
results.map { |r_o| Capybara::Apparition::Node.new(driver, @page, r_o['objectId']) }
|
47
|
+
rescue ::Capybara::Apparition::BrowserError => e
|
48
|
+
raise unless e.name =~ /is not a valid (XPath expression|selector)/
|
49
|
+
|
50
|
+
raise Capybara::Apparition::InvalidSelector, [method, selector]
|
51
|
+
end
|
52
|
+
|
53
|
+
def find_xpath(selector)
|
54
|
+
find :xpath, selector
|
55
|
+
end
|
56
|
+
|
57
|
+
def find_css(selector)
|
58
|
+
find :css, selector
|
59
|
+
end
|
60
|
+
|
61
|
+
def all_text
|
62
|
+
text = evaluate_on('function(){ return this.textContent }')
|
63
|
+
text.to_s.gsub(/[\u200b\u200e\u200f]/, '')
|
64
|
+
.gsub(/[\ \n\f\t\v\u2028\u2029]+/, ' ')
|
65
|
+
.gsub(/\A[[:space:]&&[^\u00a0]]+/, '')
|
66
|
+
.gsub(/[[:space:]&&[^\u00a0]]+\z/, '')
|
67
|
+
.tr("\u00a0", ' ')
|
68
|
+
end
|
69
|
+
|
70
|
+
def visible_text
|
71
|
+
return '' unless visible?
|
72
|
+
|
73
|
+
text = evaluate_on <<~JS
|
74
|
+
function(){
|
75
|
+
if (this.nodeName == 'TEXTAREA'){
|
76
|
+
return this.textContent;
|
77
|
+
} else if (this instanceof SVGElement) {
|
78
|
+
return this.textContent;
|
79
|
+
} else {
|
80
|
+
return this.innerText;
|
81
|
+
}
|
82
|
+
}
|
83
|
+
JS
|
84
|
+
text.to_s.gsub(/\A[[:space:]&&[^\u00a0]]+/, '')
|
85
|
+
.gsub(/[[:space:]&&[^\u00a0]]+\z/, '')
|
86
|
+
.gsub(/\n+/, "\n")
|
87
|
+
.tr("\u00a0", ' ')
|
88
|
+
end
|
89
|
+
|
90
|
+
def property(name)
|
91
|
+
evaluate_on('function(name){ return this[name] }', value: name)
|
92
|
+
end
|
93
|
+
|
94
|
+
def attribute(name)
|
95
|
+
if %w[checked selected].include?(name.to_s)
|
96
|
+
property(name)
|
97
|
+
else
|
98
|
+
evaluate_on('function(name){ return this.getAttribute(name)}', value: name)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def [](name)
|
103
|
+
# Although the attribute matters, the property is consistent. Return that in
|
104
|
+
# preference to the attribute for links and images.
|
105
|
+
if ((tag_name == 'img') && (name == 'src')) || ((tag_name == 'a') && (name == 'href'))
|
106
|
+
# if attribute exists get the property
|
107
|
+
return attribute(name) && property(name)
|
108
|
+
end
|
109
|
+
|
110
|
+
value = property(name)
|
111
|
+
value = attribute(name) if value.nil? || value.is_a?(Hash)
|
112
|
+
|
113
|
+
value
|
114
|
+
end
|
115
|
+
|
116
|
+
def attributes
|
117
|
+
evaluate_on <<~JS
|
118
|
+
function(){
|
119
|
+
let attrs = {};
|
120
|
+
for (let attr of this.attributes)
|
121
|
+
attrs[attr.name] = attr.value.replace("\\n","\\\\n");
|
122
|
+
return attrs;
|
123
|
+
}
|
124
|
+
JS
|
125
|
+
end
|
126
|
+
|
127
|
+
def value
|
128
|
+
evaluate_on <<~JS
|
129
|
+
function(){
|
130
|
+
if ((this.tagName == 'SELECT') && this.multiple){
|
131
|
+
let selected = [];
|
132
|
+
for (let option of this.children) {
|
133
|
+
if (option.selected) {
|
134
|
+
selected.push(option.value);
|
135
|
+
}
|
136
|
+
}
|
137
|
+
return selected;
|
138
|
+
} else {
|
139
|
+
return this.value;
|
140
|
+
}
|
141
|
+
}
|
142
|
+
JS
|
143
|
+
end
|
144
|
+
|
145
|
+
def set(value, **_options)
|
146
|
+
if tag_name == 'input'
|
147
|
+
case self[:type]
|
148
|
+
when 'radio'
|
149
|
+
click
|
150
|
+
when 'checkbox'
|
151
|
+
click if value != checked?
|
152
|
+
when 'file'
|
153
|
+
files = value.respond_to?(:to_ary) ? value.to_ary.map(&:to_s) : value.to_s
|
154
|
+
set_files(files)
|
155
|
+
when 'date'
|
156
|
+
set_date(value)
|
157
|
+
when 'time'
|
158
|
+
set_time(value)
|
159
|
+
when 'datetime-local'
|
160
|
+
set_datetime_local(value)
|
161
|
+
else
|
162
|
+
set_text(value.to_s)
|
163
|
+
end
|
164
|
+
elsif tag_name == 'textarea'
|
165
|
+
set_text(value.to_s)
|
166
|
+
elsif self[:isContentEditable]
|
167
|
+
delete_text
|
168
|
+
send_keys(value.to_s)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def select_option
|
173
|
+
return false if disabled?
|
174
|
+
|
175
|
+
evaluate_on <<~JS
|
176
|
+
function(){
|
177
|
+
let sel = this.parentNode;
|
178
|
+
if (sel.tagName == 'OPTGROUP'){
|
179
|
+
sel = sel.parentNode;
|
180
|
+
}
|
181
|
+
let event_options = { bubbles: true, cancelable: true };
|
182
|
+
sel.dispatchEvent(new FocusEvent('focus', event_options));
|
183
|
+
|
184
|
+
this.selected = true
|
185
|
+
|
186
|
+
sel.dispatchEvent(new Event('change', event_options));
|
187
|
+
sel.dispatchEvent(new FocusEvent('blur', event_options));
|
188
|
+
|
189
|
+
}
|
190
|
+
JS
|
191
|
+
true
|
192
|
+
end
|
193
|
+
|
194
|
+
def unselect_option
|
195
|
+
return false if disabled?
|
196
|
+
|
197
|
+
res = evaluate_on <<~JS
|
198
|
+
function(){
|
199
|
+
let sel = this.parentNode;
|
200
|
+
if (sel.tagName == 'OPTGROUP') {
|
201
|
+
sel = sel.parentNode;
|
202
|
+
}
|
203
|
+
|
204
|
+
if (!sel.multiple){
|
205
|
+
return false;
|
206
|
+
}
|
207
|
+
|
208
|
+
this.selected = false;
|
209
|
+
return true;
|
210
|
+
}
|
211
|
+
JS
|
212
|
+
res || raise(Capybara::UnselectNotAllowed, 'Cannot unselect option from single select box.')
|
213
|
+
end
|
214
|
+
|
215
|
+
def tag_name
|
216
|
+
@tag_name ||= evaluate_on('function(){ return this.tagName; }').downcase
|
217
|
+
end
|
218
|
+
|
219
|
+
def visible?
|
220
|
+
# if an area element, check visibility of relevant image
|
221
|
+
evaluate_on <<~JS
|
222
|
+
function(){
|
223
|
+
el = this;
|
224
|
+
if (el.tagName == 'AREA'){
|
225
|
+
const map_name = document.evaluate('./ancestor::map/@name', el, null, XPathResult.STRING_TYPE, null).stringValue;
|
226
|
+
el = document.querySelector(`img[usemap='#${map_name}']`);
|
227
|
+
if (!el){
|
228
|
+
return false;
|
229
|
+
}
|
230
|
+
}
|
231
|
+
|
232
|
+
while (el) {
|
233
|
+
const style = window.getComputedStyle(el);
|
234
|
+
if ((style.display == 'none') ||
|
235
|
+
(style.visibility == 'hidden') ||
|
236
|
+
(parseFloat(style.opacity) == 0)) {
|
237
|
+
return false;
|
238
|
+
}
|
239
|
+
el = el.parentElement;
|
240
|
+
}
|
241
|
+
return true;
|
242
|
+
}
|
243
|
+
JS
|
244
|
+
end
|
245
|
+
|
246
|
+
def checked?
|
247
|
+
self[:checked]
|
248
|
+
end
|
249
|
+
|
250
|
+
def selected?
|
251
|
+
!!self[:selected]
|
252
|
+
end
|
253
|
+
|
254
|
+
def disabled?
|
255
|
+
evaluate_on <<~JS
|
256
|
+
function() {
|
257
|
+
const xpath = 'parent::optgroup[@disabled] | \
|
258
|
+
ancestor::select[@disabled] | \
|
259
|
+
parent::fieldset[@disabled] | \
|
260
|
+
ancestor::*[not(self::legend) or preceding-sibling::legend][parent::fieldset[@disabled]]';
|
261
|
+
return this.disabled || document.evaluate(xpath, this, null, XPathResult.BOOLEAN_TYPE, null).booleanValue
|
262
|
+
}
|
263
|
+
JS
|
264
|
+
end
|
265
|
+
|
266
|
+
def click(keys = [], button: 'left', count: 1, **options)
|
267
|
+
pos = if options[:x] && options[:y]
|
268
|
+
visible_top_left.tap do |p|
|
269
|
+
p[:x] += options[:x]
|
270
|
+
p[:y] += options[:y]
|
271
|
+
end
|
272
|
+
else
|
273
|
+
visible_center
|
274
|
+
end
|
275
|
+
raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['click']) if pos.nil?
|
276
|
+
|
277
|
+
test = mouse_event_test(pos)
|
278
|
+
raise ::Capybara::Apparition::MouseEventFailed.new(self, 'args' => ['click', test.selector, pos]) unless test.success
|
279
|
+
|
280
|
+
@page.mouse.click_at pos.merge(button: button, count: count, modifiers: keys)
|
281
|
+
puts 'Waiting to see if click triggered page load' if ENV['DEBUG']
|
282
|
+
sleep 0.1
|
283
|
+
@page.wait_for_loaded(allow_obsolete: true)
|
284
|
+
end
|
285
|
+
|
286
|
+
def right_click(keys = [], **options)
|
287
|
+
click(keys, button: 'right', **options)
|
288
|
+
end
|
289
|
+
|
290
|
+
def double_click(keys = [], **options)
|
291
|
+
click(keys, count: 2, **options)
|
292
|
+
end
|
293
|
+
|
294
|
+
def hover
|
295
|
+
pos = visible_center
|
296
|
+
raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['hover']) if pos.nil?
|
297
|
+
|
298
|
+
@page.mouse.move_to(pos)
|
299
|
+
end
|
300
|
+
|
301
|
+
def drag_to(other, delay: 0.1)
|
302
|
+
pos = visible_center
|
303
|
+
raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['drag_to']) if pos.nil?
|
304
|
+
|
305
|
+
test = mouse_event_test(pos)
|
306
|
+
raise ::Capybara::Apparition::MouseEventFailed.new(self, 'args' => ['drag', test.selector, pos]) unless test.success
|
307
|
+
|
308
|
+
begin
|
309
|
+
@page.mouse.move_to(pos)
|
310
|
+
@page.mouse.down(pos)
|
311
|
+
sleep delay
|
312
|
+
|
313
|
+
other_pos = other.visible_center
|
314
|
+
raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['drag_to']) if other_pos.nil?
|
315
|
+
|
316
|
+
@page.mouse.move_to(other_pos.merge(button: 'left'))
|
317
|
+
sleep delay
|
318
|
+
ensure
|
319
|
+
@page.mouse.up(other_pos)
|
320
|
+
end
|
321
|
+
end
|
322
|
+
|
323
|
+
def drag_by(x, y, delay: 0.1)
|
324
|
+
pos = visible_center
|
325
|
+
raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['hover']) if pos.nil?
|
326
|
+
|
327
|
+
other_pos = { x: pos[:x] + x, y: pos[:y] + y }
|
328
|
+
raise ::Capybara::Apparition::MouseEventFailed.new(self, 'args' => ['drag', test['selector'], pos]) unless mouse_event_test?(pos)
|
329
|
+
|
330
|
+
@page.mouse.move_to(pos)
|
331
|
+
@page.mouse.down(pos)
|
332
|
+
sleep delay
|
333
|
+
@page.mouse.move_to(other_pos.merge(button: 'left'))
|
334
|
+
sleep delay
|
335
|
+
@page.mouse.up(other_pos)
|
336
|
+
end
|
337
|
+
|
338
|
+
EVENTS = {
|
339
|
+
blur: ['FocusEvent'],
|
340
|
+
focus: ['FocusEvent'],
|
341
|
+
focusin: ['FocusEvent', { bubbles: true }],
|
342
|
+
focusout: ['FocusEvent', { bubbles: true }],
|
343
|
+
click: ['MouseEvent', { bubbles: true, cancelable: true }],
|
344
|
+
dblckick: ['MouseEvent', { bubbles: true, cancelable: true }],
|
345
|
+
mousedown: ['MouseEvent', { bubbles: true, cancelable: true }],
|
346
|
+
mouseup: ['MouseEvent', { bubbles: true, cancelable: true }],
|
347
|
+
mouseenter: ['MouseEvent'],
|
348
|
+
mouseleave: ['MouseEvent'],
|
349
|
+
mousemove: ['MouseEvent', { bubbles: true, cancelable: true }],
|
350
|
+
submit: ['Event', { bubbles: true, cancelable: true }]
|
351
|
+
}.freeze
|
352
|
+
|
353
|
+
def trigger(name, **options)
|
354
|
+
raise ArgumentError, 'Unknown event' unless EVENTS.key?(name.to_sym)
|
355
|
+
|
356
|
+
event_type, opts = EVENTS[name.to_sym]
|
357
|
+
opts ||= {}
|
358
|
+
|
359
|
+
evaluate_on <<~JS, { value: name }, value: opts.merge(options)
|
360
|
+
function(name, options){
|
361
|
+
var event = new #{event_type}(name, options);
|
362
|
+
this.dispatchEvent(event);
|
363
|
+
}
|
364
|
+
JS
|
365
|
+
end
|
366
|
+
|
367
|
+
def ==(other)
|
368
|
+
evaluate_on('function(el){ return this == el; }', objectId: other.id)
|
369
|
+
rescue ObsoleteNode
|
370
|
+
false
|
371
|
+
end
|
372
|
+
|
373
|
+
def send_keys(*keys)
|
374
|
+
selected = evaluate_on <<~JS
|
375
|
+
function(){
|
376
|
+
let selectedNode = document.getSelection().focusNode;
|
377
|
+
if (!selectedNode)
|
378
|
+
return false;
|
379
|
+
if (selectedNode.nodeType == 3)
|
380
|
+
selectedNode = selectedNode.parentNode;
|
381
|
+
return this.contains(selectedNode);
|
382
|
+
}
|
383
|
+
JS
|
384
|
+
click unless selected
|
385
|
+
@page.keyboard.type(keys)
|
386
|
+
end
|
387
|
+
alias_method :send_key, :send_keys
|
388
|
+
|
389
|
+
def path
|
390
|
+
evaluate_on <<~JS
|
391
|
+
function(){
|
392
|
+
const xpath = document.evaluate('ancestor-or-self::node()', this, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
393
|
+
let elements = [];
|
394
|
+
for (let i=1; i<xpath.snapshotLength; i++){
|
395
|
+
elements.push(xpath.snapshotItem(i));
|
396
|
+
}
|
397
|
+
let selectors = elements.map( el => {
|
398
|
+
prev_siblings = document.evaluate(`./preceding-sibling::${el.tagName}`, el, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
399
|
+
return `${el.tagName}[${prev_siblings.snapshotLength + 1}]`;
|
400
|
+
})
|
401
|
+
return '//' + selectors.join('/');
|
402
|
+
}
|
403
|
+
JS
|
404
|
+
end
|
405
|
+
|
406
|
+
def visible_top_left
|
407
|
+
evaluate_on('function(){ this.scrollIntoViewIfNeeded() }')
|
408
|
+
# result = @page.command('DOM.getBoxModel', objectId: id)
|
409
|
+
result = evaluate_on <<~JS
|
410
|
+
function(){
|
411
|
+
var rect = this.getBoundingClientRect();
|
412
|
+
return rect.toJSON();
|
413
|
+
}
|
414
|
+
JS
|
415
|
+
|
416
|
+
return nil if result.nil?
|
417
|
+
|
418
|
+
result = result['model'] if result['model']
|
419
|
+
frame_offset = @page.current_frame_offset
|
420
|
+
|
421
|
+
if (result['width'].zero? || result['height'].zero?) && (tag_name == 'area')
|
422
|
+
map = find('xpath', 'ancestor::map').first
|
423
|
+
img = find('xpath', "//img[@usemap='##{map[:name]}']").first
|
424
|
+
return nil unless img.visible?
|
425
|
+
|
426
|
+
img_pos = img.top_left
|
427
|
+
coords = self[:coords].split(',').map(&:to_i)
|
428
|
+
|
429
|
+
offset_pos = case self[:shape]
|
430
|
+
when 'rect'
|
431
|
+
{ x: coords[0], y: coords[1] }
|
432
|
+
when 'circle'
|
433
|
+
{ x: coords[0], y: coords[1] }
|
434
|
+
when 'poly'
|
435
|
+
raise 'TODO: Poly not implemented'
|
436
|
+
else
|
437
|
+
raise 'Unknown Shape'
|
438
|
+
end
|
439
|
+
|
440
|
+
{ x: img_pos[:x] + offset_pos[:x] + frame_offset[:x],
|
441
|
+
y: img_pos[:y] + offset_pos[:y] + frame_offset[:y] }
|
442
|
+
else
|
443
|
+
{ x: result['left'] + frame_offset[:x],
|
444
|
+
y: result['top'] + frame_offset[:y] }
|
445
|
+
end
|
446
|
+
end
|
447
|
+
|
448
|
+
def visible_center
|
449
|
+
evaluate_on('function(){ this.scrollIntoViewIfNeeded() }')
|
450
|
+
# result = @page.command('DOM.getBoxModel', objectId: id)
|
451
|
+
result = evaluate_on <<~JS
|
452
|
+
function(){
|
453
|
+
var rect = this.getBoundingClientRect();
|
454
|
+
return rect.toJSON();
|
455
|
+
}
|
456
|
+
JS
|
457
|
+
|
458
|
+
return nil if result.nil?
|
459
|
+
|
460
|
+
result = result['model'] if result['model']
|
461
|
+
frame_offset = @page.current_frame_offset
|
462
|
+
|
463
|
+
if (result['width'].zero? || result['height'].zero?) && (tag_name == 'area')
|
464
|
+
map = find('xpath', 'ancestor::map').first
|
465
|
+
img = find('xpath', "//img[@usemap='##{map[:name]}']").first
|
466
|
+
return nil unless img.visible?
|
467
|
+
|
468
|
+
img_pos = img.top_left
|
469
|
+
coords = self[:coords].split(',').map(&:to_i)
|
470
|
+
|
471
|
+
offset_pos = case self[:shape]
|
472
|
+
when 'rect'
|
473
|
+
{ x: (coords[0] + coords[2]) / 2,
|
474
|
+
y: (coords[1] + coords[2]) / 2 }
|
475
|
+
when 'circle'
|
476
|
+
{ x: coords[0], y: coords[1] }
|
477
|
+
when 'poly'
|
478
|
+
raise 'TODO: Poly not implemented'
|
479
|
+
else
|
480
|
+
raise 'Unknown Shape'
|
481
|
+
end
|
482
|
+
|
483
|
+
{ x: img_pos[:x] + offset_pos[:x] + frame_offset[:x],
|
484
|
+
y: img_pos[:y] + offset_pos[:y] + frame_offset[:y] }
|
485
|
+
else
|
486
|
+
lm = @page.command('Page.getLayoutMetrics')
|
487
|
+
# quad = result["border"]
|
488
|
+
# xs,ys = quad.partition.with_index { |_, idx| idx.even? }
|
489
|
+
xs = [result['left'], result['right']]
|
490
|
+
ys = [result['top'], result['bottom']]
|
491
|
+
x_extents, y_extents = xs.minmax, ys.minmax
|
492
|
+
|
493
|
+
x_extents[1] = [x_extents[1], lm['layoutViewport']['clientWidth']].min
|
494
|
+
y_extents[1] = [y_extents[1], lm['layoutViewport']['clientHeight']].min
|
495
|
+
|
496
|
+
{ x: (x_extents.sum / 2) + frame_offset[:x],
|
497
|
+
y: (y_extents.sum / 2) + frame_offset[:y] }
|
498
|
+
end
|
499
|
+
end
|
500
|
+
|
501
|
+
def top_left
|
502
|
+
result = evaluate_on <<~JS
|
503
|
+
function(){
|
504
|
+
rect = this.getBoundingClientRect();
|
505
|
+
return rect.toJSON();
|
506
|
+
}
|
507
|
+
JS
|
508
|
+
# @page.command('DOM.getBoxModel', objectId: id)
|
509
|
+
return nil if result.nil?
|
510
|
+
|
511
|
+
# { x: result["model"]["content"][0],
|
512
|
+
# y: result["model"]["content"][1] }
|
513
|
+
{ x: result['x'],
|
514
|
+
y: result['y'] }
|
515
|
+
end
|
516
|
+
|
517
|
+
def scroll_by(x, y)
|
518
|
+
driver.execute_script <<~JS, self, x, y
|
519
|
+
var el = arguments[0];
|
520
|
+
if (el.scrollBy){
|
521
|
+
el.scrollBy(arguments[1], arguments[2]);
|
522
|
+
} else {
|
523
|
+
el.scrollTop = el.scrollTop + arguments[2];
|
524
|
+
el.scrollLeft = el.scrollLeft + arguments[1];
|
525
|
+
}
|
526
|
+
JS
|
527
|
+
end
|
528
|
+
|
529
|
+
def scroll_to(element, location, position = nil)
|
530
|
+
# location, element = element, nil if element.is_a? Symbol
|
531
|
+
if element.is_a? Capybara::Apparition::Node
|
532
|
+
scroll_element_to_location(element, location)
|
533
|
+
elsif location.is_a? Symbol
|
534
|
+
scroll_to_location(location)
|
535
|
+
else
|
536
|
+
scroll_to_coords(*position)
|
537
|
+
end
|
538
|
+
self
|
539
|
+
end
|
540
|
+
|
541
|
+
private
|
542
|
+
|
543
|
+
def filter_text(text)
|
544
|
+
text.to_s.gsub(/[[:space:]]+/, ' ').strip
|
545
|
+
end
|
546
|
+
|
547
|
+
def evaluate_on(page_function, *args)
|
548
|
+
obsolete_checked_function = <<~JS
|
549
|
+
function(){
|
550
|
+
if (!this.ownerDocument.contains(this)) { throw 'ObsoleteNode' };
|
551
|
+
return #{page_function.strip}.apply(this, arguments);
|
552
|
+
}
|
553
|
+
JS
|
554
|
+
response = @page.command('Runtime.callFunctionOn',
|
555
|
+
functionDeclaration: obsolete_checked_function,
|
556
|
+
objectId: id,
|
557
|
+
returnByValue: false,
|
558
|
+
awaitPromise: true,
|
559
|
+
arguments: args)
|
560
|
+
process_response(response)
|
561
|
+
end
|
562
|
+
|
563
|
+
def process_response(response)
|
564
|
+
exception_details = response['exceptionDetails']
|
565
|
+
if exception_details && (exception = exception_details['exception'])
|
566
|
+
case exception['className']
|
567
|
+
when 'DOMException'
|
568
|
+
raise ::Capybara::Apparition::BrowserError.new('name' => exception['description'], 'args' => nil)
|
569
|
+
else
|
570
|
+
raise ::Capybara::Apparition::ObsoleteNode.new(self, '') if exception['value'] == 'ObsoleteNode'
|
571
|
+
|
572
|
+
puts "Unknown Exception: #{exception['value']}"
|
573
|
+
end
|
574
|
+
raise exception_details
|
575
|
+
end
|
576
|
+
|
577
|
+
result = response['result'] || response ['object']
|
578
|
+
if result['type'] == 'object'
|
579
|
+
if result['subtype'] == 'array'
|
580
|
+
remote_object = @page.command('Runtime.getProperties',
|
581
|
+
objectId: result['objectId'],
|
582
|
+
ownProperties: true)
|
583
|
+
|
584
|
+
return extract_properties_array(remote_object['result'])
|
585
|
+
elsif result['subtype'] == 'node'
|
586
|
+
return result
|
587
|
+
elsif result['className'] == 'Object'
|
588
|
+
remote_object = @page.command('Runtime.getProperties',
|
589
|
+
objectId: result['objectId'],
|
590
|
+
ownProperties: true)
|
591
|
+
extract_properties_object(remote_object['result'])
|
592
|
+
else
|
593
|
+
result['value']
|
594
|
+
end
|
595
|
+
else
|
596
|
+
result['value']
|
597
|
+
end
|
598
|
+
end
|
599
|
+
|
600
|
+
def set_text(value, clear: nil, **_unused)
|
601
|
+
value = value.to_s
|
602
|
+
if value.empty? && clear.nil?
|
603
|
+
evaluate_on <<~JS
|
604
|
+
function() {
|
605
|
+
this.focus();
|
606
|
+
this.value = '';
|
607
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
608
|
+
}
|
609
|
+
JS
|
610
|
+
elsif clear == :backspace
|
611
|
+
# Clear field by sending the correct number of backspace keys.
|
612
|
+
backspaces = [:backspace] * self.value.to_s.length
|
613
|
+
send_keys(*([:end] + backspaces + [value]))
|
614
|
+
elsif clear.is_a? Array
|
615
|
+
send_keys(*clear, value)
|
616
|
+
else
|
617
|
+
# Clear field by JavaScript assignment of the value property.
|
618
|
+
# Script can change a readonly element which user input cannot, so
|
619
|
+
# don't execute if readonly.
|
620
|
+
driver.execute_script "arguments[0].value = ''", self unless clear == :none
|
621
|
+
send_keys(value)
|
622
|
+
end
|
623
|
+
end
|
624
|
+
|
625
|
+
def set_files(files)
|
626
|
+
@page.command('DOM.setFileInputFiles',
|
627
|
+
files: Array(files),
|
628
|
+
objectId: id)
|
629
|
+
end
|
630
|
+
|
631
|
+
def set_date(value)
|
632
|
+
value = SettableValue.new(value)
|
633
|
+
return set_text(value) unless value.dateable?
|
634
|
+
|
635
|
+
# TODO: this would be better if locale can be detected and correct keystrokes sent
|
636
|
+
update_value_js(value.to_date_str)
|
637
|
+
end
|
638
|
+
|
639
|
+
def set_time(value)
|
640
|
+
value = SettableValue.new(value)
|
641
|
+
return set_text(value) unless value.timeable?
|
642
|
+
|
643
|
+
# TODO: this would be better if locale can be detected and correct keystrokes sent
|
644
|
+
update_value_js(value.to_time_str)
|
645
|
+
end
|
646
|
+
|
647
|
+
def set_datetime_local(value)
|
648
|
+
value = SettableValue.new(value)
|
649
|
+
return set_text(value) unless value.timeable?
|
650
|
+
|
651
|
+
# TODO: this would be better if locale can be detected and correct keystrokes sent
|
652
|
+
update_value_js(value.to_datetime_str)
|
653
|
+
end
|
654
|
+
|
655
|
+
def update_value_js(value)
|
656
|
+
evaluate_on(<<~JS, value: value)
|
657
|
+
function(value){
|
658
|
+
if (document.activeElement !== this){
|
659
|
+
this.focus();
|
660
|
+
}
|
661
|
+
if (this.value != value) {
|
662
|
+
this.value = value;
|
663
|
+
this.dispatchEvent(new InputEvent('input'));
|
664
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
665
|
+
}
|
666
|
+
}
|
667
|
+
JS
|
668
|
+
end
|
669
|
+
|
670
|
+
def mouse_event_test?(x:, y:)
|
671
|
+
mouse_event_test(x: x, y: y).success
|
672
|
+
end
|
673
|
+
|
674
|
+
def mouse_event_test(x:, y:)
|
675
|
+
frame_offset = @page.current_frame_offset
|
676
|
+
# return { status: 'failure' } if x < 0 || y < 0
|
677
|
+
result = evaluate_on(<<~JS, { value: x - frame_offset[:x] }, value: y - frame_offset[:y])
|
678
|
+
function(x,y){
|
679
|
+
const hit_node = document.elementFromPoint(x,y);
|
680
|
+
if ((hit_node == this) || this.contains(hit_node))
|
681
|
+
return { status: 'success' };
|
682
|
+
|
683
|
+
const getSelector = function(element){
|
684
|
+
if (element == null)
|
685
|
+
return 'Element out of bounds';
|
686
|
+
|
687
|
+
let selector = '';
|
688
|
+
if (element.tagName != 'HTML')
|
689
|
+
selector = getSelector(element.parentNode) + ' ';
|
690
|
+
selector += element.tagName.toLowerCase();
|
691
|
+
if (element.id)
|
692
|
+
selector += `#${element.id}`;
|
693
|
+
|
694
|
+
for (let className of element.classList){
|
695
|
+
if (className != '')
|
696
|
+
selector += `.${className}`;
|
697
|
+
}
|
698
|
+
return selector;
|
699
|
+
}
|
700
|
+
|
701
|
+
return { status: 'failure', selector: getSelector(hit_node) };
|
702
|
+
}
|
703
|
+
JS
|
704
|
+
|
705
|
+
OpenStruct.new(success: result['status'] == 'success', selector: result['selector'])
|
706
|
+
end
|
707
|
+
|
708
|
+
def scroll_element_to_location(element, location)
|
709
|
+
scroll_opts = case location
|
710
|
+
when :top
|
711
|
+
'true'
|
712
|
+
when :bottom
|
713
|
+
'false'
|
714
|
+
when :center
|
715
|
+
"{behavior: 'instant', block: 'center'}"
|
716
|
+
else
|
717
|
+
raise ArgumentError, "Invalid scroll_to location: #{location}"
|
718
|
+
end
|
719
|
+
driver.execute_script <<~JS, element
|
720
|
+
arguments[0].scrollIntoView(#{scroll_opts})
|
721
|
+
JS
|
722
|
+
end
|
723
|
+
|
724
|
+
def scroll_to_location(location)
|
725
|
+
scroll_y = case location
|
726
|
+
when :top
|
727
|
+
'0'
|
728
|
+
when :bottom
|
729
|
+
'arguments[0].scrollHeight'
|
730
|
+
when :center
|
731
|
+
'(arguments[0].scrollHeight - arguments[0].clientHeight)/2'
|
732
|
+
end
|
733
|
+
|
734
|
+
driver.execute_script <<~JS, self
|
735
|
+
arguments[0].scrollTo(0, #{scroll_y});
|
736
|
+
JS
|
737
|
+
end
|
738
|
+
|
739
|
+
def scroll_to_coords(x, y)
|
740
|
+
driver.execute_script <<~JS, self, x, y
|
741
|
+
arguments[0].scrollTo(arguments[1], arguments[2]);
|
742
|
+
JS
|
743
|
+
end
|
744
|
+
|
745
|
+
# evaluate_on("function(hit_node){
|
746
|
+
# if ((this == hit_node) || (this.contains(hit_node)))
|
747
|
+
# return { status: 'success' };
|
748
|
+
#
|
749
|
+
# const getSelector = function(element){
|
750
|
+
# let selector = '';
|
751
|
+
# if (element.tagName != 'HTML')
|
752
|
+
# selector = getSelector(element.parentNode) + ' ';
|
753
|
+
# selector += element.tagName.toLowerCase();
|
754
|
+
# if (element.id)
|
755
|
+
# selector += `#${element.id}`;
|
756
|
+
#
|
757
|
+
# for (let className of element.classList){
|
758
|
+
# if (className != '')
|
759
|
+
# selector += `.${className}`;
|
760
|
+
# }
|
761
|
+
# return selector;
|
762
|
+
# }
|
763
|
+
#
|
764
|
+
# return { status: 'failure', selector: getSelector(hit_node)};
|
765
|
+
# }", objectId: hit_node_id)
|
766
|
+
|
767
|
+
def delete_text
|
768
|
+
evaluate_on <<~JS
|
769
|
+
function(){
|
770
|
+
range = document.createRange();
|
771
|
+
range.selectNodeContents(this);
|
772
|
+
window.getSelection().removeAllRanges();
|
773
|
+
window.getSelection().addRange(range);
|
774
|
+
window.getSelection().deleteFromDocument();
|
775
|
+
window.getSelection().removeAllRanges();
|
776
|
+
}
|
777
|
+
JS
|
778
|
+
end
|
779
|
+
|
780
|
+
# SettableValue encapsulates time/date field formatting
|
781
|
+
class SettableValue
|
782
|
+
attr_reader :value
|
783
|
+
|
784
|
+
def initialize(value)
|
785
|
+
@value = value
|
786
|
+
end
|
787
|
+
|
788
|
+
def to_s
|
789
|
+
value.to_s
|
790
|
+
end
|
791
|
+
|
792
|
+
def dateable?
|
793
|
+
!value.is_a?(String) && value.respond_to?(:to_date)
|
794
|
+
end
|
795
|
+
|
796
|
+
def to_date_str
|
797
|
+
value.to_date.strftime('%Y-%m-%d')
|
798
|
+
end
|
799
|
+
|
800
|
+
def timeable?
|
801
|
+
!value.is_a?(String) && value.respond_to?(:to_time)
|
802
|
+
end
|
803
|
+
|
804
|
+
def to_time_str
|
805
|
+
value.to_time.strftime('%H:%M')
|
806
|
+
end
|
807
|
+
|
808
|
+
def to_datetime_str
|
809
|
+
value.to_time.strftime('%Y-%m-%dT%H:%M')
|
810
|
+
end
|
811
|
+
end
|
812
|
+
private_constant :SettableValue
|
813
|
+
|
814
|
+
def extract_properties_array(properties)
|
815
|
+
properties.each_with_object([]) do |property, results|
|
816
|
+
if property['enumerable']
|
817
|
+
if property.dig('value', 'subtype') == 'node'
|
818
|
+
results.push(property['value'])
|
819
|
+
else
|
820
|
+
# releasePromises.push(helper.releaseObject(@element._client, property.value))
|
821
|
+
results.push(property.dig('value', 'value'))
|
822
|
+
end
|
823
|
+
end
|
824
|
+
# await Promise.all(releasePromises);
|
825
|
+
# id = (@page._elements.push(element)-1 for element from result)[0]
|
826
|
+
#
|
827
|
+
# new Apparition.Node @page, id
|
828
|
+
|
829
|
+
# releasePromises = [helper.releaseObject(@element._client, remote_object)]
|
830
|
+
end
|
831
|
+
end
|
832
|
+
|
833
|
+
def extract_properties_object(properties)
|
834
|
+
properties.each_with_object({}) do |property, object|
|
835
|
+
if property['enumerable']
|
836
|
+
object[property['name']] = property['value']['value']
|
837
|
+
else
|
838
|
+
# releasePromises.push(helper.releaseObject(@element._client, property.value))
|
839
|
+
end
|
840
|
+
# releasePromises = [helper.releaseObject(@element._client, remote_object)]
|
841
|
+
end
|
842
|
+
end
|
843
|
+
end
|
844
|
+
end
|