capybara-playwright-driver 0.0.0.pre.r1

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