capybara-playwright-driver 0.5.9 → 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: a319465a40c53ac5b47d86ccd4d15136c20dcf7f6dde6fe17b3906d44cb2fd94
4
- data.tar.gz: 1e38fb6020a87a07e6eaa8e997084b3abd98a0a8d5cbec4e92819a960c525f62
3
+ metadata.gz: 4a90dd1edbae8b79e2f4d7eac72dcc8014e8d9e491730cbffa20be906c74ee5c
4
+ data.tar.gz: 729834c2bd8e56d523230e043f3d148777a89e2870da1624294d109eadd9434a
5
5
  SHA512:
6
- metadata.gz: 5d1d8aeccb05f0c667c3b1a617cb1d2f19eb7fa096788a46b81985730f1a75b6ee7196ad952d2152c173b7401980090f738db399503b3ddceac097ced637678c
7
- data.tar.gz: 141fd5024a9d322faf9031951e0f74a61426af350ed06d73017b2db90ec5b866e1af9b17edd5017e5c35f79f198cfa5916b220afcf621db9a2538c5fb50f443b
6
+ metadata.gz: 9b674a26ae213904eacec537a259f9111c5c4686d2317691bfe6bd965ad9fe4d57bad3dfb701cb2f456676f7a9d50ed94b50fb94723f83055b1bd79cb2c3d95c
7
+ data.tar.gz: 350f3c693c186594ccac2aa32e05cfd762f7baa069e01ebd6df8d20c744a3e72dfdfdd03a29728fcce77b5b173bd3bc6a2efcaedb55ca66fde43b8d55340bd9b
data/AGENTS.md CHANGED
@@ -105,3 +105,16 @@ end
105
105
  ## General Guidance
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
+
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
+
114
+ ## How to execute RSpec or Ruby
115
+
116
+ `ruby` uses macOS system ruby interpreter (typically 2.6 or older). rbenv should be used.
117
+
118
+ - Do not run the full `spec/capybara/playwright_spec.rb` or full RSpec suite locally; run broad coverage in GitHub Actions.
119
+ - To run a focused Playwright compatibility check locally, use an example filter, for example:
120
+ `RBENV_VERSION=$(cat .ruby-version) ~/.rbenv/shims/bundle exec rspec spec/capybara/playwright_spec.rb --example fill_in`
data/Gemfile CHANGED
@@ -15,6 +15,7 @@ gem 'rack-test_server'
15
15
  gem 'rake', '~> 13.0.3'
16
16
  gem 'rspec', '~> 3.11.0'
17
17
  gem 'rubocop-rspec'
18
+ gem 'selenium-webdriver', '~> 4.34.0' if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.2')
18
19
  gem 'sinatra', '>= 1.4.0'
19
20
  gem 'webrick'
20
21
  gem 'websocket-driver'
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:
@@ -89,9 +89,29 @@ module Capybara
89
89
  }
90
90
  end
91
91
 
92
+ private def firefox?
93
+ @playwright_browser.browser_type.name == 'firefox'
94
+ end
95
+
92
96
  def refresh
93
97
  assert_page_alive {
94
- @playwright_page.capybara_current_frame.evaluate('() => { location.reload(true) }')
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
112
+ response = @playwright_page.reload
113
+ @playwright_page.capybara_set_last_response(response)
114
+ end
95
115
  }
96
116
  end
97
117
 
@@ -255,7 +275,8 @@ module Capybara
255
275
  when /Element is not attached to the DOM/,
256
276
  /Execution context was destroyed, most likely because of a navigation/,
257
277
  /Cannot find context with specified id/,
258
- /Unable to adopt element handle from a different document/
278
+ /Unable to adopt element handle from a different document/,
279
+ /maybe frame was detached/
259
280
  # ignore error for retry
260
281
  @internal_logger.warn(err.message)
261
282
  else
@@ -318,16 +339,35 @@ module Capybara
318
339
 
319
340
  def window_size(handle)
320
341
  on_window(handle) do |page|
321
- page.evaluate('() => [window.innerWidth, window.innerHeight]')
342
+ outer_window_size(page)
322
343
  end
323
344
  end
324
345
 
325
346
  def resize_window_to(handle, width, height)
326
347
  on_window(handle) do |page|
327
- page.viewport_size = { width: width, height: height }
348
+ page.viewport_size = viewport_size_for(page, width, height)
328
349
  end
329
350
  end
330
351
 
352
+ # Capybara follows Selenium's outer window size semantics.
353
+ private def outer_window_size(page)
354
+ page.evaluate('() => [window.outerWidth || window.innerWidth, window.outerHeight || window.innerHeight]')
355
+ end
356
+
357
+ private def viewport_size_for(page, width, height)
358
+ inset_width, inset_height = page.evaluate(<<~JAVASCRIPT)
359
+ () => [
360
+ Math.max(0, (window.outerWidth || window.innerWidth) - window.innerWidth),
361
+ Math.max(0, (window.outerHeight || window.innerHeight) - window.innerHeight)
362
+ ]
363
+ JAVASCRIPT
364
+
365
+ {
366
+ width: [width - inset_width, 0].max,
367
+ height: [height - inset_height, 0].max
368
+ }
369
+ end
370
+
331
371
  def maximize_window(handle)
332
372
  @internal_logger.warn("maximize_window is not supported in Playwright driver")
333
373
  # incomplete in Playwright
@@ -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
@@ -258,11 +274,26 @@ module Capybara
258
274
  end
259
275
 
260
276
  private def capybara_default_wait_time
261
- Capybara.default_max_wait_time * 1100 # with 10% buffer for allowing overhead.
277
+ Capybara.default_max_wait_time.to_f * 1100 # with 10% buffer for allowing overhead.
262
278
  end
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,28 +438,102 @@ 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
412
445
 
413
446
  text = value.to_s
414
- if press_enter = text.end_with?("\n")
447
+ if press_enter = text.end_with?("\r\n")
448
+ text = text[0...-2]
449
+ elsif press_enter = text.end_with?("\n")
415
450
  text = text[0...-1]
416
451
  end
417
452
 
418
- if options[:clear] == :none
419
- @element.type(text, timeout: @timeout)
420
- else
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
+ rescue ::Playwright::TimeoutError
458
+ raise if @element.editable?
459
+
460
+ @internal_logger.info("Node#set: element is not editable. #{@element}")
461
+ end
462
+
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
421
466
  @element.fill(text, timeout: @timeout)
467
+ return
468
+ end
469
+
470
+ text = text.gsub(/\r\n?/, "\n")
471
+
472
+ if text.include?("\t")
473
+ type_tab_separated_text(text, append: append, keyboard: keyboard)
474
+ return
422
475
  end
423
476
 
424
- if press_enter
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
485
+ end
486
+
487
+ private def replace_text(keyboard, text)
488
+ head, *tail = text.split("\n", -1)
489
+ if head.empty?
425
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?
426
499
  end
427
- rescue ::Playwright::TimeoutError
428
- raise if @element.editable?
429
500
 
430
- @internal_logger.info("Node#set: element is not editable. #{@element}")
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
512
+
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
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?
431
537
  end
432
538
  end
433
539
 
@@ -537,20 +643,26 @@ module Capybara
537
643
  end
538
644
 
539
645
  def click(keys = [], **options)
540
- click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time)
541
- @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
542
650
  end
543
651
 
544
652
  def right_click(keys = [], **options)
545
- click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time)
546
- params = click_options.as_params
547
- params[:button] = 'right'
548
- @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
549
659
  end
550
660
 
551
661
  def double_click(keys = [], **options)
552
- click_options = ClickOptions.new(@element, keys, options, capybara_default_wait_time)
553
- @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
554
666
  end
555
667
 
556
668
  class ClickOptions
@@ -620,7 +732,7 @@ module Capybara
620
732
 
621
733
  private def position
622
734
  if @offset_center
623
- box = @element.bounding_box
735
+ box = ElementGeometry.bounding_box(@element)
624
736
 
625
737
  {
626
738
  x: @coords[:x] + box['width'] / 2,
@@ -807,11 +919,17 @@ module Capybara
807
919
  end
808
920
 
809
921
  def hover
810
- @element.hover(timeout: capybara_default_wait_time)
922
+ assert_element_not_stale do
923
+ @element.hover(timeout: capybara_default_wait_time)
924
+ end
811
925
  end
812
926
 
813
- def drag_to(element, **options)
814
- 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
815
933
  end
816
934
 
817
935
  class DragTo
@@ -828,22 +946,28 @@ module Capybara
828
946
  # @param page [Playwright::Page]
829
947
  # @param source [Playwright::ElementHandle]
830
948
  # @param target [Playwright::ElementHandle]
831
- def initialize(page, source, target, options)
949
+ def initialize(page, source, target, options, timeout)
832
950
  @page = page
833
951
  @source = source
834
952
  @target = target
835
953
  @options = options
954
+ @timeout = timeout
836
955
  end
837
956
 
838
957
  def execute
839
- @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)
840
962
 
841
- # down
842
963
  position_from = center_of(@source)
964
+ center_of(@target)
843
965
  @page.mouse.move(*position_from)
966
+ input_started = true
967
+ mouse_down = true
844
968
  @page.mouse.down
845
969
 
846
- @target.scroll_into_view_if_needed
970
+ @target.scroll_into_view_if_needed(timeout: @timeout)
847
971
 
848
972
  # move and up
849
973
  sleep_delay
@@ -852,20 +976,33 @@ module Capybara
852
976
  @page.mouse.move(*position_to, steps: 6)
853
977
  sleep_delay
854
978
  @page.mouse.up
979
+ mouse_down = false
855
980
  end
856
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
857
988
  end
858
989
 
859
990
  # @param element [Playwright::ElementHandle]
860
991
  private def center_of(element)
861
- box = element.bounding_box
992
+ box = ElementGeometry.bounding_box(element)
993
+
862
994
  [box["x"] + box["width"] / 2, box["y"] + box["height"] / 2]
863
995
  end
864
996
 
865
- private def with_key_pressing(keys, &block)
866
- keys.each { |key| @page.keyboard.down(key) }
867
- block.call
868
- 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) }
869
1006
  end
870
1007
 
871
1008
  # @returns Array<String>
@@ -1045,7 +1182,9 @@ module Capybara
1045
1182
  end
1046
1183
 
1047
1184
  def obscured?
1048
- @element.capybara_obscured?
1185
+ assert_element_not_stale do
1186
+ @element.capybara_obscured?
1187
+ end
1049
1188
  end
1050
1189
 
1051
1190
  def checked?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Playwright
5
- VERSION = '0.5.9'
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.9
4
+ version: 0.5.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - YusukeIwaki