File: element.rb

package info (click to toggle)
ruby-capybara 3.36.0%2Bds-2
  • links: PTS, VCS
  • area: main
  • in suites: bookworm
  • size: 2,380 kB
  • sloc: ruby: 23,399; javascript: 748; makefile: 11
file content (602 lines) | stat: -rw-r--r-- 19,911 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
# frozen_string_literal: true

module Capybara
  module Node
    ##
    #
    # A {Capybara::Node::Element} represents a single element on the page. It is possible
    # to interact with the contents of this element the same as with a document:
    #
    #     session = Capybara::Session.new(:rack_test, my_app)
    #
    #     bar = session.find('#bar')              # from Capybara::Node::Finders
    #     bar.select('Baz', from: 'Quox')      # from Capybara::Node::Actions
    #
    # {Capybara::Node::Element} also has access to HTML attributes and other properties of the
    # element:
    #
    #      bar.value
    #      bar.text
    #      bar[:title]
    #
    # @see Capybara::Node
    #
    class Element < Base
      def initialize(session, base, query_scope, query)
        super(session, base)
        @query_scope = query_scope
        @query = query
        @allow_reload = false
        @query_idx = nil
      end

      def allow_reload!(idx = nil)
        @query_idx = idx
        @allow_reload = true
      end

      ##
      #
      # @return [Object]    The native element from the driver, this allows access to driver specific methods
      #
      def native
        synchronize { base.native }
      end

      ##
      #
      # Retrieve the text of the element. If {Capybara.configure ignore_hidden_elements}
      # is `true`, which it is by default, then this will return only text
      # which is visible. The exact semantics of this may differ between
      # drivers, but generally any text within elements with `display:none` is
      # ignored. This behaviour can be overridden by passing `:all` to this
      # method.
      #
      # @param type [:all, :visible]  Whether to return only visible or all text
      # @return [String]              The text of the element
      #
      def text(type = nil, normalize_ws: false)
        type ||= :all unless session_options.ignore_hidden_elements || session_options.visible_text_only
        txt = synchronize { type == :all ? base.all_text : base.visible_text }
        normalize_ws ? txt.gsub(/[[:space:]]+/, ' ').strip : txt
      end

      ##
      #
      # Retrieve the given attribute.
      #
      #     element[:title] # => HTML title attribute
      #
      # @param  [Symbol] attribute     The attribute to retrieve
      # @return [String]               The value of the attribute
      #
      def [](attribute)
        synchronize { base[attribute] }
      end

      ##
      #
      # Retrieve the given CSS styles.
      #
      #     element.style('color', 'font-size') # => Computed values of CSS 'color' and 'font-size' styles
      #
      # @param [Array<String>] styles   Names of the desired CSS properties
      # @return [Hash]            Hash of the CSS property names to computed values
      #
      def style(*styles)
        styles = styles.flatten.map(&:to_s)
        raise ArgumentError, 'You must specify at least one CSS style' if styles.empty?

        begin
          synchronize { base.style(styles) }
        rescue NotImplementedError => e
          begin
            evaluate_script(STYLE_SCRIPT, *styles)
          rescue Capybara::NotSupportedByDriverError
            raise e
          end
        end
      end

      ##
      #
      # @return [String]    The value of the form element
      #
      def value
        synchronize { base.value }
      end

      ##
      #
      # Set the value of the form element to the given value.
      #
      # @param [String] value    The new value
      # @param [Hash] options    Driver specific options for how to set the value. Take default values from {Capybara.configure default_set_options}.
      #
      # @return [Capybara::Node::Element]  The element
      def set(value, **options)
        if ENV['CAPYBARA_THOROUGH'] && readonly?
          raise Capybara::ReadOnlyElementError, "Attempt to set readonly element with value: #{value}"
        end

        options = session_options.default_set_options.to_h.merge(options)
        synchronize { base.set(value, **options) }
        self
      end

      ##
      #
      # Select this node if it is an option element inside a select tag.
      #
      # @!macro action_waiting_behavior
      #   If the driver dynamic pages (JS) and the element is currently non-interactable, this method will
      #   continuously retry the action until either the element becomes interactable or the maximum
      #   wait time expires.
      #
      #   @param [false, Numeric] wait
      #     Maximum time to wait for the action to succeed. Defaults to {Capybara.configure default_max_wait_time}.
      # @return [Capybara::Node::Element]  The element
      def select_option(wait: nil)
        synchronize(wait) { base.select_option }
        self
      end

      ##
      #
      # Unselect this node if it is an option element inside a multiple select tag.
      #
      # @macro action_waiting_behavior
      # @return [Capybara::Node::Element]  The element
      def unselect_option(wait: nil)
        synchronize(wait) { base.unselect_option }
        self
      end

      ##
      #
      # Click the Element.
      #
      # @macro action_waiting_behavior
      # @!macro click_modifiers
      #   Both x: and y: must be specified if an offset is wanted, if not specified the click will occur at the middle of the element.
      #   @overload $0(*modifier_keys, wait: nil, **offset)
      #     @param *modifier_keys [:alt, :control, :meta, :shift] ([]) Keys to be held down when clicking
      #     @option options [Integer] x  X coordinate to offset the click location. If {Capybara.configure w3c_click_offset} is `true` the
      #       offset will be from the element center, otherwise it will be from the top left corner of the element
      #     @option options [Integer] y  Y coordinate to offset the click location. If {Capybara.configure w3c_click_offset} is `true` the
      #       offset will be from the element center, otherwise it will be from the top left corner of the element
      # @option options [Float] delay  Delay between the mouse down and mouse up events in seconds (0)
      # @return [Capybara::Node::Element]  The element
      def click(*keys, **options)
        perform_click_action(keys, **options) do |k, opts|
          base.click(k, **opts)
        end
      end

      ##
      #
      # Right Click the Element.
      #
      # @macro action_waiting_behavior
      # @macro click_modifiers
      # @option options [Float] delay  Delay between the mouse down and mouse up events in seconds (0)
      # @return [Capybara::Node::Element]  The element
      def right_click(*keys, **options)
        perform_click_action(keys, **options) do |k, opts|
          base.right_click(k, **opts)
        end
      end

      ##
      #
      # Double Click the Element.
      #
      # @macro action_waiting_behavior
      # @macro click_modifiers
      # @return [Capybara::Node::Element]  The element
      def double_click(*keys, **options)
        perform_click_action(keys, **options) do |k, opts|
          base.double_click(k, **opts)
        end
      end

      ##
      #
      # Send Keystrokes to the Element.
      #
      # @overload send_keys(keys, ...)
      #   @param keys [String, Symbol, Array<String,Symbol>]
      #
      # Examples:
      #
      #     element.send_keys "foo"                     #=> value: 'foo'
      #     element.send_keys "tet", :left, "s"         #=> value: 'test'
      #     element.send_keys [:control, 'a'], :space   #=> value: ' ' - assuming ctrl-a selects all contents
      #
      # Symbols supported for keys:
      # * :cancel
      # * :help
      # * :backspace
      # * :tab
      # * :clear
      # * :return
      # * :enter
      # * :shift
      # * :control
      # * :alt
      # * :pause
      # * :escape
      # * :space
      # * :page_up
      # * :page_down
      # * :end
      # * :home
      # * :left
      # * :up
      # * :right
      # * :down
      # * :insert
      # * :delete
      # * :semicolon
      # * :equals
      # * :numpad0
      # * :numpad1
      # * :numpad2
      # * :numpad3
      # * :numpad4
      # * :numpad5
      # * :numpad6
      # * :numpad7
      # * :numpad8
      # * :numpad9
      # * :multiply      - numeric keypad *
      # * :add           - numeric keypad +
      # * :separator     - numeric keypad 'separator' key ??
      # * :subtract      - numeric keypad -
      # * :decimal       - numeric keypad .
      # * :divide        - numeric keypad /
      # * :f1
      # * :f2
      # * :f3
      # * :f4
      # * :f5
      # * :f6
      # * :f7
      # * :f8
      # * :f9
      # * :f10
      # * :f11
      # * :f12
      # * :meta
      # * :command      - alias of :meta
      #
      # @return [Capybara::Node::Element]  The element
      def send_keys(*args)
        synchronize { base.send_keys(*args) }
        self
      end

      ##
      #
      # Hover on the Element.
      #
      # @return [Capybara::Node::Element]  The element
      def hover
        synchronize { base.hover }
        self
      end

      ##
      #
      # @return [String]      The tag name of the element
      #
      def tag_name
        # Element type is immutable so cache it
        @tag_name ||= initial_cache[:tag_name] || synchronize { base.tag_name }
      end

      ##
      #
      # Whether or not the element is visible. Not all drivers support CSS, so
      # the result may be inaccurate.
      #
      # @return [Boolean]     Whether the element is visible
      #
      def visible?
        synchronize { base.visible? }
      end

      ##
      #
      # Whether or not the element is currently in the viewport and it (or descendants)
      # would be considered clickable at the elements center point.
      #
      # @return [Boolean]     Whether the elements center is obscured.
      #
      def obscured?
        synchronize { base.obscured? }
      end

      ##
      #
      # Whether or not the element is checked.
      #
      # @return [Boolean]     Whether the element is checked
      #
      def checked?
        synchronize { base.checked? }
      end

      ##
      #
      # Whether or not the element is selected.
      #
      # @return [Boolean]     Whether the element is selected
      #
      def selected?
        synchronize { base.selected? }
      end

      ##
      #
      # Whether or not the element is disabled.
      #
      # @return [Boolean]     Whether the element is disabled
      #
      def disabled?
        synchronize { base.disabled? }
      end

      ##
      #
      # Whether or not the element is readonly.
      #
      # @return [Boolean]     Whether the element is readonly
      #
      def readonly?
        synchronize { base.readonly? }
      end

      ##
      #
      # Whether or not the element supports multiple results.
      #
      # @return [Boolean]     Whether the element supports multiple results.
      #
      def multiple?
        synchronize { base.multiple? }
      end

      ##
      #
      # An XPath expression describing where on the page the element can be found.
      #
      # @return [String]      An XPath expression
      #
      def path
        synchronize { base.path }
      end

      def rect
        synchronize { base.rect }
      end

      ##
      #
      # Trigger any event on the current element, for example mouseover or focus
      # events. Not supported with the Selenium driver, and SHOULDN'T BE USED IN TESTING unless you
      # fully understand why you're using it, that it can allow actions a user could never
      # perform, and that it may completely invalidate your test.
      #
      # @param [String] event       The name of the event to trigger
      #
      # @return [Capybara::Node::Element]  The element
      def trigger(event)
        synchronize { base.trigger(event) }
        self
      end

      ##
      #
      # Drag the element to the given other element.
      #
      #     source = page.find('#foo')
      #     target = page.find('#bar')
      #     source.drag_to(target)
      #
      # @param [Capybara::Node::Element] node     The element to drag to
      # @param [Hash] options  Driver specific options for dragging. May not be supported by all drivers.
      # @option options [Numeric] :delay   (0.05) When using Chrome/Firefox with Selenium and HTML5 dragging this is the number
      #                                    of seconds between each stage of the drag.
      # @option options [Boolean] :html5   When using Chrome/Firefox with Selenium enables to force the use of HTML5
      #                                    (true) or legacy (false) dragging. If not specified the driver will attempt to
      #                                    detect the correct method to use.
      # @option options [Array<Symbol>,Symbol] :drop_modifiers   Modifier keys which should be held while the dragged element is dropped.
      #
      #
      # @return [Capybara::Node::Element]  The dragged element
      def drag_to(node, **options)
        synchronize { base.drag_to(node.base, **options) }
        self
      end

      ##
      #
      # Drop items on the current element.
      #
      #     target = page.find('#foo')
      #     target.drop('/some/path/file.csv')
      #
      # @overload drop(path, ...)
      #   @param [String, #to_path] path Location of the file to drop on the element
      #
      # @overload drop(strings, ...)
      #   @param [Hash] strings A hash of type to data to be dropped - `{ "text/url" => "https://www.google.com" }`
      #
      # @return [Capybara::Node::Element]  The element
      def drop(*args)
        options = args.map { |arg| arg.respond_to?(:to_path) ? arg.to_path : arg }
        synchronize { base.drop(*options) }
        self
      end

      ##
      #
      # Scroll the page or element.
      #
      # @overload scroll_to(position, offset: [0,0])
      #   Scroll the page or element to its top, bottom or middle.
      #   @param [:top, :bottom, :center, :current] position
      #   @param [[Integer, Integer]] offset
      #
      # @overload scroll_to(element, align: :top)
      #   Scroll the page or current element until the given element is aligned at the top, bottom, or center of it.
      #   @param [Capybara::Node::Element] element   The element to be scrolled into view
      #   @param [:top, :bottom, :center] align Where to align the element being scrolled into view with relation to the current page/element if possible
      #
      # @overload scroll_to(x,y)
      #   @param [Integer] x    Horizontal scroll offset
      #   @param [Integer] y    Vertical scroll offset
      #
      # @return [Capybara::Node::Element]  The element
      def scroll_to(pos_or_el_or_x, y = nil, align: :top, offset: nil)
        case pos_or_el_or_x
        when Symbol
          synchronize { base.scroll_to(nil, pos_or_el_or_x) } unless pos_or_el_or_x == :current
        when Capybara::Node::Element
          synchronize { base.scroll_to(pos_or_el_or_x.base, align) }
        else
          synchronize { base.scroll_to(nil, nil, [pos_or_el_or_x, y]) }
        end
        synchronize { base.scroll_by(*offset) } unless offset.nil?
        self
      end

      ##
      #
      # Execute the given JS in the context of the element not returning a result. This is useful for scripts that return
      # complex objects, such as jQuery statements. {#execute_script} should be used over
      # {#evaluate_script} whenever a result is not expected or needed. `this` in the script will refer to the element this is called on.
      #
      # @param [String] script   A string of JavaScript to execute
      # @param args  Optional arguments that will be passed to the script. Driver support for this is optional and types of objects supported may differ between drivers
      #
      def execute_script(script, *args)
        session.execute_script(<<~JS, self, *args)
          (function (){
            #{script}
          }).apply(arguments[0], Array.prototype.slice.call(arguments,1));
        JS
      end

      ##
      #
      # Evaluate the given JS in the context of the element and return the result. Be careful when using this with
      # scripts that return complex objects, such as jQuery statements. {#execute_script} might
      # be a better alternative. `this` in the script will refer to the element this is called on.
      #
      # @param  [String] script   A string of JavaScript to evaluate
      # @return [Object]          The result of the evaluated JavaScript (may be driver specific)
      #
      def evaluate_script(script, *args)
        session.evaluate_script(<<~JS, self, *args)
          (function(){
            return #{script.strip}
          }).apply(arguments[0], Array.prototype.slice.call(arguments,1));
        JS
      end

      ##
      #
      # Evaluate the given JavaScript in the context of the element and obtain the result from a
      # callback function which will be passed as the last argument to the script. `this` in the
      # script will refer to the element this is called on.
      #
      # @param  [String] script   A string of JavaScript to evaluate
      # @return [Object]          The result of the evaluated JavaScript (may be driver specific)
      #
      def evaluate_async_script(script, *args)
        session.evaluate_async_script(<<~JS, self, *args)
          (function (){
            #{script}
          }).apply(arguments[0], Array.prototype.slice.call(arguments,1));
        JS
      end

      ##
      #
      # Toggle the elements background color between white and black for a period of time.
      #
      # @return [Capybara::Node::Element]  The element
      def flash
        execute_script(<<~JS, 100)
          async function flash(el, delay){
            var old_bg = el.style.backgroundColor;
            var colors = ["black", "white"];
            for(var i=0; i<20; i++){
              el.style.backgroundColor = colors[i % colors.length];
              await new Promise(resolve => setTimeout(resolve, delay));
            }
            el.style.backgroundColor = old_bg;
          }
          flash(this, arguments[0]);
        JS

        self
      end

      # @api private
      def reload
        return self unless @allow_reload

        begin
          reloaded = @query.resolve_for(query_scope ? query_scope.reload : session)[@query_idx.to_i]
          @base = reloaded.base if reloaded
        rescue StandardError => e
          raise e unless catch_error?(e)
        end
        self
      end

      ##
      #
      # A human-readable representation of the element.
      #
      # @return [String]  A string representation
      def inspect
        %(#<Capybara::Node::Element tag="#{base.tag_name}" path="#{base.path}">)
      rescue NotSupportedByDriverError
        %(#<Capybara::Node::Element tag="#{base.tag_name}">)
      rescue *session.driver.invalid_element_errors
        %(Obsolete #<Capybara::Node::Element>)
      end

      # @api private
      def initial_cache
        base.respond_to?(:initial_cache) ? base.initial_cache : {}
      end

      STYLE_SCRIPT = <<~JS
        (function(){
          var s = window.getComputedStyle(this);
          var result = {};
          for (var i = arguments.length; i--; ) {
            var property_name = arguments[i];
            result[property_name] = s.getPropertyValue(property_name);
          }
          return result;
        }).apply(this, arguments)
      JS

    private

      def perform_click_action(keys, wait: nil, **options)
        raise ArgumentError, 'You must specify both x: and y: for a click offset' if nil ^ options[:x] ^ options[:y]

        options[:offset] ||= :center if session_options.w3c_click_offset
        synchronize(wait) { yield keys, options }
        self
      end
    end
  end
end