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.
@@ -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