capybara-playwright-driver 0.5.10 → 0.5.12

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: a9766817ce50a7580acf4f72c062780da5a2363ea838dea3a8f17207c359c144
4
- data.tar.gz: 5b48eb5b0eb4145088457e8b0c1a26c5843f80dc3e3d9acf120c095fd2d7691a
3
+ metadata.gz: 4a90dd1edbae8b79e2f4d7eac72dcc8014e8d9e491730cbffa20be906c74ee5c
4
+ data.tar.gz: 729834c2bd8e56d523230e043f3d148777a89e2870da1624294d109eadd9434a
5
5
  SHA512:
6
- metadata.gz: d7b079c7c184643a16cfae94e7aaafd325460a3a13e3ece9cf453b127045a44a8ea008b29b65c326325ad3cbb7b3a8f092ff325267df512ac3f1645565ef6baa
7
- data.tar.gz: 46290aabe5623d54094efa2cd30369261ca32525b99835e0f6e393ddc48de3e9d1e21011e64cd61dfd00531bf05818dab69525c1cb450f9f2d46679b9c19b0c5
6
+ metadata.gz: 9b674a26ae213904eacec537a259f9111c5c4686d2317691bfe6bd965ad9fe4d57bad3dfb701cb2f456676f7a9d50ed94b50fb94723f83055b1bd79cb2c3d95c
7
+ data.tar.gz: 350f3c693c186594ccac2aa32e05cfd762f7baa069e01ebd6df8d20c744a3e72dfdfdd03a29728fcce77b5b173bd3bc6a2efcaedb55ca66fde43b8d55340bd9b
data/AGENTS.md CHANGED
@@ -106,6 +106,11 @@ end
106
106
  - Prefer concise Ruby that reads top-to-bottom without carrying unnecessary temporary state.
107
107
  - Prefer small private helper objects only when they reduce complexity. Once extracted, give them Ruby-like method names and keep their public surface minimal.
108
108
 
109
+ ## Test Style
110
+ - Avoid assertion roulette. Each example must verify one clearly named behavior so a failure identifies the broken behavior directly.
111
+ - Do not use loops, parameter tables, or metaprogramming to generate examples for distinct public operations. Write an explicit `it` block for each operation, even when their setup and expectations are similar.
112
+ - Split independent expectations into separate examples. Keep multiple expectations together only when they jointly describe one inseparable outcome.
113
+
109
114
  ## How to execute RSpec or Ruby
110
115
 
111
116
  `ruby` uses macOS system ruby interpreter (typically 2.6 or older). rbenv should be used.
data/README.md CHANGED
@@ -26,13 +26,14 @@ Capybara.save_path = 'tmp/capybara'
26
26
  # run
27
27
  Capybara.app_host = 'https://github.com'
28
28
  visit '/'
29
- first('div.search-input-container').click
30
- fill_in('query-builder-test', with: 'Capybara')
29
+ find('button[aria-label^="Search or jump to"]').click
30
+ search_field = find('input[aria-label="Search or jump to"]')
31
+ search_field.fill_in(with: 'Capybara')
31
32
 
32
33
  ## [REMARK] We can use Playwright-native selector and action, instead of Capybara DSL.
33
- # first('[aria-label="Capybara, Search all of GitHub"]').click
34
+ # search_field.send_keys(:enter)
34
35
  page.driver.with_playwright_page do |page|
35
- page.get_by_label('Capybara, Search all of GitHub').click
36
+ page.get_by_label('Search or jump to', exact: true).press('Enter')
36
37
  end
37
38
 
38
39
  all('[data-testid="results-list"] h3').each do |li|
@@ -43,6 +44,38 @@ end
43
44
 
44
45
  Refer the [documentation](https://playwright-ruby-client.vercel.app/docs/article/guides/rails_integration) for more detailed configuration.
45
46
 
47
+ ## Selenium compatibility
48
+
49
+ Visible text for ordinary HTML elements follows the browser's
50
+ [`innerText` behavior](https://html.spec.whatwg.org/multipage/dom.html#the-innertext-idl-attribute). This preserves
51
+ meaningful blank lines, but paragraph and rich-text-editor boundaries can differ from Selenium's WebDriver text
52
+ algorithm. When exact whitespace is not part of an assertion, use Capybara's `normalize_ws: true` option:
53
+
54
+ ```ruby
55
+ expect(message).to have_text('Hello Regards', normalize_ws: true)
56
+ ```
57
+
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:
62
+
63
+ ```ruby
64
+ editor.send_keys(:end, '@') # The page moves focus to an autocomplete control.
65
+ editor.send_keys(:end, 'alice')
66
+ ```
67
+
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
70
+ `input` for other characters, including Japanese text. This follows
71
+ [Playwright's documented keyboard behavior](https://playwright.dev/docs/api/class-keyboard#keyboard-type). Typing very
72
+ large values is slower than Playwright's native `fill`, which does not send per-character keyboard events. When input
73
+ speed matters more than those events, consider using `fill` directly:
74
+
75
+ ```ruby
76
+ page.driver.with_playwright_page { |playwright_page| playwright_page.get_by_label('Body').fill(large_value) }
77
+ ```
78
+
46
79
  ## Development
47
80
 
48
81
  Prepare to run tests:
@@ -94,15 +94,25 @@ module Capybara
94
94
  end
95
95
 
96
96
  def refresh
97
- if firefox?
98
- # ref: https://github.com/microsoft/playwright/issues/39738
99
- @playwright_page.capybara_current_frame.evaluate('() => { location.reload(true) }')
100
- else
101
- assert_page_alive {
97
+ assert_page_alive {
98
+ if firefox?
99
+ # Preserve POST requests: https://github.com/microsoft/playwright/issues/39738
100
+ # Mark the old document so pushState and an earlier navigation's load
101
+ # cannot satisfy the wait for the reloaded document.
102
+ @playwright_page.evaluate(<<~JAVASCRIPT)
103
+ () => {
104
+ document[Symbol.for('capybara-playwright-driver.refresh')] = true;
105
+ location.reload(true);
106
+ }
107
+ JAVASCRIPT
108
+ @playwright_page.wait_for_function(<<~JAVASCRIPT, polling: 10, timeout: @default_navigation_timeout).dispose
109
+ () => !document[Symbol.for('capybara-playwright-driver.refresh')] && document.readyState === 'complete'
110
+ JAVASCRIPT
111
+ else
102
112
  response = @playwright_page.reload
103
113
  @playwright_page.capybara_set_last_response(response)
104
- }
105
- end
114
+ end
115
+ }
106
116
  end
107
117
 
108
118
  def find_xpath(query, **options)
@@ -1,22 +1,38 @@
1
1
  module Capybara
2
2
  module ElementClickOptionPatch
3
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
4
+ return super unless driver.is_a?(Capybara::Playwright::Driver)
8
5
 
9
- # Playwright has own auto-waiting feature.
10
- # So disable Capybara's retry logic.
11
- if driver.is_a?(Capybara::Playwright::Driver)
12
- options[:wait] = 0
13
- end
6
+ wait = options[:wait]
7
+
8
+ # Expose `wait` value to the block given to perform_click_action.
9
+ options[:_playwright_wait] = wait if wait.is_a?(Numeric)
10
+ options[:wait] = 0 if wait == false
14
11
 
12
+ # Playwright waits for actionability while Capybara retries replaced elements.
15
13
  super
16
14
  end
17
15
  end
18
16
  Node::Element.prepend(ElementClickOptionPatch)
19
17
 
18
+ module ElementDragToPatch
19
+ def drag_to(target, **options)
20
+ return super unless driver.is_a?(Capybara::Playwright::Driver)
21
+
22
+ synchronize do
23
+ begin
24
+ base.drag_to(target.base, **options)
25
+ rescue Capybara::Playwright::Node::StaleReferenceError
26
+ # Capybara's synchronize reloads only the source element.
27
+ target.reload if session_options.automatic_reload
28
+ raise
29
+ end
30
+ end
31
+ self
32
+ end
33
+ end
34
+ Node::Element.prepend(ElementDragToPatch)
35
+
20
36
  module WithElementHandlePatch
21
37
  def with_playwright_element_handle(&block)
22
38
  raise ArgumentError.new('block must be given') unless block
@@ -263,6 +279,21 @@ module Capybara
263
279
 
264
280
  class NotActionableError < StandardError ; end
265
281
  class StaleReferenceError < StandardError ; end
282
+ class MissingBoundingBoxError < StandardError ; end
283
+ class DragInterruptedError < StandardError ; end
284
+
285
+ class ElementGeometry
286
+ def self.bounding_box(element)
287
+ box = element.bounding_box
288
+ return box if box
289
+
290
+ unless element.evaluate('element => element.isConnected')
291
+ raise StaleReferenceError, 'Element is not attached to the DOM'
292
+ end
293
+
294
+ raise MissingBoundingBoxError, 'Element is attached but has no bounding box'
295
+ end
296
+ end
266
297
 
267
298
  def all_text
268
299
  assert_element_not_stale {
@@ -284,7 +315,7 @@ module Capybara
284
315
  if (el.nodeName == 'TEXTAREA'){
285
316
  return el.textContent;
286
317
  } else if (el instanceof SVGElement) {
287
- return el.textContent;
318
+ return el.textContent.replace(/\\n+/g, '\\n');
288
319
  } else {
289
320
  return el.innerText;
290
321
  }
@@ -292,7 +323,6 @@ module Capybara
292
323
  JAVASCRIPT
293
324
  text.to_s.scrub.gsub(/\A[[:space:]&&[^\u00a0]]+/, '')
294
325
  .gsub(/[[:space:]&&[^\u00a0]]+\z/, '')
295
- .gsub(/\n+/, "\n")
296
326
  .tr("\u00a0", ' ')
297
327
  }
298
328
  end
@@ -368,7 +398,9 @@ module Capybara
368
398
  end
369
399
  end
370
400
 
371
- settable_class.new(@element, capybara_default_wait_time, @internal_logger).set(value, **options)
401
+ assert_element_not_stale do
402
+ settable_class.new(@element, capybara_default_wait_time, @internal_logger).set(value, **options)
403
+ end
372
404
  rescue ::Playwright::TimeoutError => err
373
405
  raise NotActionableError.new(err)
374
406
  end
@@ -406,6 +438,7 @@ module Capybara
406
438
  existing_text.length.times { @element.press('Backspace', timeout: @timeout) }
407
439
  when :none
408
440
  @element.press('End', timeout: @timeout)
441
+ ensure_attached
409
442
  when Array
410
443
  @internal_logger.warn "options { clear: #{options[:clear]} } is ignored"
411
444
  end
@@ -417,51 +450,91 @@ module Capybara
417
450
  text = text[0...-1]
418
451
  end
419
452
 
420
- set_text(text, append: options[:clear] == :none)
453
+ keyboard = @element.owner_frame.page.keyboard
454
+ set_text(text, append: options[:clear] == :none, keyboard: keyboard)
421
455
 
422
- if press_enter
423
- @element.press('Enter', timeout: @timeout)
424
- end
456
+ keyboard.press('Enter') if press_enter
425
457
  rescue ::Playwright::TimeoutError
426
458
  raise if @element.editable?
427
459
 
428
460
  @internal_logger.info("Node#set: element is not editable. #{@element}")
429
461
  end
430
462
 
431
- private def set_text(text, append:)
463
+ private def set_text(text, append:, keyboard:)
432
464
  # ElementHandle#type can refocus inherited contenteditable descendants and drop the input.
433
465
  if @element.evaluate('el => el.isContentEditable') && !append
434
466
  @element.fill(text, timeout: @timeout)
435
467
  return
436
468
  end
437
469
 
470
+ text = text.gsub(/\r\n?/, "\n")
471
+
438
472
  if text.include?("\t")
439
- type_tab_separated_text(text, append: append)
473
+ type_tab_separated_text(text, append: append, keyboard: keyboard)
440
474
  return
441
475
  end
442
476
 
443
- grapheme_clusters = text.scan(/\X/)
444
- fill_text = grapheme_clusters[0...-1].join
445
- typed_text = grapheme_clusters[-1].to_s
446
-
447
477
  if append
448
- @element.type(fill_text, timeout: @timeout) unless fill_text.empty?
478
+ type_text(keyboard, text)
479
+ elsif text.empty?
480
+ @element.fill('', timeout: @timeout)
449
481
  else
450
- @element.fill(fill_text, timeout: @timeout)
482
+ @element.select_text(timeout: @timeout)
483
+ replace_text(keyboard, text)
451
484
  end
452
- @element.type(typed_text, timeout: @timeout) unless typed_text.empty?
453
485
  end
454
486
 
455
- private def type_tab_separated_text(text, append:)
456
- head, *tail = text.split("\t", -1)
457
- set_text(head, append: append)
458
- keyboard = @element.owner_frame.page.keyboard
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?
499
+ end
500
+
501
+ type_lines(keyboard, tail)
502
+ end
503
+
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)
511
+ end
459
512
 
460
- tail.each do |part|
461
- keyboard.press('Tab')
513
+ private def type_lines(keyboard, parts)
514
+ parts.each do |part|
515
+ keyboard.press('Enter')
462
516
  keyboard.type(part) unless part.empty?
463
517
  end
464
518
  end
519
+
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
532
+ end
533
+
534
+ private def ensure_attached
535
+ # Trigger Playwright's stale-element check without changing focus.
536
+ @element.enabled?
537
+ end
465
538
  end
466
539
 
467
540
  class FileUpload < Settable
@@ -570,20 +643,26 @@ module Capybara
570
643
  end
571
644
 
572
645
  def click(keys = [], **options)
573
- click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time)
574
- @element.click(**click_options.as_params)
646
+ assert_element_not_stale do
647
+ click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time)
648
+ @element.click(**click_options.as_params)
649
+ end
575
650
  end
576
651
 
577
652
  def right_click(keys = [], **options)
578
- click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time)
579
- params = click_options.as_params
580
- params[:button] = 'right'
581
- @element.click(**params)
653
+ assert_element_not_stale do
654
+ click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time)
655
+ params = click_options.as_params
656
+ params[:button] = 'right'
657
+ @element.click(**params)
658
+ end
582
659
  end
583
660
 
584
661
  def double_click(keys = [], **options)
585
- click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time)
586
- @element.dblclick(**click_options.as_params)
662
+ assert_element_not_stale do
663
+ click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time)
664
+ @element.dblclick(**click_options.as_params)
665
+ end
587
666
  end
588
667
 
589
668
  class ClickOptions
@@ -653,7 +732,7 @@ module Capybara
653
732
 
654
733
  private def position
655
734
  if @offset_center
656
- box = @element.bounding_box
735
+ box = ElementGeometry.bounding_box(@element)
657
736
 
658
737
  {
659
738
  x: @coords[:x] + box['width'] / 2,
@@ -840,11 +919,17 @@ module Capybara
840
919
  end
841
920
 
842
921
  def hover
843
- @element.hover(timeout: capybara_default_wait_time)
922
+ assert_element_not_stale do
923
+ @element.hover(timeout: capybara_default_wait_time)
924
+ end
844
925
  end
845
926
 
846
- def drag_to(element, **options)
847
- DragTo.new(@page, @element, element.element, options).execute
927
+ def drag_to(target, **options)
928
+ assert_element_not_stale do
929
+ target.send(:assert_element_not_stale) do
930
+ DragTo.new(@page, @element, target.element, options, capybara_default_wait_time).execute
931
+ end
932
+ end
848
933
  end
849
934
 
850
935
  class DragTo
@@ -861,22 +946,28 @@ module Capybara
861
946
  # @param page [Playwright::Page]
862
947
  # @param source [Playwright::ElementHandle]
863
948
  # @param target [Playwright::ElementHandle]
864
- def initialize(page, source, target, options)
949
+ def initialize(page, source, target, options, timeout)
865
950
  @page = page
866
951
  @source = source
867
952
  @target = target
868
953
  @options = options
954
+ @timeout = timeout
869
955
  end
870
956
 
871
957
  def execute
872
- @source.scroll_into_view_if_needed
958
+ input_started = false
959
+ mouse_down = false
960
+ @target.wait_for_element_state('visible', timeout: @timeout)
961
+ @source.scroll_into_view_if_needed(timeout: @timeout)
873
962
 
874
- # down
875
963
  position_from = center_of(@source)
964
+ center_of(@target)
876
965
  @page.mouse.move(*position_from)
966
+ input_started = true
967
+ mouse_down = true
877
968
  @page.mouse.down
878
969
 
879
- @target.scroll_into_view_if_needed
970
+ @target.scroll_into_view_if_needed(timeout: @timeout)
880
971
 
881
972
  # move and up
882
973
  sleep_delay
@@ -885,20 +976,33 @@ module Capybara
885
976
  @page.mouse.move(*position_to, steps: 6)
886
977
  sleep_delay
887
978
  @page.mouse.up
979
+ mouse_down = false
888
980
  end
889
981
  sleep_delay
982
+ rescue StaleReferenceError, ::Playwright::Error => err
983
+ raise DragInterruptedError, "Drag was interrupted after input began: #{err.message}" if input_started
984
+
985
+ raise
986
+ ensure
987
+ @page.mouse.up if mouse_down
890
988
  end
891
989
 
892
990
  # @param element [Playwright::ElementHandle]
893
991
  private def center_of(element)
894
- box = element.bounding_box
992
+ box = ElementGeometry.bounding_box(element)
993
+
895
994
  [box["x"] + box["width"] / 2, box["y"] + box["height"] / 2]
896
995
  end
897
996
 
898
- private def with_key_pressing(keys, &block)
899
- keys.each { |key| @page.keyboard.down(key) }
900
- block.call
901
- keys.each { |key| @page.keyboard.up(key) }
997
+ private def with_key_pressing(keys)
998
+ pressed_keys = []
999
+ keys.each do |key|
1000
+ @page.keyboard.down(key)
1001
+ pressed_keys << key
1002
+ end
1003
+ yield
1004
+ ensure
1005
+ pressed_keys.reverse_each { |key| @page.keyboard.up(key) }
902
1006
  end
903
1007
 
904
1008
  # @returns Array<String>
@@ -1078,7 +1182,9 @@ module Capybara
1078
1182
  end
1079
1183
 
1080
1184
  def obscured?
1081
- @element.capybara_obscured?
1185
+ assert_element_not_stale do
1186
+ @element.capybara_obscured?
1187
+ end
1082
1188
  end
1083
1189
 
1084
1190
  def checked?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Playwright
5
- VERSION = '0.5.10'
5
+ VERSION = '0.5.12'
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.10
4
+ version: 0.5.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - YusukeIwaki