From 06164ed049826ce153c37f85662d0ce37602c7f6 Mon Sep 17 00:00:00 2001 From: Alexandr Savelyev <74300709+QuirrelForU@users.noreply.github.com> Date: Fri, 31 Jan 2025 11:11:04 +0700 Subject: [PATCH] Add quotes escaper method --- docs/CHANGELOG.rst | 7 ++++ pomcorn/component.py | 4 ++- pomcorn/locators/base_locators.py | 45 ++++++++++++++++++++++-- pomcorn/locators/xpath_locators.py | 16 ++++++--- pyproject.toml | 2 +- tests/locators/__init__.py | 0 tests/locators/test_quote_escaper.py | 51 ++++++++++++++++++++++++++++ 7 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 tests/locators/__init__.py create mode 100644 tests/locators/test_quote_escaper.py diff --git a/docs/CHANGELOG.rst b/docs/CHANGELOG.rst index 1bd2832..d2455cb 100644 --- a/docs/CHANGELOG.rst +++ b/docs/CHANGELOG.rst @@ -3,6 +3,13 @@ Version history We follow `Semantic Versions `_. +0.8.4 (30.01.25) +******************************************************************************* +- Add escaping single and double quotes in the: ``ElementWithTextLocator``, + ``InputInLabelLocator``, ``InputByLabelLocator``, ``TextAreaByLabelLocator``. +- Add escaping single and double quotes in the ``get_item_by_text`` method of + the ``ListComponent`` + 0.8.3 (20.12.24) ******************************************************************************* - Rename ``__parameters__`` in ``ListComponent`` to ``__generic__parameters`` diff --git a/pomcorn/component.py b/pomcorn/component.py index 986b17c..8adc05c 100644 --- a/pomcorn/component.py +++ b/pomcorn/component.py @@ -352,7 +352,9 @@ def is_valid_item_class(cls, item_class: Any) -> bool: def get_item_by_text(self, text: str) -> ListItemType: """Get list item by text.""" locator = self.base_item_locator.extend_query( - extra_query=f"[contains(.,'{text}')]", + extra_query=( + f"[contains(., {self.base_item_locator._escape_quotes(text)})]" + ), ) return self._item_class(page=self.page, base_locator=locator) diff --git a/pomcorn/locators/base_locators.py b/pomcorn/locators/base_locators.py index 952b522..955d713 100644 --- a/pomcorn/locators/base_locators.py +++ b/pomcorn/locators/base_locators.py @@ -156,6 +156,47 @@ def __bool__(self) -> bool: """Return whether query of current locator is empty or not.""" return bool(self.related_query) + @classmethod + def _escape_quotes(cls, text: str) -> str: + """Escape single and double quotes in given text for use in locators. # noqa: D202, E501. + + This method is useful when locating elements + with text containing single or double quotes. + + For example, the text `He's 6'2"` will be transformed into: + `concat("He", "'", "s 6", "'", "2", '"')`. + + The resulting string can be used in XPath expressions + like `text()=...` or `contains(.,...)`. + + Returns: + The escaped text wrapped in `concat()` for XPath compatibility, + or the original text in double quotes if no escaping is needed. + + """ + + if not text or ('"' not in text and "'" not in text): + return f'"{text}"' + + escaped_parts = [] + buffer = "" # Temporary storage for normal characters + + for char in text: + if char not in ('"', "'"): + buffer += char + continue + if buffer: + escaped_parts.append(f'"{buffer}"') + buffer = "" + escaped_parts.append( + "'" + char + "'" if char == '"' else '"' + char + '"', + ) + + if buffer: + escaped_parts.append(f'"{buffer}"') + + return f"concat({', '.join(escaped_parts)})" + def extend_query(self, extra_query: str) -> XPathLocator: """Return new XPathLocator with extended query.""" return XPathLocator(query=self.query + extra_query) @@ -172,8 +213,8 @@ def contains(self, text: str, exact: bool = False) -> XPathLocator: By default, the search is based on a partial match. """ - partial_query = f"[contains(., '{text}')]" - exact_query = f"[./text()='{text}']" + partial_query = f"[contains(., {self._escape_quotes(text)})]" + exact_query = f"[./text()={self._escape_quotes(text)}]" return self.extend_query(exact_query if exact else partial_query) def prepare_relative_locator( diff --git a/pomcorn/locators/xpath_locators.py b/pomcorn/locators/xpath_locators.py index ab559dd..e25a168 100644 --- a/pomcorn/locators/xpath_locators.py +++ b/pomcorn/locators/xpath_locators.py @@ -140,8 +140,8 @@ def __init__(self, text: str, element: str = "*", exact: bool = False): partial match of the value. """ - exact_query = f'//{element}[./text()="{text}"]' - partial_query = f'//{element}[contains(.,"{text}")]' + exact_query = f"//{element}[./text()={self._escape_quotes(text)}]" + partial_query = f"//{element}[contains(.,{self._escape_quotes(text)})]" super().__init__(query=exact_query if exact else partial_query) @@ -219,7 +219,7 @@ class InputInLabelLocator(XPathLocator): def __init__(self, label: str): """Init XPathLocator.""" super().__init__( - query=f'//label[contains(., "{label}")]//input', + query=f"//label[contains(., {self._escape_quotes(label)})]//input", ) @@ -243,7 +243,10 @@ class InputByLabelLocator(XPathLocator): def __init__(self, label: str): """Init XPathLocator.""" super().__init__( - query=f'//label[contains(., "{label}")]/following-sibling::input', + query=( + f"//label[contains(., {self._escape_quotes(label)})]" + "/following-sibling::input" + ), ) @@ -259,5 +262,8 @@ class TextAreaByLabelLocator(XPathLocator): def __init__(self, label: str): """Init XPathLocator.""" super().__init__( - query=f'//*[label[contains(text(), "{label}")]]/textarea', + query=( + "//*[label[contains(text(), " + f"{self._escape_quotes(label)})]]/textarea" + ), ) diff --git a/pyproject.toml b/pyproject.toml index 45c8f37..839c254 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pomcorn" -version = "0.8.3" +version = "0.8.4" description = "Base implementation of Page Object Model" authors = [ "Saritasa ", diff --git a/tests/locators/__init__.py b/tests/locators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/locators/test_quote_escaper.py b/tests/locators/test_quote_escaper.py new file mode 100644 index 0000000..617ab7b --- /dev/null +++ b/tests/locators/test_quote_escaper.py @@ -0,0 +1,51 @@ +from pomcorn.locators.base_locators import XPathLocator + + +def test_empty_string(): + """Test that an empty string returned as wrapped empty string.""" + assert XPathLocator._escape_quotes("") == '""' + + +def test_no_quotes(): + """Test that a string without quotes returned as wrapped passed text.""" + assert XPathLocator._escape_quotes("Hello World") == '"Hello World"' + + +def test_single_quote(): + """Test escaping a string with a single quote.""" + assert ( + XPathLocator._escape_quotes("He's tall") + == 'concat("He", "\'", "s tall")' + ) + + +def test_double_quote(): + """Test escaping a string with a double quote.""" + assert ( + XPathLocator._escape_quotes('She said "Hello"') + == 'concat("She said ", \'"\', "Hello", \'"\')' + ) + + +def test_both_single_and_double_quotes(): + """Test escaping a string with both single and double quotes.""" + assert ( + XPathLocator._escape_quotes("He's 6'2\" tall") + == 'concat("He", "\'", "s 6", "\'", "2", \'"\', " tall")' + ) + + +def test_string_starts_with_quote(): + """Test escaping a string that starts with a quote.""" + assert XPathLocator._escape_quotes('"Start') == 'concat(\'"\', "Start")' + + +def test_string_ends_with_quote(): + """Test escaping a string that ends with a quote.""" + assert XPathLocator._escape_quotes('End"') == 'concat("End", \'"\')' + + +def test_string_with_only_quotes(): + """Test escaping a string that contains only quotes.""" + assert XPathLocator._escape_quotes('""') == "concat('\"', '\"')" + assert XPathLocator._escape_quotes("''") == 'concat("\'", "\'")'