Skip to content

Commit

Permalink
Merge pull request #46 from django-functest/web-components-and-shadow…
Browse files Browse the repository at this point in the history
…-dom

Web components and shadow dom
  • Loading branch information
spookylukey committed Apr 19, 2023
2 parents a4bbd65 + 2257c82 commit 1765f31
Show file tree
Hide file tree
Showing 11 changed files with 270 additions and 136 deletions.
4 changes: 0 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,3 @@ repos:
hooks:
- id: black
language_version: python3.9
- repo: https://github.com/mgedmin/check-manifest
rev: "0.47"
hooks:
- id: check-manifest
5 changes: 5 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
History
-------

1.5.4 (unreleased)
++++++++++++++++++

* Added some support for :doc:`shadow_dom`.

1.5.3 (2023-01-04)
++++++++++++++++++

Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Contents:
common
selenium
webtest
shadow_dom
utils
tips
pytest
Expand Down
33 changes: 33 additions & 0 deletions docs/shadow_dom.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
Shadow DOM
==========

The `shadow DOM provided by custom web components <https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM>`_ provides some challenges for django-functest.

For WebTest, the only element visible is the custom element, and not the shadow DOM which is created by Javascript. Tests that rely on functionality provided via Javascript cannot be tested via WebTest.

For Selenium, we still have the difficulty that traversing the DOM is difficult due to the boundaries of the shadow DOM. In order to traverse into a custom element’s shadow DOM, you can supply a sequence (tuple or list) of CSS selectors instead of a single CSS selector. Each CSS selector after the first one will be used starting from the `shadowRoot <https://developer.mozilla.org/en-US/docs/Web/API/Element/shadowRoot>`_ of the element located so far.

For example, if instead of an ``<input>`` you have a custom ``my-input`` element whose shadow DOM contains a single real ``<input>``, you can fill it like this:

.. code-block:: python
self.fill({("my-input#my-id", "input"): "Text"})
This works for every API that supports CSS selectors, currently with the following exceptions:

* ``assertTextPresent``
* ``assertTextAbsent``

You should note that not every element that is “part of” a custom element is within the shadow DOM:

.. code-block:: html

<my-custom-element>
<div>This is a normal element within the normal DOM</div>
</my-custom-element>


If you are using shadow DOM a lot, this may still be too awkward, and you might be better of using `Playwright for Python <https://playwright.dev/python/>`_, which `has more seamless support for shadow DOM <https://playwright.dev/python/docs/locators#locate-in-shadow-dom>`_.

At the time of writing, geckodriver does not support finding elements within a shadow root – see `https://github.com/mozilla/geckodriver/issues/2005 <https://github.com/mozilla/geckodriver/issues/2005>`_.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ profile = "black"
multi_line_output = 3
skip = ["node_modules", ".git", ".tox", "build"]
known_first_party = ["django_functest"]

[tool.ruff]
line-length = 120
2 changes: 2 additions & 0 deletions release.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/bin/sh

test $(git rev-parse --abbrev-ref HEAD | tr -d '\n') = 'master' || { echo "Must be on master branch"; exit 1; }
check-manifest || exit 1

umask 000
git ls-tree --full-tree --name-only -r HEAD | xargs chmod ugo+r
python setup.py sdist || exit 1
Expand Down
26 changes: 21 additions & 5 deletions src/django_functest/funcselenium.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from selenium.common.exceptions import NoSuchElementException, NoSuchWindowException, StaleElementReferenceException
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.command import Command
from selenium.webdriver.support.ui import Select, WebDriverWait

from .base import FuncBaseMixin
Expand Down Expand Up @@ -118,7 +119,7 @@ def get_element_attribute(self, css_selector, attribute):
or None if there is no such element or attribute.
"""
try:
element = self._driver.find_element(By.CSS_SELECTOR, css_selector)
element = self._find(css_selector=css_selector)
except NoSuchElementException:
return None
return element.get_dom_attribute(attribute)
Expand All @@ -129,7 +130,7 @@ def get_element_inner_text(self, css_selector):
the css_selector, or None if there is none.
"""
try:
element = self._driver.find_element(By.CSS_SELECTOR, css_selector)
element = self._find(css_selector=css_selector)
except NoSuchElementException:
return None
return element.text
Expand All @@ -156,7 +157,7 @@ def is_element_present(self, css_selector):
False otherwise.
"""
try:
self._driver.find_element(By.CSS_SELECTOR, css_selector)
self._find(css_selector)
except NoSuchElementException:
return False
return True
Expand Down Expand Up @@ -380,7 +381,7 @@ def is_element_displayed(self, css_selector):
present and visible on the page.
"""
try:
elem = self._driver.find_element(By.CSS_SELECTOR, css_selector)
elem = self._find(css_selector=css_selector)
except NoSuchElementException:
return False
return elem.is_displayed()
Expand Down Expand Up @@ -567,8 +568,16 @@ def _driver(self):
return self._cls_driver

def _get_finder(self, css_selector=None, xpath=None, text=None, text_parent_id=None):
def _find_by_css(driver):
css_selectors = [css_selector] if isinstance(css_selector, str) else list(css_selector)
first_selector, *remaining_selectors = css_selectors
element = driver.find_element(By.CSS_SELECTOR, first_selector)
for selector in remaining_selectors:
element = _get_shadow_root(element).find_element(By.CSS_SELECTOR, selector)
return element

if css_selector is not None:
return lambda driver: driver.find_element(By.CSS_SELECTOR, css_selector)
return _find_by_css
if xpath is not None:
return lambda driver: driver.find_element(By.XPATH, xpath)
if text is not None:
Expand Down Expand Up @@ -840,3 +849,10 @@ def _normalize_linebreaks(self, possibly_text):
# Linux)
text = text.replace("\r\n", "\n")
return text


def _get_shadow_root(element):
# Workaround the fact that `element.shadow_root` throws assertion error for Firefox.
# (finding elements still doesn't work at time of writing, but we don't want to wait
# for a Selenium release to get this fixed, when a geckodriver release may fix it).
return element._execute(Command.GET_SHADOW_ROOT)["value"]
122 changes: 122 additions & 0 deletions tests/django_functest_tests/templates/tests/web_components.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html>
<head>
<title>Web components and shadow DOMs</title>
<script>
class MyInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
const wrapper = document.createElement('div');
this.input = document.createElement('input');
const name = this.getAttribute('name');
this.input.setAttribute('part', 'input');
if (name) {
this.input.setAttribute('name', name);
}
wrapper.appendChild(this.input);
shadow.appendChild(wrapper);
this.internals = this.attachInternals();
this.input.addEventListener(
"input",
(event) => {
// update value that form has access to
this.internals.setFormValue(event.target.value);
});
}
}

class MySubmit extends HTMLElement {
static formAssociated = true;
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
this.internals = this.attachInternals();
this.shadow = shadow;
}

connectedCallback() {
setTimeout(() => { // Wait for child elements
const span = document.createElement('span');
const button = document.createElement('button');
button.innerHTML = this.innerHTML;
this.shadow.appendChild(span);
span.appendChild(button);
button.addEventListener(
"click",
(event) => {
this.internals.setFormValue(event.target.value);
const form = this.closest('form');
if (form) {
form.submit();
};
});
});
}
}

class MyDiv extends HTMLElement {
constructor() {
super();

const template = document.getElementById('my-div-template');
const templateContent = template.content;

this.attachShadow({mode: 'open'}).appendChild(
templateContent.cloneNode(true)
);
}
};

customElements.define('my-input', MyInput);
customElements.define('my-submit', MySubmit);
customElements.define('my-div', MyDiv);

</script>
</head>
<body>
<form method="GET" action="">
<my-input id="id-query" name="query"></my-input>
<input type="submit" name="normal-submit" value="Submit">
<my-submit id="id-my-submit" name="my-submit">My Submit</my-submit>
</form>
{% if 'query' in request.GET %}
<p>Submitted query: {{ request.GET.query }}</p>
{% endif %}

{% if 'my-submit' in request.GET %}
<p>my-submit was pressed</p>
{% endif %}


<h2>Nested</h2>

<template id="my-div-template">
<style>
.my-div-inner {
border: 1px solid #888;
padding: 5px;
margin: 5px;
}
</style>
<div class="my-div-inner">
<h3>my-div heading</h3>
<slot name="contents"></slot>
</div>
</template>

<my-div>
<div slot="contents">
<p>my-div slot text</p>
<my-div>
<div slot="contents">
my-div nested slot text
</div>
</my-div>
</div>
</my-div>


</body>
</html>
49 changes: 48 additions & 1 deletion tests/django_functest_tests/test_selenium.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from functools import wraps

import pytest
from django.contrib.auth import get_user_model
Expand Down Expand Up @@ -162,9 +163,55 @@ def test_scroll_method_auto(self):
self.submit('[name="mybutton"]')
self.assertTextPresent("mybutton was pressed")

def test_fill_with_shadow_root(self):
self.get_url("web_components")
self.fill({("my-input#id-query", "input"): "My query text"})
self.submit('[type="submit"]')
self.assertTextPresent("Submitted query: My query text")

def test_click_with_shadow_root(self):
self.get_url("web_components")
self.submit(["my-submit", "button"])
self.assertTextPresent("my-submit was pressed")

def test_element_utils_with_shadow_root(self):
self.get_url("web_components")
assert self.is_element_present(("my-div", "div.my-div-inner"))
assert not self.is_element_present(("my-div", "my-div")) # Nested my-div is in real DOM, not shadow DOM

assert self.is_element_displayed(("my-div", "div.my-div-inner"))

assert self.get_element_inner_text(("my-div", "div > h3")) == "my-div heading"
assert self.get_element_inner_text(("my-div my-div", "div > h3")) == "my-div heading"

assert self.get_element_attribute(("my-div", "div"), "class") == "my-div-inner"


def wrap(func):
"""
Returns function with a wrapper
"""
# Can be useful for badly behaved decorators that don't themselves wrap the
# callable and return a new one, but modify the original directly, like
# pytest.mark.xfail
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)

return wrapper


class TestFuncSeleniumSpecificFirefox(FuncSeleniumSpecificBase, FirefoxBase):
pass
# Finding elements from shadow_root in Firefox currently fails:
# https://github.com/mozilla/geckodriver/issues/2005
# From https://bugzilla.mozilla.org/show_bug.cgi?id=1700097 it may be implemented
# in Firefox 113 and related geckodriver

test_fill_with_shadow_root = pytest.mark.xfail(wrap(FuncSeleniumSpecificBase.test_fill_with_shadow_root))
test_click_with_shadow_root = pytest.mark.xfail(wrap(FuncSeleniumSpecificBase.test_click_with_shadow_root))
test_element_utils_with_shadow_root = pytest.mark.xfail(
wrap(FuncSeleniumSpecificBase.test_element_utils_with_shadow_root)
)


class TestFuncSeleniumSpecificChrome(FuncSeleniumSpecificBase, ChromeBase):
Expand Down

0 comments on commit 1765f31

Please sign in to comment.