capybara-playwright-driver 0.0.0.pre.r1

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,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