Skip to content

Commit

Permalink
Implement Shadow DOM API
Browse files Browse the repository at this point in the history
This adds new method `Element#shadow_root` which returns an instance of
`ShadowRoot` class. It can then be used to locate elements within this
shadow root.

Note that currently no drivers support WebDriver's Shadow DOM API.
Consider this implementation a draft because there was no way to test
it.
  • Loading branch information
p0deje committed Jul 7, 2021
1 parent ee0193d commit d44b41b
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 28 deletions.
1 change: 1 addition & 0 deletions rb/lib/selenium/webdriver/common.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,4 @@
require 'selenium/webdriver/common/takes_screenshot'
require 'selenium/webdriver/common/driver'
require 'selenium/webdriver/common/element'
require 'selenium/webdriver/common/shadow_root'
4 changes: 3 additions & 1 deletion rb/lib/selenium/webdriver/common/driver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,9 @@ def capabilities
# @see SearchContext
#

def ref; end
def ref
[:driver, nil]
end

private

Expand Down
25 changes: 17 additions & 8 deletions rb/lib/selenium/webdriver/common/element.rb
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ def attribute(name)
#

def dom_attribute(name)
bridge.element_dom_attribute self, name
bridge.element_dom_attribute @id, name
end

#
Expand All @@ -157,7 +157,7 @@ def dom_attribute(name)
#

def property(name)
bridge.element_property self, name
bridge.element_property @id, name
end

#
Expand All @@ -167,7 +167,7 @@ def property(name)
#

def aria_role
bridge.element_aria_role self
bridge.element_aria_role @id
end

#
Expand All @@ -177,7 +177,7 @@ def aria_role
#

def accessible_name
bridge.element_aria_label self
bridge.element_aria_label @id
end

#
Expand Down Expand Up @@ -317,6 +317,16 @@ def size
bridge.element_size @id
end

#
# Returns the shadow root of an element.
#
# @return [WebDriver::ShadowRoot]
#

def shadow_root
bridge.shadow_root @id
end

#-------------------------------- sugar --------------------------------

#
Expand All @@ -336,14 +346,13 @@ def size
#
alias_method :[], :attribute

#
# for SearchContext and execute_script
#
# @api private
# @see SearchContext
#

def ref
@id
[:element, @id]
end

#
Expand Down Expand Up @@ -379,7 +388,7 @@ def selectable?
end

def screenshot
bridge.element_screenshot(self)
bridge.element_screenshot(@id)
end
end # Element
end # WebDriver
Expand Down
12 changes: 12 additions & 0 deletions rb/lib/selenium/webdriver/common/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ class UnknownCommandError < WebDriverError; end

class StaleElementReferenceError < WebDriverError; end

#
# A command failed because the referenced shadow root is no longer attached to the DOM.
#

class DetachedShadowRootError < WebDriverError; end

#
# The target element is in an invalid state, rendering it impossible to interact with, for
# example if you click a disabled element.
Expand Down Expand Up @@ -93,6 +99,12 @@ class TimeoutError < WebDriverError; end

class NoSuchWindowError < WebDriverError; end

#
# The element does not have a shadow root.
#

class NoSuchShadowRootError < WebDriverError; end

#
# An illegal attempt was made to set a cookie under a different domain than the current page.
#
Expand Down
87 changes: 87 additions & 0 deletions rb/lib/selenium/webdriver/common/shadow_root.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# frozen_string_literal: true

# Licensed to the Software Freedom Conservancy (SFC) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The SFC licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

module Selenium
module WebDriver
class ShadowRoot
ROOT_KEY = 'shadow-6066-11e4-a52e-4f735466cecf'

include SearchContext

#
# Creates a new shadow root
#
# @api private
#

def initialize(bridge, id)
@bridge = bridge
@id = id
end

def inspect
format '#<%<class>s:0x%<hash>x id=%<id>s>', class: self.class, hash: hash * 2, id: @id.inspect
end

def ==(other)
other.is_a?(self.class) && ref == other.ref
end
alias_method :eql?, :==

def hash
@id.hash ^ @bridge.hash
end

#
# @api private
# @see SearchContext
#

def ref
[:shadow_root, @id]
end

#
# Convert to a ShadowRoot JSON Object for transmission over the wire.
# @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#basic-terms-and-concepts
#
# @api private
#

def to_json(*)
JSON.generate as_json
end

#
# For Rails 3 - http://jonathanjulian.com/2010/04/rails-to_json-or-as_json/
#
# @api private
#

def as_json(*)
{ROOT_KEY => @id}
end

private

attr_reader :bridge

end # ShadowRoot
end # WebDriver
end # Selenium
43 changes: 30 additions & 13 deletions rb/lib/selenium/webdriver/remote/bridge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ def screenshot
end

def element_screenshot(element)
execute :take_element_screenshot, id: element.ref
execute :take_element_screenshot, id: element
end

#
Expand Down Expand Up @@ -431,7 +431,7 @@ def clear_element(element)
end

def submit_element(element)
form = find_element_by('xpath', "./ancestor-or-self::form", element)
form = find_element_by('xpath', "./ancestor-or-self::form", [:element, element])
execute_script("var e = arguments[0].ownerDocument.createEvent('Event');" \
"e.initEvent('submit', true, true);" \
'if (arguments[0].dispatchEvent(e)) { arguments[0].submit() }', form.as_json)
Expand All @@ -451,19 +451,19 @@ def element_attribute(element, name)
end

def element_dom_attribute(element, name)
execute :get_element_attribute, id: element.ref, name: name
execute :get_element_attribute, id: element, name: name
end

def element_property(element, name)
execute :get_element_property, id: element.ref, name: name
execute :get_element_property, id: element, name: name
end

def element_aria_role(element)
execute :get_element_aria_role, id: element.ref
execute :get_element_aria_role, id: element
end

def element_aria_label(element)
execute :get_element_aria_label, id: element.ref
execute :get_element_aria_label, id: element
end

def element_value(element)
Expand Down Expand Up @@ -524,34 +524,47 @@ def active_element

alias_method :switch_to_active_element, :active_element

def find_element_by(how, what, parent = nil)
def find_element_by(how, what, parent_ref = [])
how, what = convert_locator(how, what)

return execute_atom(:findElements, Support::RelativeLocator.new(what).as_json).first if how == 'relative'

id = if parent
execute :find_child_element, {id: parent}, {using: how, value: what.to_s}
parent_type, parent_id = parent_ref
id = case parent_type
when :element
execute :find_child_element, {id: parent_id}, {using: how, value: what.to_s}
when :shadow_root
execute :find_shadow_child_element, {id: parent_id}, {using: how, value: what.to_s}
else
execute :find_element, {}, {using: how, value: what.to_s}
end

Element.new self, element_id_from(id)
end

def find_elements_by(how, what, parent = nil)
def find_elements_by(how, what, parent_ref = [])
how, what = convert_locator(how, what)

return execute_atom :findElements, Support::RelativeLocator.new(what).as_json if how == 'relative'

ids = if parent
execute :find_child_elements, {id: parent}, {using: how, value: what.to_s}
parent_type, parent_id = parent_ref
ids = case parent_type
when :element
execute :find_child_elements, {id: parent_id}, {using: how, value: what.to_s}
when :shadow_root
execute :find_shadow_child_elements, {id: parent_id}, {using: how, value: what.to_s}
else
execute :find_elements, {}, {using: how, value: what.to_s}
end

ids.map { |id| Element.new self, element_id_from(id) }
end

def shadow_root(element)
id = execute :get_element_shadow_root, id: element
ShadowRoot.new self, shadow_root_id_from(id)
end

private

#
Expand Down Expand Up @@ -599,7 +612,11 @@ def unwrap_script_result(arg)
end

def element_id_from(id)
id['ELEMENT'] || id['element-6066-11e4-a52e-4f735466cecf']
id['ELEMENT'] || id[Element::ELEMENT_KEY]
end

def shadow_root_id_from(id)
id[ShadowRoot::ROOT_KEY]
end

def prepare_capabilities_payload(capabilities)
Expand Down
3 changes: 3 additions & 0 deletions rb/lib/selenium/webdriver/remote/commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ class Bridge
find_elements: [:post, 'session/:session_id/elements'],
find_child_element: [:post, 'session/:session_id/element/:id/element'],
find_child_elements: [:post, 'session/:session_id/element/:id/elements'],
find_shadow_child_element: [:post, 'session/:session_id/shadow/:id/element'],
find_shadow_child_elements: [:post, 'session/:session_id/shadow/:id/elements'],
get_active_element: [:get, 'session/:session_id/element/active'],
get_element_shadow_root: [:get, 'session/:session_id/element/:id/shadow'],
is_element_selected: [:get, 'session/:session_id/element/:id/selected'],
get_element_attribute: [:get, 'session/:session_id/element/:id/attribute/:name'],
get_element_property: [:get, 'session/:session_id/element/:id/property/:name'],
Expand Down
4 changes: 2 additions & 2 deletions rb/lib/selenium/webdriver/support/event_firing_bridge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,15 @@ def find_element_by(how, what, parent = nil)
@delegate.find_element_by how, what, parent
end

Element.new self, e.ref
Element.new self, e.ref.last
end

def find_elements_by(how, what, parent = nil)
es = dispatch(:find, how, what, driver) do
@delegate.find_elements_by(how, what, parent)
end

es.map { |e| Element.new self, e.ref }
es.map { |e| Element.new self, e.ref.last }
end

def execute_script(script, *args)
Expand Down
8 changes: 4 additions & 4 deletions rb/spec/unit/selenium/webdriver/support/event_firing_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,20 @@ module Support
context 'finding elements' do
it 'fires events for find_element' do
expect(listener).to receive(:before_find).with('id', 'foo', instance_of(Driver))
allow(bridge).to receive(:find_element_by).with('id', 'foo', nil).and_return(element)
allow(bridge).to receive(:find_element_by).with('id', 'foo', [:driver, nil]).and_return(element)
expect(listener).to receive(:after_find).with('id', 'foo', instance_of(Driver))

driver.find_element(id: 'foo')
expect(bridge).to have_received(:find_element_by).with('id', 'foo', nil)
expect(bridge).to have_received(:find_element_by).with('id', 'foo', [:driver, nil])
end

it 'fires events for find_elements' do
expect(listener).to receive(:before_find).with('class name', 'foo', instance_of(Driver))
allow(bridge).to receive(:find_elements_by).with('class name', 'foo', nil).and_return([element])
allow(bridge).to receive(:find_elements_by).with('class name', 'foo', [:driver, nil]).and_return([element])
expect(listener).to receive(:after_find).with('class name', 'foo', instance_of(Driver))

driver.find_elements(class: 'foo')
expect(bridge).to have_received(:find_elements_by).with('class name', 'foo', nil)
expect(bridge).to have_received(:find_elements_by).with('class name', 'foo', [:driver, nil])
end
end

Expand Down

0 comments on commit d44b41b

Please sign in to comment.