capybara-playwright-driver 0.5.12 → 0.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a90dd1edbae8b79e2f4d7eac72dcc8014e8d9e491730cbffa20be906c74ee5c
4
- data.tar.gz: 729834c2bd8e56d523230e043f3d148777a89e2870da1624294d109eadd9434a
3
+ metadata.gz: 35fdde9b43c2e26b14f271e049ca2a9599d36552a777a00dacaaf6e498b96581
4
+ data.tar.gz: acf38394ff2727fb1cf519d67401c8c1012bd9b7410dfbc3e7cbeb599fc9401d
5
5
  SHA512:
6
- metadata.gz: 9b674a26ae213904eacec537a259f9111c5c4686d2317691bfe6bd965ad9fe4d57bad3dfb701cb2f456676f7a9d50ed94b50fb94723f83055b1bd79cb2c3d95c
7
- data.tar.gz: 350f3c693c186594ccac2aa32e05cfd762f7baa069e01ebd6df8d20c744a3e72dfdfdd03a29728fcce77b5b173bd3bc6a2efcaedb55ca66fde43b8d55340bd9b
6
+ metadata.gz: 991e90ee94c3ed6e7cb44fc1a1f838e95b7de72cb29ce271ce7c65e0069c712a8c569c8e836bcf6b62bd3932dfcb79ba5569b35497cf6e8dc6659099de8adeca
7
+ data.tar.gz: 2da9229fcb4f1856f34220aaddb8bff505eb12cda450706e754f678372c6b48c7c133099cdf8420788abcf80ffa35658cb740024410e4ad2e1a30dae2483542a
data/README.md CHANGED
@@ -46,6 +46,16 @@ Refer the [documentation](https://playwright-ruby-client.vercel.app/docs/article
46
46
 
47
47
  ## Selenium compatibility
48
48
 
49
+ `visible?` and selector filters such as `visible: true` do not account for ancestor overflow clipping. An element
50
+ completely clipped by an ancestor with `overflow: hidden` can still be considered visible by this driver, while
51
+ Selenium considers it hidden. This can change the results of queries such as `all('.item', visible: true)` when
52
+ migrating Selenium tests.
53
+
54
+ Playwright's native visibility methods (`ElementHandle#visible?` and `Locator#visible?` in playwright-ruby-client)
55
+ also consider these clipped elements visible. If a test needs to count items actually displayed inside a clipped
56
+ container, check their geometry relative to the container explicitly. See
57
+ [#174](https://github.com/YusukeIwaki/capybara-playwright-driver/issues/174) for the driver comparison and discussion.
58
+
49
59
  Visible text for ordinary HTML elements follows the browser's
50
60
  [`innerText` behavior](https://html.spec.whatwg.org/multipage/dom.html#the-innertext-idl-attribute). This preserves
51
61
  meaningful blank lines, but paragraph and rich-text-editor boundaries can differ from Selenium's WebDriver text
@@ -55,18 +65,31 @@ algorithm. When exact whitespace is not part of an assertion, use Capybara's `no
55
65
  expect(message).to have_text('Hello Regards', normalize_ws: true)
56
66
  ```
57
67
 
58
- This driver delegates keyboard focus and caret behavior to Playwright rather than overriding it with JavaScript. One
59
- observable difference is that when a `contenteditable` element loses focus between `send_keys` calls, Playwright may
60
- place the caret differently from Selenium when the element is focused again. Specify the caret movement explicitly
61
- when the test depends on it:
68
+ Element `send_keys` follows WebDriver's focus and selection rules: an unfocused text input, textarea, or
69
+ `contenteditable` starts at the end of its content; an already focused element keeps its caret or selection.
70
+ The element is prepared once per call. Subsequent keys follow focus changes made by the application, including Tab
71
+ navigation and autocomplete controls. For example, a new call can return to an editor after its autocomplete takes focus:
62
72
 
63
73
  ```ruby
64
- editor.send_keys(:end, '@') # The page moves focus to an autocomplete control.
65
- editor.send_keys(:end, 'alice')
74
+ editor.send_keys('@') # The page moves focus to an autocomplete control.
75
+ editor.send_keys('alice') # Focus the editor again and append at its end.
66
76
  ```
67
77
 
68
- For text inputs and textareas, `fill_in` uses Playwright's typing behavior for every character regardless of the value
69
- length. Playwright sends `keydown`, `keypress`/`input`, and `keyup` for characters on a US keyboard, but sends only
78
+ `set`, `fill_in`, and `send_keys` use the same keyboard sequence handling, including Enter for newlines and Tab for
79
+ tab characters. Replacement selects the target content before typing, including inherited `contenteditable` children.
80
+ `clear: :none` preserves an existing selection when the input already has focus; `clear: :backspace` and key arrays
81
+ send their clearing keys before the replacement text. Contenteditable replacement retains Playwright's visibility,
82
+ enabled-state, and editability waiting. Element key presses retain navigation waiting while following the current
83
+ focus. Elements replaced during waiting, focus, or selection can be retried before typing starts. Once typing begins,
84
+ the sequence is not replayed when the original element is replaced. A Playwright error during dispatch raises
85
+ `Capybara::Playwright::Node::InputInterruptedError` rather than retrying potentially delivered input.
86
+
87
+ The compatibility specs record two intentional differences from Capybara Selenium: setting an empty string clears
88
+ `contenteditable` content, and a focused input inside a shadow root keeps its selection instead of moving to the end.
89
+ They also retain Playwright's wait for ARIA disabled/readonly state, which Selenium does not apply to contenteditable.
90
+
91
+ Text entry uses Playwright's typing behavior for every character regardless of the value length. Playwright sends
92
+ `keydown`, `keypress`/`input`, and `keyup` for characters on a US keyboard, but sends only
70
93
  `input` for other characters, including Japanese text. This follows
71
94
  [Playwright's documented keyboard behavior](https://playwright.dev/docs/api/class-keyboard#keyboard-type). Typing very
72
95
  large values is slower than Playwright's native `fill`, which does not send per-character keyboard events. When input
@@ -238,7 +238,7 @@ module Capybara
238
238
  }
239
239
  end
240
240
 
241
- def send_keys(*args)
241
+ def send_keys(*args, **options)
242
242
  Node::SendKeys.new(@playwright_page.keyboard, args).execute
243
243
  end
244
244
 
@@ -422,8 +422,9 @@ module Capybara
422
422
  when ::Playwright::ElementHandle
423
423
  Node.new(@driver, @internal_logger, @playwright_page, arg)
424
424
  when ::Playwright::JSHandle
425
- obj_type, is_array = arg.evaluate('obj => [typeof obj, Array.isArray(obj)]')
426
- if obj_type == 'object'
425
+ # typeof null is 'object', so check null separately and let json_value return nil.
426
+ obj_type, is_array, is_null = arg.evaluate('obj => [typeof obj, Array.isArray(obj), obj === null]')
427
+ if obj_type == 'object' && !is_null
427
428
  if is_array
428
429
  # Firefox often include 'toJSON' into properties.
429
430
  # https://github.com/microsoft/playwright/issues/7015
@@ -56,86 +56,26 @@ module Capybara
56
56
  end
57
57
 
58
58
  def set_checked_state_via_label?
59
- playwright_checkable = find_playwright_element_handle_by_non_label_locator
60
- playwright_checkable ||= playwright_locator_by_label unless @locator.nil?
61
- return false unless playwright_checkable
59
+ return false if @locator.nil?
62
60
 
63
- click_associated_label?(playwright_checkable)
64
- rescue Capybara::ElementNotFound, Capybara::ExpectationNotMet, ::Playwright::Error
61
+ checkable = @node.find(@node_type, @locator, visible: :all, wait: 0)
62
+ click_associated_label?(checkable.native)
63
+ rescue Capybara::ElementNotFound, Capybara::ExpectationNotMet,
64
+ Capybara::Playwright::Node::StaleReferenceError, ::Playwright::Error
65
65
  false
66
66
  end
67
67
 
68
68
  private
69
69
 
70
- def playwright_locator_by_label
71
- driver.with_playwright_page do |playwright_page|
72
- return playwright_page.get_by_label(@locator.to_s)
73
- end
74
- end
70
+ def click_associated_label?(element_handle)
71
+ return true if element_handle.evaluate('el => !!el.checked') == @checked
75
72
 
76
- def click_associated_label?(playwright_element_handle_or_locator)
77
- return true if playwright_element_handle_or_locator.evaluate('el => !!el.checked') == @checked
78
-
79
- label_element_handle = playwright_element_handle_or_locator.evaluate_handle('(el) => (el.labels && el.labels[0]) || el.closest("label") || null')
73
+ label_element_handle = element_handle.evaluate_handle('(el) => (el.labels && el.labels[0]) || el.closest("label") || null')
80
74
  return false unless label_element_handle.is_a?(::Playwright::ElementHandle)
81
75
 
82
76
  label_element_handle.click
83
77
 
84
- playwright_element_handle_or_locator.evaluate('el => !!el.checked') == @checked
85
- end
86
-
87
- def find_playwright_element_handle_by_non_label_locator
88
- return nil if @locator.nil?
89
-
90
- locator_string = @locator.to_s
91
- test_id_attr = session_options.test_id&.to_s
92
-
93
- driver.with_playwright_page do |playwright_page|
94
- return non_label_playwright_element_handle_candidates(playwright_page).find do |element_handle|
95
- attribute_values = element_handle.evaluate(<<~JAVASCRIPT, arg: test_id_attr)
96
- (el, testIdAttr) => ({
97
- id: el.id || '',
98
- name: el.getAttribute('name') || '',
99
- testId: testIdAttr ? (el.getAttribute(testIdAttr) || '') : '',
100
- })
101
- JAVASCRIPT
102
- [attribute_values['id'], attribute_values['name'], attribute_values['testId']].include?(locator_string)
103
- end
104
- end
105
- rescue ::Playwright::Error
106
- nil
107
- end
108
-
109
- def non_label_playwright_element_handle_candidates(playwright_page)
110
- input_type =
111
- case @node_type
112
- when :checkbox
113
- 'checkbox'
114
- when :radio_button
115
- 'radio'
116
- else
117
- return []
118
- end
119
-
120
- current_scope = scope_element
121
- return current_scope.query_selector_all(%(input[type="#{input_type}"])) if current_scope
122
-
123
- playwright_page.capybara_current_frame.query_selector_all(%(input[type="#{input_type}"]))
124
- end
125
-
126
- def scope_element
127
- return nil unless @node.is_a?(Capybara::Node::Element)
128
- return nil unless @node.send(:base).is_a?(Capybara::Playwright::Node)
129
-
130
- @node.send(:base).send(:element)
131
- end
132
-
133
- def driver
134
- @node.send(:driver)
135
- end
136
-
137
- def session_options
138
- @node.send(:session_options)
78
+ element_handle.evaluate('el => !!el.checked') == @checked
139
79
  end
140
80
  end
141
81
 
@@ -279,6 +219,7 @@ module Capybara
279
219
 
280
220
  class NotActionableError < StandardError ; end
281
221
  class StaleReferenceError < StandardError ; end
222
+ class InputInterruptedError < StandardError ; end
282
223
  class MissingBoundingBoxError < StandardError ; end
283
224
  class DragInterruptedError < StandardError ; end
284
225
 
@@ -431,109 +372,154 @@ module Capybara
431
372
 
432
373
  class TextInput < Settable
433
374
  def set(value, **options)
375
+ text = value.to_s
376
+ input = KeyboardInput.new(@element, @timeout)
377
+
434
378
  case options[:clear]
435
- when :backspace
436
- @element.press('End', timeout: @timeout)
437
- existing_text = @element.evaluate('el => el.value')
438
- existing_text.length.times { @element.press('Backspace', timeout: @timeout) }
439
379
  when :none
440
- @element.press('End', timeout: @timeout)
441
- ensure_attached
380
+ input.send_keys(text)
381
+ when :backspace
382
+ length = @element.evaluate('el => el.value || el.textContent || ""').length
383
+ input.send_keys(:end, *Array.new(length, :backspace), text)
442
384
  when Array
443
- @internal_logger.warn "options { clear: #{options[:clear]} } is ignored"
444
- end
445
-
446
- text = value.to_s
447
- if press_enter = text.end_with?("\r\n")
448
- text = text[0...-2]
449
- elsif press_enter = text.end_with?("\n")
450
- text = text[0...-1]
385
+ input.send_keys(*options[:clear], text)
386
+ else
387
+ if text.empty?
388
+ @element.fill('', timeout: @timeout)
389
+ else
390
+ input.replace(text)
391
+ end
451
392
  end
452
-
453
- keyboard = @element.owner_frame.page.keyboard
454
- set_text(text, append: options[:clear] == :none, keyboard: keyboard)
455
-
456
- keyboard.press('Enter') if press_enter
457
393
  rescue ::Playwright::TimeoutError
458
394
  raise if @element.editable?
459
395
 
460
396
  @internal_logger.info("Node#set: element is not editable. #{@element}")
461
397
  end
398
+ end
462
399
 
463
- private def set_text(text, append:, keyboard:)
464
- # ElementHandle#type can refocus inherited contenteditable descendants and drop the input.
465
- if @element.evaluate('el => el.isContentEditable') && !append
466
- @element.fill(text, timeout: @timeout)
467
- return
468
- end
469
-
470
- text = text.gsub(/\r\n?/, "\n")
400
+ # Prepare the selection once, then let the browser route the whole sequence.
401
+ # Inherited contenteditable descendants are selected independently of their
402
+ # editing host, which is the element that can actually receive focus.
403
+ class KeyboardInput
404
+ def initialize(element, timeout)
405
+ @element = element
406
+ @timeout = timeout
407
+ end
471
408
 
472
- if text.include?("\t")
473
- type_tab_separated_text(text, append: append, keyboard: keyboard)
474
- return
409
+ def replace(text)
410
+ if @element.evaluate('element => element.isContentEditable')
411
+ # This path used fill(), which waits for visibility and editability
412
+ # (including enabled state). select_text() alone only waits for visibility.
413
+ # https://playwright.dev/docs/actionability
414
+ @element.wait_for_element_state('visible', timeout: @timeout)
415
+ @element.wait_for_element_state('editable', timeout: @timeout)
475
416
  end
417
+ @element.select_text(timeout: @timeout)
418
+ type_keys([text])
419
+ end
476
420
 
477
- if append
478
- type_text(keyboard, text)
479
- elsif text.empty?
480
- @element.fill('', timeout: @timeout)
481
- else
482
- @element.select_text(timeout: @timeout)
483
- replace_text(keyboard, text)
484
- end
421
+ def send_keys(*keys)
422
+ # WebDriver Element Send Keys moves the caret to the end only when the
423
+ # element was not focused. Preserve the selection in an active editor.
424
+ # https://www.w3.org/TR/2026/WD-webdriver2-20260702/#element-send-keys
425
+ #
426
+ # References in ChromeDriver's element_commands.cc:
427
+ # - SendKeysToElement: append to newly focused text controls, then send
428
+ # the whole sequence to the window without refocusing for each key.
429
+ # https://github.com/chromium/chromium/blob/852d53b2cdde761fff9003021300b31c15fc1636/chrome/test/chromedriver/element_commands.cc#L191-L222
430
+ # - ExecuteSendKeysToElement: find the top editable ancestor for focus,
431
+ # but collapse a Range over the requested element's own contents.
432
+ # https://github.com/chromium/chromium/blob/852d53b2cdde761fff9003021300b31c15fc1636/chrome/test/chromedriver/element_commands.cc#L735-L799
433
+ # This adapts the caret preparation, not all of ChromeDriver's
434
+ # interactability checks; our differential specs cover the contract.
435
+ @element.evaluate(<<~JAVASCRIPT)
436
+ element => {
437
+ let host = element;
438
+ if (element.isContentEditable) {
439
+ while (host.parentElement && host.parentElement.isContentEditable)
440
+ host = host.parentElement;
441
+ }
442
+ // Our shadow DOM adaptation of ChromeDriver's active-element check:
443
+ // document.activeElement would identify the shadow host instead.
444
+ const root = host.getRootNode();
445
+ if (root.activeElement === host) return;
446
+
447
+ // Focus first so it cannot reset the Range we are about to create.
448
+ host.focus();
449
+ if (element.isContentEditable) {
450
+ // Use the target's document so the Range also belongs to its iframe.
451
+ const range = element.ownerDocument.createRange();
452
+ range.selectNodeContents(element);
453
+ range.collapse(false);
454
+ const selection = element.ownerDocument.getSelection();
455
+ selection.removeAllRanges();
456
+ selection.addRange(range);
457
+ } else if (element.nodeName === 'TEXTAREA' ||
458
+ (element.nodeName === 'INPUT' && /^(text|search|tel|url|password)$/.test(element.type))) {
459
+ // ChromeDriver's kTextControlTypes: other input types do not
460
+ // support setSelectionRange (e.g. number and email).
461
+ element.setSelectionRange(element.value.length, element.value.length);
462
+ }
463
+ }
464
+ JAVASCRIPT
465
+ type_keys(keys)
485
466
  end
486
467
 
487
- private def replace_text(keyboard, text)
488
- head, *tail = text.split("\n", -1)
489
- if head.empty?
490
- @element.press('Enter', timeout: @timeout)
491
- head = tail.shift
492
- keyboard.type(head) unless head.empty?
493
- else
494
- first_character = head[/\A\X/]
495
- @element.type(first_character, timeout: @timeout)
496
- ensure_attached
497
- remaining_text = head[first_character.length..-1]
498
- keyboard.type(remaining_text) unless remaining_text.empty?
468
+ private def type_keys(keys)
469
+ keyboard = FocusedKeyboard.new(@element.owner_frame.page, @timeout)
470
+ sequence = SendKeys.new(keyboard, keys)
471
+ # Focus/select can replace the element in a microtask. Check before
472
+ # dispatch so Capybara can reload it without replaying any keystrokes.
473
+ @element.enabled?
474
+ # Do not recheck the original handle after input has begun: the app may
475
+ # deliberately replace it or move focus, and replaying can duplicate input.
476
+ begin
477
+ sequence.execute
478
+ rescue ::Playwright::Error => err
479
+ # A navigation or a detached current focus can fail after earlier
480
+ # keys were delivered. Do not turn that into a retry of the whole set.
481
+ raise InputInterruptedError.new(err)
499
482
  end
500
-
501
- type_lines(keyboard, tail)
502
483
  end
484
+ end
503
485
 
504
- private def type_text(keyboard, text)
505
- return if text.empty?
506
-
507
- head, *tail = text.split("\n", -1)
508
- keyboard.type(head) unless head.empty?
509
-
510
- type_lines(keyboard, tail)
486
+ class FocusedKeyboard
487
+ def initialize(page, timeout)
488
+ @page = page
489
+ @timeout = timeout
511
490
  end
512
491
 
513
- private def type_lines(keyboard, parts)
514
- parts.each do |part|
515
- keyboard.press('Enter')
516
- keyboard.type(part) unless part.empty?
517
- end
492
+ def type(text)
493
+ @page.keyboard.type(text)
518
494
  end
519
495
 
520
- private def type_tab_separated_text(text, append:, keyboard:)
521
- head, *tail = text.split("\t", -1)
522
- set_text(head, append: append, keyboard: keyboard)
523
-
524
- tail.each_with_index do |part, index|
525
- if index.zero? && !append && head.empty?
526
- @element.press('Tab', timeout: @timeout)
527
- else
528
- keyboard.press('Tab')
529
- end
530
- type_text(keyboard, part.gsub(/\r\n?/, "\n"))
531
- end
496
+ def press(key)
497
+ # ElementHandle#press waits for navigation triggered by the key, unlike
498
+ # Keyboard#press. Use the current focus so Tab/autocomplete can redirect
499
+ # later keys without returning to the original element.
500
+ # https://playwright.dev/docs/api/class-elementhandle#element-handle-press
501
+ focused = focused_element(@page.main_frame)
502
+ focused.press(key, timeout: @timeout)
503
+ ensure
504
+ focused.dispose if focused
532
505
  end
533
506
 
534
- private def ensure_attached
535
- # Trigger Playwright's stale-element check without changing focus.
536
- @element.enabled?
507
+ private def focused_element(frame)
508
+ # Local focus traversal through shadow roots and frames. Pressing the iframe
509
+ # itself would blur its editor. content_frame also works across origins.
510
+ focused = frame.evaluate_handle(<<~JAVASCRIPT)
511
+ () => {
512
+ let element = document.activeElement;
513
+ while (element && element.shadowRoot && element.shadowRoot.activeElement)
514
+ element = element.shadowRoot.activeElement;
515
+ return element;
516
+ }
517
+ JAVASCRIPT
518
+ child_frame = focused.content_frame
519
+ return focused unless child_frame
520
+
521
+ focused.dispose
522
+ focused_element(child_frame)
537
523
  end
538
524
  end
539
525
 
@@ -745,7 +731,9 @@ module Capybara
745
731
  end
746
732
 
747
733
  def send_keys(*args)
748
- SendKeys.new(@element, args).execute
734
+ assert_element_not_stale do
735
+ KeyboardInput.new(@element, capybara_default_wait_time).send_keys(*args)
736
+ end
749
737
  end
750
738
 
751
739
  class SendKeys
@@ -817,93 +805,63 @@ module Capybara
817
805
  command: 'Meta',
818
806
  }
819
807
 
820
- def initialize(element_or_keyboard, keys)
821
- @element_or_keyboard = element_or_keyboard
808
+ def initialize(keyboard, keys)
809
+ @keyboard = keyboard
810
+ @executables = []
811
+ add_keys(keys)
812
+ end
822
813
 
823
- holding_keys = []
824
- @executables = keys.each_with_object([]) do |key, executables|
825
- if MODIFIERS[key]
826
- holding_keys << key
827
- else
828
- if holding_keys.empty?
829
- case key
830
- when String
831
- executables << TypeText.new(key)
832
- when Symbol
833
- executables << PressKey.new(
834
- key: key_for(key),
835
- modifiers: [],
836
- )
837
- when Array
838
- _key = key.last
839
- code =
840
- if _key.is_a?(String) && _key.length == 1
841
- _key
842
- elsif _key.is_a?(Symbol)
843
- key_for(_key)
844
- else
845
- raise ArgumentError.new("invalid key: #{_key}. Symbol of 1-length String is expected.")
846
- end
847
- modifiers = key.first(key.size - 1).map { |k| modifier_for(k) }
848
- executables << PressKey.new(
849
- key: code,
850
- modifiers: modifiers,
851
- )
852
- end
853
- else
854
- modifiers = holding_keys.map { |k| modifier_for(k) }
855
-
856
- case key
857
- when String
858
- key.each_char do |char|
859
- executables << PressKey.new(
860
- key: char,
861
- modifiers: modifiers,
862
- )
863
- end
864
- when Symbol
865
- executables << PressKey.new(
866
- key: key_for(key),
867
- modifiers: modifiers
868
- )
814
+ private def add_keys(keys, modifiers = [])
815
+ keys.each do |key|
816
+ case key
817
+ when Array
818
+ add_keys(key, modifiers.dup)
819
+ when String
820
+ key.scan(/\r\n|[\r\n\t]|[^\r\n\t]+/).each do |part|
821
+ case part
822
+ when "\t"
823
+ add_key('Tab', modifiers)
824
+ when "\r", "\n", "\r\n"
825
+ add_key('Enter', modifiers)
869
826
  else
870
- raise ArgumentError.new("#{key} cannot be handled with holding key #{holding_keys}")
827
+ if modifiers.empty?
828
+ @executables << TypeText.new(part)
829
+ else
830
+ part.each_char { |character| add_key(character, modifiers) }
831
+ end
871
832
  end
872
833
  end
834
+ when Symbol
835
+ if MODIFIERS.key?(key)
836
+ modifiers << MODIFIERS.fetch(key)
837
+ elsif key == :null
838
+ modifiers.clear
839
+ else
840
+ add_key(KEYS.fetch(key) { raise ArgumentError, "invalid key specified: #{key}" }, modifiers)
841
+ end
842
+ else
843
+ raise ArgumentError, "invalid key specified: #{key.inspect}"
873
844
  end
874
845
  end
875
846
  end
876
847
 
877
- private def modifier_for(modifier)
878
- MODIFIERS[modifier] or raise ArgumentError.new("invalid modifier specified: #{modifier}")
879
- end
880
-
881
- private def key_for(key)
882
- KEYS[key] or raise ArgumentError.new("invalid key specified: #{key}")
848
+ private def add_key(key, modifiers)
849
+ @executables << PressKey.new(key: key, modifiers: modifiers)
883
850
  end
884
851
 
885
852
  def execute
886
- @executables.each do |executable|
887
- executable.execute_for(@element_or_keyboard)
888
- end
853
+ @executables.each { |executable| executable.execute_for(@keyboard) }
889
854
  end
890
855
 
891
856
  class PressKey
892
857
  def initialize(key:, modifiers:)
893
- # Shift requires an explicitly uppercase a-z key to produce the correct output
894
- # See https://playwright.dev/docs/input#keys-and-shortcuts
895
- key = key.upcase if modifiers == [MODIFIERS[:shift]] && key.match?(/^[a-z]$/)
896
-
897
- # puts "PressKey: key=#{key} modifiers: #{modifiers}"
898
- if modifiers.empty?
899
- @key = key
900
- else
901
- @key = (modifiers + [key]).join('+')
902
- end
858
+ # Playwright needs the uppercase key when Shift modifies a letter.
859
+ key = key.upcase if modifiers.include?('Shift') && key.match?(/^[a-z]$/)
860
+ @key = (modifiers + [key]).uniq.join('+')
903
861
  end
904
862
 
905
- def execute_for(element)
906
- element.press(@key)
863
+ def execute_for(keyboard)
864
+ keyboard.press(@key)
907
865
  end
908
866
  end
909
867
 
@@ -912,8 +870,8 @@ module Capybara
912
870
  @text = text
913
871
  end
914
872
 
915
- def execute_for(element)
916
- element.type(@text)
873
+ def execute_for(keyboard)
874
+ keyboard.type(@text)
917
875
  end
918
876
  end
919
877
  end
@@ -1144,7 +1102,9 @@ module Capybara
1144
1102
  end
1145
1103
 
1146
1104
  def tag_name
1147
- @tag_name ||= @element.evaluate('e => e.tagName.toLowerCase()')
1105
+ @tag_name ||= assert_element_not_stale {
1106
+ @element.evaluate('e => e.tagName.toLowerCase()')
1107
+ }
1148
1108
  end
1149
1109
 
1150
1110
  def visible?
@@ -1200,23 +1160,29 @@ module Capybara
1200
1160
  end
1201
1161
 
1202
1162
  def disabled?
1203
- @element.evaluate(<<~JAVASCRIPT)
1204
- function(el) {
1205
- const xpath = 'parent::optgroup[@disabled] | \
1206
- ancestor::select[@disabled] | \
1207
- parent::fieldset[@disabled] | \
1208
- ancestor::*[not(self::legend) or preceding-sibling::legend][parent::fieldset[@disabled]]';
1209
- return el.disabled || document.evaluate(xpath, el, null, XPathResult.BOOLEAN_TYPE, null).booleanValue
1163
+ assert_element_not_stale {
1164
+ @element.evaluate(<<~JAVASCRIPT)
1165
+ function(el) {
1166
+ const xpath = 'parent::optgroup[@disabled] | \
1167
+ ancestor::select[@disabled] | \
1168
+ parent::fieldset[@disabled] | \
1169
+ ancestor::*[not(self::legend) or preceding-sibling::legend][parent::fieldset[@disabled]]';
1170
+ return el.disabled || document.evaluate(xpath, el, null, XPathResult.BOOLEAN_TYPE, null).booleanValue
1171
+ }
1172
+ JAVASCRIPT
1210
1173
  }
1211
- JAVASCRIPT
1212
1174
  end
1213
1175
 
1214
1176
  def readonly?
1215
- !@element.editable?
1177
+ assert_element_not_stale {
1178
+ !@element.editable?
1179
+ }
1216
1180
  end
1217
1181
 
1218
1182
  def multiple?
1219
- @element.evaluate('el => el.multiple')
1183
+ assert_element_not_stale {
1184
+ @element.evaluate('el => el.multiple')
1185
+ }
1220
1186
  end
1221
1187
 
1222
1188
  def rect
@@ -1290,7 +1256,9 @@ module Capybara
1290
1256
  def ==(other)
1291
1257
  return false unless other.is_a?(Node)
1292
1258
 
1293
- @element.evaluate('(self, other) => self == other', arg: other.element)
1259
+ assert_element_not_stale {
1260
+ @element.evaluate('(self, other) => self == other', arg: other.element)
1261
+ }
1294
1262
  end
1295
1263
 
1296
1264
  def find_xpath(query, **options)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Playwright
5
- VERSION = '0.5.12'
5
+ VERSION = '0.5.14'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-playwright-driver
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.12
4
+ version: 0.5.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - YusukeIwaki