capybara-playwright-driver 0.5.8 → 0.5.10

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: fe14f82e64d08efc962dda883c9c4a6b54b794c75bb5458c522fbf88e839267a
4
- data.tar.gz: 59156f6e1a5db3dd787b1342cafd5c9f8aca74df33042013304cdd029875e64c
3
+ metadata.gz: a9766817ce50a7580acf4f72c062780da5a2363ea838dea3a8f17207c359c144
4
+ data.tar.gz: 5b48eb5b0eb4145088457e8b0c1a26c5843f80dc3e3d9acf120c095fd2d7691a
5
5
  SHA512:
6
- metadata.gz: 3bbf39026f4bc9dfdce2c319510c709e00b6eb5a890e8057566f57a2b804e2890ff3ee97d67a8ba5da3fe7ede58c526f4b239cd425fe6ac47f049075a83a0a7d
7
- data.tar.gz: 813ccb3ce551cb1a3484d261096d8847d045cd694b6dc8275ba27f4012fab798ed23ad2bf36f2996818b653bf6ed4d0213d8f7c9f131cccf1c242450ea86327c
6
+ metadata.gz: d7b079c7c184643a16cfae94e7aaafd325460a3a13e3ece9cf453b127045a44a8ea008b29b65c326325ad3cbb7b3a8f092ff325267df512ac3f1645565ef6baa
7
+ data.tar.gz: 46290aabe5623d54094efa2cd30369261ca32525b99835e0f6e393ddc48de3e9d1e21011e64cd61dfd00531bf05818dab69525c1cb450f9f2d46679b9c19b0c5
data/AGENTS.md ADDED
@@ -0,0 +1,115 @@
1
+ # AGENTS.md
2
+
3
+ ## Ruby Style
4
+ - This repository is Ruby-first. Prefer Ruby naming and control flow over Python- or Java-style ceremony.
5
+ - Use names that describe the role in the domain. Avoid vague plumbing names like `context`, `handler`, `data`, or `control` unless they are the clearest possible name.
6
+ - Boolean-returning methods must end with `?`.
7
+ - Prefer `find`, `any?`, early return, and guard clauses over `value = nil` plus later reassignment.
8
+ - Do not add `attr_reader` for every instance variable in a small internal object. If the state is only used internally, access the instance variable directly.
9
+ - If an object is already scoped to one responsibility, avoid redundant prefixes in every private method name.
10
+ - Do not prefix Ruby private methods with `_`. Use `private` for visibility and a normal method name for intent.
11
+
12
+ ## Naming Examples
13
+
14
+ Bad:
15
+
16
+ ```ruby
17
+ def _playwright_try_get_by_label(locator)
18
+ # ...
19
+ end
20
+ ```
21
+
22
+ Good:
23
+
24
+ ```ruby
25
+ def find_by_label(locator)
26
+ # ...
27
+ end
28
+ ```
29
+
30
+ Bad:
31
+
32
+ ```ruby
33
+ def click_associated_label(control)
34
+ return true if control.evaluate("el => !!el.checked")
35
+ end
36
+ ```
37
+
38
+ Good:
39
+
40
+ ```ruby
41
+ def click_associated_label?(playwright_locator)
42
+ return true if playwright_locator.evaluate("el => !!el.checked")
43
+ end
44
+ ```
45
+
46
+ Bad:
47
+
48
+ ```ruby
49
+ def initialize(node_context:, selector:, locator:, checked:)
50
+ @node_context = node_context
51
+ @selector = selector
52
+ @locator = locator
53
+ @checked = checked
54
+ end
55
+ ```
56
+
57
+ Good:
58
+
59
+ ```ruby
60
+ def initialize(node:, node_type:, locator:, checked:)
61
+ @node = node
62
+ @node_type = node_type
63
+ @locator = locator
64
+ @checked = checked
65
+ end
66
+ ```
67
+
68
+ Bad:
69
+
70
+ ```ruby
71
+ control = nil
72
+
73
+ candidates.each do |candidate|
74
+ next unless matches?(candidate)
75
+
76
+ control = candidate
77
+ break
78
+ end
79
+
80
+ control
81
+ ```
82
+
83
+ Good:
84
+
85
+ ```ruby
86
+ candidates.find do |element_handle|
87
+ matches?(element_handle)
88
+ end
89
+ ```
90
+
91
+ Bad:
92
+
93
+ ```ruby
94
+ attr_reader :node, :node_type, :locator, :checked
95
+ ```
96
+
97
+ Good:
98
+
99
+ ```ruby
100
+ def click_associated_label?(playwright_locator)
101
+ return true if playwright_locator.evaluate("el => !!el.checked") == @checked
102
+ end
103
+ ```
104
+
105
+ ## General Guidance
106
+ - Prefer concise Ruby that reads top-to-bottom without carrying unnecessary temporary state.
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
+ ## How to execute RSpec or Ruby
110
+
111
+ `ruby` uses macOS system ruby interpreter (typically 2.6 or older). rbenv should be used.
112
+
113
+ - Do not run the full `spec/capybara/playwright_spec.rb` or full RSpec suite locally; run broad coverage in GitHub Actions.
114
+ - To run a focused Playwright compatibility check locally, use an example filter, for example:
115
+ `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'
@@ -48,7 +48,7 @@ module Capybara
48
48
  private def create_page(browser_context)
49
49
  browser_context.new_page.tap do |page|
50
50
  page.on('close', -> {
51
- if @playwright_page
51
+ if @playwright_page&.guid == page.guid
52
52
  @playwright_page = nil
53
53
  end
54
54
  })
@@ -89,10 +89,20 @@ 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
- assert_page_alive {
97
+ if firefox?
98
+ # ref: https://github.com/microsoft/playwright/issues/39738
94
99
  @playwright_page.capybara_current_frame.evaluate('() => { location.reload(true) }')
95
- }
100
+ else
101
+ assert_page_alive {
102
+ response = @playwright_page.reload
103
+ @playwright_page.capybara_set_last_response(response)
104
+ }
105
+ end
96
106
  end
97
107
 
98
108
  def find_xpath(query, **options)
@@ -255,7 +265,8 @@ module Capybara
255
265
  when /Element is not attached to the DOM/,
256
266
  /Execution context was destroyed, most likely because of a navigation/,
257
267
  /Cannot find context with specified id/,
258
- /Unable to adopt element handle from a different document/
268
+ /Unable to adopt element handle from a different document/,
269
+ /maybe frame was detached/
259
270
  # ignore error for retry
260
271
  @internal_logger.warn(err.message)
261
272
  else
@@ -318,16 +329,35 @@ module Capybara
318
329
 
319
330
  def window_size(handle)
320
331
  on_window(handle) do |page|
321
- page.evaluate('() => [window.innerWidth, window.innerHeight]')
332
+ outer_window_size(page)
322
333
  end
323
334
  end
324
335
 
325
336
  def resize_window_to(handle, width, height)
326
337
  on_window(handle) do |page|
327
- page.viewport_size = { width: width, height: height }
338
+ page.viewport_size = viewport_size_for(page, width, height)
328
339
  end
329
340
  end
330
341
 
342
+ # Capybara follows Selenium's outer window size semantics.
343
+ private def outer_window_size(page)
344
+ page.evaluate('() => [window.outerWidth || window.innerWidth, window.outerHeight || window.innerHeight]')
345
+ end
346
+
347
+ private def viewport_size_for(page, width, height)
348
+ inset_width, inset_height = page.evaluate(<<~JAVASCRIPT)
349
+ () => [
350
+ Math.max(0, (window.outerWidth || window.innerWidth) - window.innerWidth),
351
+ Math.max(0, (window.outerHeight || window.innerHeight) - window.innerHeight)
352
+ ]
353
+ JAVASCRIPT
354
+
355
+ {
356
+ width: [width - inset_width, 0].max,
357
+ height: [height - inset_height, 0].max
358
+ }
359
+ end
360
+
331
361
  def maximize_window(handle)
332
362
  @internal_logger.warn("maximize_window is not supported in Playwright driver")
333
363
  # incomplete in Playwright
@@ -31,30 +31,127 @@ module Capybara
31
31
  Node::Element.prepend(WithElementHandlePatch)
32
32
 
33
33
  module NodeActionsAllowLabelClickPatch
34
+ class SelectableElementHandler
35
+ def initialize(node:, node_type:, locator:, checked:)
36
+ @node = node
37
+ @node_type = node_type
38
+ @locator = locator
39
+ @checked = checked
40
+ end
41
+
42
+ def set_checked_state_via_label?
43
+ playwright_checkable = find_playwright_element_handle_by_non_label_locator
44
+ playwright_checkable ||= playwright_locator_by_label unless @locator.nil?
45
+ return false unless playwright_checkable
46
+
47
+ click_associated_label?(playwright_checkable)
48
+ rescue Capybara::ElementNotFound, Capybara::ExpectationNotMet, ::Playwright::Error
49
+ false
50
+ end
51
+
52
+ private
53
+
54
+ def playwright_locator_by_label
55
+ driver.with_playwright_page do |playwright_page|
56
+ return playwright_page.get_by_label(@locator.to_s)
57
+ end
58
+ end
59
+
60
+ def click_associated_label?(playwright_element_handle_or_locator)
61
+ return true if playwright_element_handle_or_locator.evaluate('el => !!el.checked') == @checked
62
+
63
+ label_element_handle = playwright_element_handle_or_locator.evaluate_handle('(el) => (el.labels && el.labels[0]) || el.closest("label") || null')
64
+ return false unless label_element_handle.is_a?(::Playwright::ElementHandle)
65
+
66
+ label_element_handle.click
67
+
68
+ playwright_element_handle_or_locator.evaluate('el => !!el.checked') == @checked
69
+ end
70
+
71
+ def find_playwright_element_handle_by_non_label_locator
72
+ return nil if @locator.nil?
73
+
74
+ locator_string = @locator.to_s
75
+ test_id_attr = session_options.test_id&.to_s
76
+
77
+ driver.with_playwright_page do |playwright_page|
78
+ return non_label_playwright_element_handle_candidates(playwright_page).find do |element_handle|
79
+ attribute_values = element_handle.evaluate(<<~JAVASCRIPT, arg: test_id_attr)
80
+ (el, testIdAttr) => ({
81
+ id: el.id || '',
82
+ name: el.getAttribute('name') || '',
83
+ testId: testIdAttr ? (el.getAttribute(testIdAttr) || '') : '',
84
+ })
85
+ JAVASCRIPT
86
+ [attribute_values['id'], attribute_values['name'], attribute_values['testId']].include?(locator_string)
87
+ end
88
+ end
89
+ rescue ::Playwright::Error
90
+ nil
91
+ end
92
+
93
+ def non_label_playwright_element_handle_candidates(playwright_page)
94
+ input_type =
95
+ case @node_type
96
+ when :checkbox
97
+ 'checkbox'
98
+ when :radio_button
99
+ 'radio'
100
+ else
101
+ return []
102
+ end
103
+
104
+ current_scope = scope_element
105
+ return current_scope.query_selector_all(%(input[type="#{input_type}"])) if current_scope
106
+
107
+ playwright_page.capybara_current_frame.query_selector_all(%(input[type="#{input_type}"]))
108
+ end
109
+
110
+ def scope_element
111
+ return nil unless @node.is_a?(Capybara::Node::Element)
112
+ return nil unless @node.send(:base).is_a?(Capybara::Playwright::Node)
113
+
114
+ @node.send(:base).send(:element)
115
+ end
116
+
117
+ def driver
118
+ @node.send(:driver)
119
+ end
120
+
121
+ def session_options
122
+ @node.send(:session_options)
123
+ end
124
+ end
125
+
34
126
  def choose(locator = nil, **options)
35
- _playwright_check_via_get_by_label(locator, checked: true, **options) { super }
127
+ check_via_label_click(:radio_button, locator, checked: true, **options) { super }
36
128
  end
37
129
 
38
130
  def check(locator = nil, **options)
39
- _playwright_check_via_get_by_label(locator, checked: true, **options) { super }
131
+ check_via_label_click(:checkbox, locator, checked: true, **options) { super }
40
132
  end
41
133
 
42
134
  def uncheck(locator = nil, **options)
43
- _playwright_check_via_get_by_label(locator, checked: false, **options) { super }
135
+ check_via_label_click(:checkbox, locator, checked: false, **options) { super }
44
136
  end
45
137
 
46
- private def _playwright_check_via_get_by_label(locator, checked:, allow_label_click: session_options.automatic_label_click, **options)
47
- unless _playwright_use_get_by_label?(locator, allow_label_click, options)
138
+ private def check_via_label_click(node_type, locator, checked:, allow_label_click: session_options.automatic_label_click, **options)
139
+ unless should_use_label_click?(allow_label_click, options)
48
140
  return yield
49
141
  end
50
142
 
51
- return self if _playwright_try_get_by_label(locator, checked: checked)
143
+ handler = SelectableElementHandler.new(
144
+ node: self,
145
+ node_type: node_type,
146
+ locator: locator,
147
+ checked: checked,
148
+ )
149
+ return self if handler.set_checked_state_via_label?
52
150
 
53
151
  yield
54
152
  end
55
153
 
56
- private def _playwright_use_get_by_label?(locator, allow_label_click, options)
57
- return false if locator.nil?
154
+ private def should_use_label_click?(allow_label_click, options)
58
155
  return false unless allow_label_click
59
156
  return false unless driver.is_a?(Capybara::Playwright::Driver)
60
157
  return false if Hash.try_convert(allow_label_click)
@@ -62,33 +159,6 @@ module Capybara
62
159
 
63
160
  true
64
161
  end
65
-
66
- private def _playwright_try_get_by_label(locator, checked:)
67
- handled = false
68
- driver.with_playwright_page do |playwright_page|
69
- begin
70
- control_locator = playwright_page.get_by_label(locator.to_s)
71
- control = control_locator
72
-
73
- if control.evaluate('el => !!el.checked') != checked
74
- label = control.evaluate_handle('(el) => (el.labels && el.labels[0]) || el.closest("label") || null')
75
- next unless label.is_a?(::Playwright::ElementHandle)
76
-
77
- label.click
78
- end
79
-
80
- next unless control.evaluate('el => !!el.checked') == checked
81
-
82
- handled = true
83
- rescue ::Playwright::Error
84
- handled = false
85
- end
86
- end
87
-
88
- handled
89
- rescue StandardError
90
- false
91
- end
92
162
  end
93
163
  if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.7')
94
164
  # Prepend to Node::Base instead of Node::Actions because Ruby < 3.1
@@ -128,6 +198,20 @@ module Capybara
128
198
  end
129
199
  ::Playwright::ElementHandle.prepend(CapybaraObscuredPatch)
130
200
 
201
+ # ref: https://github.com/teamcapybara/capybara/pull/2424
202
+ module ElementDropPathCompatPatch
203
+ def drop(*args)
204
+ options = args.map { |arg| arg.respond_to?(:to_path) ? arg.to_path : arg }
205
+ synchronize { base.drop(*options) }
206
+ self
207
+ end
208
+ end
209
+ if Gem::Version.new(Capybara::VERSION) < Gem::Version.new('3.34.0')
210
+ # Older Capybara returns early for Pathname arguments in Element#drop,
211
+ # so the driver implementation never runs.
212
+ Node::Element.prepend(ElementDropPathCompatPatch)
213
+ end
214
+
131
215
  module Playwright
132
216
  # Selector and checking methods are derived from twapole/apparition
133
217
  # Action methods (click, select_option, ...) uses playwright.
@@ -174,7 +258,7 @@ module Capybara
174
258
  end
175
259
 
176
260
  private def capybara_default_wait_time
177
- Capybara.default_max_wait_time * 1100 # with 10% buffer for allowing overhead.
261
+ Capybara.default_max_wait_time.to_f * 1100 # with 10% buffer for allowing overhead.
178
262
  end
179
263
 
180
264
  class NotActionableError < StandardError ; end
@@ -315,18 +399,69 @@ module Capybara
315
399
 
316
400
  class TextInput < Settable
317
401
  def set(value, **options)
402
+ case options[:clear]
403
+ when :backspace
404
+ @element.press('End', timeout: @timeout)
405
+ existing_text = @element.evaluate('el => el.value')
406
+ existing_text.length.times { @element.press('Backspace', timeout: @timeout) }
407
+ when :none
408
+ @element.press('End', timeout: @timeout)
409
+ when Array
410
+ @internal_logger.warn "options { clear: #{options[:clear]} } is ignored"
411
+ end
412
+
318
413
  text = value.to_s
319
- if text.end_with?("\n")
320
- @element.fill(text[0...-1], timeout: @timeout)
414
+ if press_enter = text.end_with?("\r\n")
415
+ text = text[0...-2]
416
+ elsif press_enter = text.end_with?("\n")
417
+ text = text[0...-1]
418
+ end
419
+
420
+ set_text(text, append: options[:clear] == :none)
421
+
422
+ if press_enter
321
423
  @element.press('Enter', timeout: @timeout)
322
- else
323
- @element.fill(text, timeout: @timeout)
324
424
  end
325
425
  rescue ::Playwright::TimeoutError
326
426
  raise if @element.editable?
327
427
 
328
428
  @internal_logger.info("Node#set: element is not editable. #{@element}")
329
429
  end
430
+
431
+ private def set_text(text, append:)
432
+ # ElementHandle#type can refocus inherited contenteditable descendants and drop the input.
433
+ if @element.evaluate('el => el.isContentEditable') && !append
434
+ @element.fill(text, timeout: @timeout)
435
+ return
436
+ end
437
+
438
+ if text.include?("\t")
439
+ type_tab_separated_text(text, append: append)
440
+ return
441
+ end
442
+
443
+ grapheme_clusters = text.scan(/\X/)
444
+ fill_text = grapheme_clusters[0...-1].join
445
+ typed_text = grapheme_clusters[-1].to_s
446
+
447
+ if append
448
+ @element.type(fill_text, timeout: @timeout) unless fill_text.empty?
449
+ else
450
+ @element.fill(fill_text, timeout: @timeout)
451
+ end
452
+ @element.type(typed_text, timeout: @timeout) unless typed_text.empty?
453
+ end
454
+
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
459
+
460
+ tail.each do |part|
461
+ keyboard.press('Tab')
462
+ keyboard.type(part) unless part.empty?
463
+ end
464
+ end
330
465
  end
331
466
 
332
467
  class FileUpload < Settable
@@ -782,8 +917,47 @@ module Capybara
782
917
  end
783
918
  end
784
919
 
920
+ ATTACH_FILE = <<~JAVASCRIPT
921
+ () => {
922
+ const input = document.createElement('INPUT');
923
+ input.type = 'file';
924
+ input.multiple = true;
925
+ input.style.display = 'none';
926
+ document.body.appendChild(input);
927
+ return input;
928
+ }
929
+ JAVASCRIPT
930
+
931
+ DROP_FILE = <<~JAVASCRIPT
932
+ (el, input) => {
933
+ const dt = new DataTransfer();
934
+ for (const file of input.files) { dt.items.add(file); }
935
+ input.remove();
936
+ el.dispatchEvent(new DragEvent('drop', {
937
+ cancelable: true, bubbles: true, dataTransfer: dt
938
+ }));
939
+ }
940
+ JAVASCRIPT
941
+
942
+ DROP_STRING = <<~JAVASCRIPT
943
+ (el, items) => {
944
+ const dt = new DataTransfer();
945
+ for (const item of items) { dt.items.add(item.data, item.type); }
946
+ el.dispatchEvent(new DragEvent('drop', {
947
+ cancelable: true, bubbles: true, dataTransfer: dt
948
+ }));
949
+ }
950
+ JAVASCRIPT
951
+
785
952
  def drop(*args)
786
- raise NotImplementedError
953
+ if args.first.is_a?(String) || args.first.is_a?(Pathname)
954
+ input = @page.evaluate_handle(ATTACH_FILE)
955
+ input.as_element.set_input_files(args.map(&:to_s))
956
+ @element.evaluate(DROP_FILE, arg: input)
957
+ else
958
+ items = args.flat_map { |arg| arg.map { |(type, data)| { type: type, data: data } } }
959
+ @element.evaluate(DROP_STRING, arg: items)
960
+ end
787
961
  end
788
962
 
789
963
  def scroll_by(x, y)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Playwright
5
- VERSION = '0.5.8'
5
+ VERSION = '0.5.10'
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.8
4
+ version: 0.5.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - YusukeIwaki
@@ -58,6 +58,7 @@ extensions: []
58
58
  extra_rdoc_files: []
59
59
  files:
60
60
  - ".rspec"
61
+ - AGENTS.md
61
62
  - CODE_OF_CONDUCT.md
62
63
  - Gemfile
63
64
  - LICENSE.txt