capybara-puppeteer-driver 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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