diff --git a/.github/workflows/test_suite.yml b/.github/workflows/test_suite.yml index b301ceb2..2d4c5655 100644 --- a/.github/workflows/test_suite.yml +++ b/.github/workflows/test_suite.yml @@ -96,9 +96,10 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} docs: - name: Docs Build + name: 📚 Docs Build & Examples Tests runs-on: ubuntu-latest timeout-minutes: 15 + needs: setup-browsers steps: - name: Checkout uses: actions/checkout@v4 @@ -106,13 +107,27 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: "3.x" + python-version: "3.13" cache: 'pip' + - name: Install Playwright + run: pip install playwright + + - name: Restore Playwright browsers cache + uses: actions/cache/restore@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ needs.setup-browsers.outputs.playwright-version }}-browsers + fail-on-cache-miss: true + - name: Install Deps run: | pip install -U pip wheel - pip install .[docs] + pip install -e .[docs,test] + + - name: Run Example Tests (Headless) + run: | + pytest docs/examples --headless -vv - name: Build Docs run: sphinx-build -b html -d build/sphinx-doctrees docs build/htmldocs diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4c335392..67032804 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,3 +16,4 @@ repos: rev: v1.18.2 hooks: - id: mypy + exclude: ^docs/examples/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 080b0a6b..53898849 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -6,10 +6,20 @@ version: 2 # Set the OS, Python version and other tools you might need build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.12" + python: "3.13" + +# Install Python dependencies +python: + install: + - method: pip + path: . + extra_requirements: + - docs # Build documentation in the "docs/" directory with Sphinx sphinx: configuration: docs/conf.py + builder: html + fail_on_warning: false diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d4bb2cbb..00000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..5f237cef --- /dev/null +++ b/docs/README.md @@ -0,0 +1,47 @@ +# Widgetastic.Core Documentation + +This directory contains the comprehensive documentation for widgetastic.core, providing a superior learning experience for developers using this powerful web automation framework. + +## 🏗️ Building the Documentation + +### Prerequisites + +```bash +# For the full documentation experience, install widgetastic.core with doc dependencies. +pip install -e .[docs] +``` + +### Building HTML Documentation + +From the project root: + +```bash +cd docs +sphinx-build -b html . _build/html +``` + +The documentation will be built in `_build/html/`. Open `_build/html/index.html` in your browser to view. + +### Live Development Server + +For live reloading during development: + +```bash +sphinx-autobuild . _build/html --watch ../src +``` + +This will start a development server at `http://localhost:8000` with automatic rebuilding when files change. + +### Building Other Formats + +```bash +# PDF documentation +sphinx-build -b latex . _build/latex +cd _build/latex && make + +# EPUB format +sphinx-build -b epub . _build/epub + +# Single HTML file +sphinx-build -b singlehtml . _build/singlehtml +``` diff --git a/docs/advanced_usage.rst b/docs/advanced_usage.rst deleted file mode 100644 index 378e24d1..00000000 --- a/docs/advanced_usage.rst +++ /dev/null @@ -1,312 +0,0 @@ -Advanced usage -============== - -Simplified nested form fill ---------------------------- - -When you want to separate widgets into logical groups but you don't want to have a visual clutter in -the code, you can use dots in fill keys to signify the dictionary boundaries: - -.. code-block:: python - - # This: - view.fill({ - 'x': 1, - 'foo.bar': 2, - 'foo.baz': 3, - }) - - # Is equivalent to this: - view.fill({ - 'x': 1, - 'foo': { - 'bar': 2, - 'baz': 3, - } - }) - - -.. _version-picking: - -Version picking ------------------- -By version picking you can tackle the challenge of widgets changing between versions. - -In order to use this feature, you have to provide ``product_version`` property in the Browser which -should return the current version (ideally :py:class:`widgetastic.utils.Version`, otherwise you would need to redefine -the :py:attr:`widgetastic.utils.VersionPick.VERSION_CLASS` to point at you version handling class of choice) -of the product tested. - -Then you can version pick widgets on a view for example: - -.. code-block:: python - - from widgetastic.utils import Version, VersionPick - from widgetastic.widget import View, TextInput - - class MyVerpickedView(View): - hostname = VersionPick({ - # Version.lowest will match anything lower than 2.0.0 here. - Version.lowest(): TextInput(name='hostname'), - '2.0.0': TextInput(name='host_name'), - }) - -When you instantiate the ``MyVerpickedView`` and then subsequently access ``hostname`` it will -automatically pick the right widget under the hood. - -:py:class:`widgetastic.utils.VersionPick` is not limited to resolving widgets and can be used for anything. - -You can also pass the :py:class:`widgetastic.utils.VersionPick` instance as a constructor parameter into widget instantiation -on the view class. Because it utilizes :ref:`constructor-object-collapsing`, it will resolve itself -automatically. - -.. _parametrized-views: - -Parametrized views ------------------- - -If there is a repeated pattern on a page that differs only by eg. a title or an id, widgetastic has -a solution for that. You can use a :py:class:`widgetastic.widget.ParametrizedView` that takes an -arbitrary number of parameters and then you can use the parameters eg. in locators. - -.. code-block:: python - - from widgetastic.utils import ParametrizedLocator, ParametrizedString - from widgetastic.widget import ParametrizedView, TextInput - - class MyParametrizedView(ParametrizedView): - # Defining one parameter - PARAMETERS = ('thing_id', ) - # ParametrizedLocator coerces to a string upon access - # It follows similar formatting syntax as .format - # You can use the xpath quote filter as shown - ROOT = ParametrizedLocator('.//thing[@id={thing_id|quote}]') - - # Widget definition *args and values of **kwargs (only the first level) are processed as well - widget = TextInput(name=ParametrizedString('#asdf_{thing_id}')) - - # Then for invoking this: - view = MyParametrizedView(browser, additional_context={'thing_id': 'foo'}) - -It is also possible to nest the parametrized view inside another view, parametrized or otherwise. -In this case the invocation of a nested view looks like a method call, instead of looking like a -property. The invocation supports passing the arguments both ways, positional and keyword based. - -.. code-block:: python - - from widgetastic.utils import ParametrizedLocator, ParametrizedString - from widgetastic.widget import ParametrizedView, TextInput, View - - class MyView(View): - class this_is_parametrized(ParametrizedView): - # Defining one parameter - PARAMETERS = ('thing_id', ) - # ParametrizedLocator coerces to a string upon access - # It follows similar formatting syntax as .format - # You can use the xpath quote filter as shown - ROOT = ParametrizedLocator('.//thing[@id={thing_id|quote}]') - - # Widget definition *args and values of **kwargs (only the first level) are processed as well - the_widget = TextInput(name=ParametrizedString('#asdf_{thing_id}')) - - # We create the root view - view = MyView(browser) - # Now if it was an ordinary nested view, view.this_is_parametrized.the_widget would give us the - # nested view instance directly and then the the_widget widget. But this is a parametrized view - # and it will give us an intermediate object whose task is to collect the parameters upon - # calling and then pass them through into the real view object. - # This example will be invoking the parametrized view with the exactly same param like the - # previous example: - view.this_is_parametrized('foo') - # So, when we have that view, you can use it as you are used to - view.this_is_parametrized('foo').the_widget.do_something() - # Or with keyword params - view.this_is_parametrized(thing_id='foo').the_widget.do_something() - -The parametrized views also support list-like access using square braces. For that to work, you need -the ``all`` classmethod defined on the view so Widgetastic would be aware of all the items. You can -access the parametrized views by member index ``[i]`` and slice ``[i:j]``. - -It is also possible to iterate through all the occurences of the parametrized view. Let's assume the -previous code sample is still loaded and the ``this_is_parametrized`` class has the ``all()`` -defined. In that case, the code would like like this: - -.. code-block:: python - - for p_view in view.this_is_parametrized: - print(p_view.the_widget.read()) - -This sample code would go through all the occurences of the parametrization. Remember that the -``all`` classmethod IS REQUIRED in this case. - -You can also pass the :py:class:utils`ParametrizedString` instance as a constructor parameter into widget instantiation -on the view class. Because it utilizes :ref:`constructor-object-collapsing`, it will resolve itself -automatically. - -.. _constructor-object-collapsing: - -Constructor object collapsing ------------------------------ - -By using :py:class:`widgetastic.utils.ConstructorResolvable` you can create an object that can lazily resolve -itself into a different object upon widget instantiation. This is used eg. for the :ref:`version-picking` -where :py:class:`widgetastic.utils.VersionPick` descends from this class or for the parametrized strings. Just subclass this -class and implement ``.resolve(self, parent_object)`` where ``parent_object`` is the to-be parent -of the widget. - -.. _fillable-objects: - -Fillable objects ----------------- - -I bet that if you have ever used modelling approach to the entities represented in the product, you -have come across filling values in the UI and if you wanted to select the item representing given -object in the UI, you had to pick a correct attribute and know it. So you had to do something like -this (simplified example) - -.. code-block:: python - - some_form.item.fill(o.description) - -If you let the class of ``o`` implement :py:class:`widgetastic.utils.Fillable``, you can implement the method -``.as_fill_value`` which should return such value that is used in the UI. In that case, the -simplification is as follows. - -.. code-block:: python - - some_form.item.fill(o) - -You no longer have to care, the object itself know how it will be displayed in the UI. Unfortunately -this does not work the other way (automatic instantiation of objects based on values read) as that -would involve knowledge of metadata etc. That is a possible future feature. - - -.. _widget-including: - -Widget including ----------------- - -DRY is useful, right? Widgetastic thinks so, so it supports including widgets into other widgets. -Think about it more like C-style include, what it does is that it makes the receiving widget aware -of the other widgets that are going to be included and generates accessors for the widgets in -included widgets so if "flattens" the structure. All the ordering is kept. A simple example. - -.. code-block:: python - - class FormButtonsAdd(View): - add = Button('Add') - reset = Button('Reset') - cancel = Button('Cancel') - - class ItemAddForm(View): - name = TextInput(...) - description = TextInput(...) - - # ... - # ... - - buttons = View.include(FormButtonsAdd) - -This has the same effect like putting the buttons directly in ``ItemAddForm``. - -You **ABSOLUTELY MUST** be aware that in background this is not including in its literal sense. It -does not take the widget definitions and put them in the receiving class. If you access the widget -that has been included, what happens is that you actually access a descriptor proxy that looks up -the correct included hosting widget where the requested widget is hosted (it actually creates it on -demand), then the correct widget is returned. This has its benefit in the fact that any logical -structure that is built inside the included class is retained and works as one would expect, like -parametrized locators and such. - -All the included widgets in the structure share their parent with the widget where you started -including. So when instantiated, the underlying ``FormButtonsAdd`` has the same parent widget as -the ``ItemAddForm``. I did not think it would be wise to make the including widget a parent for the -included widgets due to the fact widgetastic fences the element lookup if ``ROOT`` is present on a -widget/view. However, :py:class:`widgetastic.widget.View.include` supports ``use_parent=True`` option which makes included -widgets use including widget as a parent for rare cases when it is really necessary. - - -.. _switchable-conditional-views: - -Switchable conditional views ----------------------------- - -If you have forms in your product whose parts change depending on previous selections, you might -like to use the :py:class:`widgetastic.widget.ConditionalSwitchableView`. It will allow you to represent different kinds of -views under one widget name. An example might be a view of items that can use icons, table, or -something else. You can make views that have the same interface for all the variants and then -put them together using this tool. That will allow you to interact with the different views the -same way. They display the same informations in the end. - -.. code-block:: python - - class SomeForm(View): - foo = Input('...') - action_type = Select(name='action_type') - - action_form = ConditionalSwitchableView(reference='action_type') - - # Simple value matching. If Action type 1 is selected in the select, use this view. - # And if the action_type value does not get matched, use this view as default - @action_form.register('Action type 1', default=True) - class ActionType1Form(View): - widget = Widget() - - # You can use a callable to declare the widget values to compare - @action_form.register(lambda action_type: action_type == 'Action type 2') - class ActionType2Form(View): - widget = Widget() - - # With callable, you can use values from multiple widgets - @action_form.register( - lambda action_type, foo: action_type == 'Action type 2' and foo == 2) - class ActionType2Form(View): - widget = Widget() - -You can see it gives you the flexibility of decision based on the values in the view. - -This example as shown (with Views) will behave like the ``action_form`` was a nested view. You can -also make a switchable widget. You can use it like this: - -.. code-block:: python - - class SomeForm(View): - foo = Input('...') - bar = Select(name='bar') - - switched_widget = ConditionalSwitchableView(reference='bar') - - switched_widget.register('Action type 1', default=True, widget=Widget()) - -Then instead of switching views, it switches widgets. - -IFrame support is views ------------------------------ - -If some html page has embedded iframes, those can be covered using regular view. -You just need to set FRAME property for it. FRAME should point out to appropriate iframe and can be xpath and whatever supported by widgetastic. - -Since iframe is another page, all its bits consider iframe as root. This has to be taken into account during creating object structure. - -If regular views and iframe views are mixed, widgetastic takes care of switching between frames on widget access. -User doesn't need to undertake any actions. - -Below is example of usage. More examples can be found in unit tests. - -.. code-block:: python - - class FirstIFrameView(View): - FRAME = '//iframe[@name="some_iframe"]' - - h3 = Text('.//h3') - select1 = Select(id='iframe_select1') - select2 = Select(name='iframe_select2') - - class RegularView(View): - h3 = Text('//h3[@id="someid-1"]') - checkbox1 = Checkbox(id='checkbox-1') - - class SecondIFrameView(View): - FRAME = './/iframe[@name="another_iframe"]' - - widget1 = Widget() - widget2 = Widget() diff --git a/docs/basic_usage.rst b/docs/basic_usage.rst deleted file mode 100644 index e4b1e618..00000000 --- a/docs/basic_usage.rst +++ /dev/null @@ -1,66 +0,0 @@ -Basic usage ------------ - -**ATTENTION!**: Read the :ref:`widgetastic-usage-guidelines` carefully before starting out. - -This sample only represents simple UI interaction. - -.. code-block:: python - - from playwright.sync_api import sync_playwright - from widgetastic.browser import Browser - from widgetastic.widget import View, Text, TextInput - - - # Subclass the default browser, add product_version property, plug in the hooks ... - class CustomBrowser(Browser): - pass - - # Create a view that represents a page - class MyView(View): - a_text = Text(locator='.//h3[@id="title"]') - an_input = TextInput(name='my_input') - - # Or a portion of it - @View.nested # not necessary but you need it if you need to keep things ordered - class my_subview(View): - # You can specify a root locator, then this view responds to is_displayed and can be - # used as a parent for widget lookup - ROOT = 'div#somediv' - another_text = Text(locator='#h2') # See "Automatic simple CSS locator detection" - - # Initialize Playwright and create browser instance - with sync_playwright() as p: - playwright_browser = p.chromium.launch() # or p.firefox.launch() - context = playwright_browser.new_context() - page = context.new_page() - browser = CustomBrowser(page) - - # Navigate - browser.url = "https://foo.com" - - # Now we have the widgetastic browser ready for work - # Let's instantiate a view. - a_view = MyView(browser) - # ^^ you would typically come up with some way of integrating this in your framework. - - # The defined widgets now work as you would expect - a_view.read() # returns a recursive dictionary of values that all widgets provide via read() - a_view.a_text.text # Accesses the text - # but the .text is widget-specific, so you might like to use just .read() - a_view.fill({'an_input': 'foo'}) # Fills an_input with foo and returns boolean whether anything changed - # Basically equivalent to: - a_view.an_input.fill('foo') # Since views just dispatch fill to the widgets based on the order - a_view.an_input.is_displayed - - # Clean up resources - context.close() - playwright_browser.close() - - -Typically, you want to incorporate a system that would do the navigation (like -`navmazing `_ for example), as Widgetastic only facilitates -UI interactions. - -An example of such integration is currently **TODO**, but it will eventually appear here once a PoC -for a different project will happen. diff --git a/docs/conf.py b/docs/conf.py index d7375565..83aa5f96 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,33 +1,81 @@ +# Configuration file for the Sphinx documentation builder. +import os +import sys from datetime import datetime +# -- Path setup -------------------------------------------------------------- + +sys.path.insert(0, os.path.abspath("../src")) + # -- Project information ----------------------------------------------------- -project = "widgetastic.core" +project = "Widgetastic.Core" +author = "Milan Falešník, Red Hat, Inc." copyright = ( f"2016-2019, Milan Falešník; 2020-{datetime.now().year}, Red Hat, Inc. (Apache license 2)" ) -author = "Milan Falešník, Red Hat, Inc." # -- General configuration --------------------------------------------------- extensions = [ "sphinx.ext.autodoc", + "sphinx.ext.autosummary", "sphinx.ext.doctest", "sphinx.ext.napoleon", "sphinx.ext.intersphinx", + "sphinx.ext.viewcode", + "sphinx.ext.todo", ] +master_doc = "index" +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# -- Options for HTML output ------------------------------------------------- + +html_theme = "sphinx_rtd_theme" + +# -- Extension configuration ------------------------------------------------- + intersphinx_mapping = { - "python": ("http://docs.python.org/3.12/", None), - "playwright": ("https://playwright.dev/python/", None), + "python": ("https://docs.python.org/3/", None), + # Playwright doesn't have a proper intersphinx inventory yet + # "playwright": ("https://playwright.dev/python/", None), } -templates_path = ["_templates"] +# Configure warnings and error handling +nitpick_ignore = [ + # Ignore missing references that are known to be problematic + ("py:class", "RootResolverError"), # anytree reference that may not exist + ("py:exc", "RootResolverError"), # Exception variant + ("any", "RootResolverError"), # Any reference type +] -master_doc = "index" +autodoc_member_order = "bysource" +autosummary_generate = True -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +# Suppress warnings for missing references +suppress_warnings = ["ref.python"] -# -- Options for HTML output ------------------------------------------------- +# Make cross-references non-strict to avoid ambiguity errors +nitpicky = False + +# Configure autodoc to be less strict about signatures and types +autodoc_typehints = "description" +autodoc_type_aliases: dict[str, str] = {} + +# Configure autosummary +autosummary_mock_imports: list[str] = [] + +# -- Napoleon settings ------------------------------------------------------- -html_theme = "nature" +napoleon_google_docstring = True +napoleon_numpy_docstring = True +napoleon_include_init_with_doc = False +napoleon_include_private_with_doc = False +napoleon_include_special_with_doc = True +napoleon_use_admonition_for_examples = False +napoleon_use_admonition_for_notes = False +napoleon_use_admonition_for_references = False +napoleon_use_ivar = False +napoleon_use_param = True +napoleon_use_rtype = True diff --git a/docs/examples/README.md b/docs/examples/README.md new file mode 100644 index 00000000..6bbe4c55 --- /dev/null +++ b/docs/examples/README.md @@ -0,0 +1,20 @@ +# Documentation Examples + +Executable Python examples for Widgetastic.Core documentation. + +## Running Examples + +```bash +# Run all examples just like normal pytest run. +pytest docs/examples + +# with headless mode +pytest docs/examples --headless +``` + +## How It Works + +**`conftest.py`** collects all `.py` files and runs them as tests: + +- **Regular examples**: Get `browser` instance from `browser_setup.py` +- **Standalone examples** (with `sync_playwright`): Run in subprocess with their own browser setup diff --git a/docs/examples/__init__.py b/docs/examples/__init__.py new file mode 100644 index 00000000..3ced687d --- /dev/null +++ b/docs/examples/__init__.py @@ -0,0 +1 @@ +"""Examples for Widgetastic.Core documentation.""" diff --git a/docs/examples/basic-widgets/checkbox.py b/docs/examples/basic-widgets/checkbox.py new file mode 100644 index 00000000..d9b69989 --- /dev/null +++ b/docs/examples/basic-widgets/checkbox.py @@ -0,0 +1,23 @@ +"""Checkbox Widget Example + +This example demonstrates checkbox operations. +""" + +from widgetastic.widget import Checkbox + +enabled_checkbox = Checkbox(browser, id="input2") # noqa: F821 +disabled_checkbox = Checkbox(browser, id="input2_disabled") # noqa: F821 + +# Check is_displayed and is_enabled +print(f"Enabled checkbox is displayed: {enabled_checkbox.is_displayed}") +print(f"Disabled checkbox is displayed: {disabled_checkbox.is_displayed}") + +print(f"Enabled checkbox is enabled: {enabled_checkbox.is_enabled}") +print(f"Disabled checkbox is enabled: {disabled_checkbox.is_enabled}") + +# Filling and reading checkboxes +enabled_checkbox.fill(True) +print(f"After fill(True), read returns: {enabled_checkbox.read()}") + +enabled_checkbox.fill(False) +print(f"After fill(False), read returns: {enabled_checkbox.read()}") diff --git a/docs/examples/basic-widgets/colourinput.py b/docs/examples/basic-widgets/colourinput.py new file mode 100644 index 00000000..1acf660d --- /dev/null +++ b/docs/examples/basic-widgets/colourinput.py @@ -0,0 +1,16 @@ +"""ColourInput Widget Example + +This example demonstrates HTML5 color picker operations. +""" + +from widgetastic.widget import ColourInput + +colour_input = ColourInput(browser, id="colourinput") # noqa: F821 + +# Color operations +colour_input.fill("#ff0000") +print(f"After fill('#ff0000'), read returns: {colour_input.read()}") + +# Set different colors with colour setter property +colour_input.colour = "#00ff00" +print(f"After setting colour to '#00ff00': {colour_input.colour}") diff --git a/docs/examples/basic-widgets/fileinput.py b/docs/examples/basic-widgets/fileinput.py new file mode 100644 index 00000000..c40dd0c1 --- /dev/null +++ b/docs/examples/basic-widgets/fileinput.py @@ -0,0 +1,15 @@ +"""FileInput Widget Example + +This example demonstrates file upload operations. +""" + +from widgetastic.widget import FileInput + +file_input = FileInput(browser, id="fileinput") # noqa: F821 + +print(f"File input is displayed: {file_input.is_displayed}") +print(f"File input is enabled: {file_input.is_enabled}") + +# File upload operations +result = file_input.fill("/etc/resolv.conf") +print(f"File upload result: {result}") diff --git a/docs/examples/basic-widgets/image.py b/docs/examples/basic-widgets/image.py new file mode 100644 index 00000000..9d5c660a --- /dev/null +++ b/docs/examples/basic-widgets/image.py @@ -0,0 +1,16 @@ +"""Image Widget Example + +This example demonstrates accessing HTML image elements. +""" + +from widgetastic.widget import Image + +full_image = Image(browser, locator="#test-image-full") # noqa: F821 + +# Check image visibility +print(f"Image is displayed: {full_image.is_displayed}") + +# Accessing image attributes +print(f"Image src: {full_image.src}") +print(f"Image alt: {full_image.alt}") +print(f"Image title: {full_image.title}") diff --git a/docs/examples/basic-widgets/select_widget.py b/docs/examples/basic-widgets/select_widget.py new file mode 100644 index 00000000..e4b69f2b --- /dev/null +++ b/docs/examples/basic-widgets/select_widget.py @@ -0,0 +1,27 @@ +"""Select Widget Example + +This example demonstrates select dropdown operations. +""" + +from widgetastic.widget import Select + +single_select = Select(browser, name="testselect1") # noqa: F821 +multi_select = Select(browser, name="testselect2") # noqa: F821 + +# Reading selected values +print(f"Single select current value: {single_select.read()}") + +# Get all available options +print(f"All available options: {single_select.all_options}") + +# Select by visible text +single_select.fill("Bar") +print(f"After fill('Bar'): {single_select.read()}") + +# Select by value +single_select.fill(("by_value", "foo")) +print(f"After fill by value 'foo': {single_select.read()}") + +# Multiple selection +multi_select.fill(["Foo", "Baz"]) +print(f"Multiple select values: {multi_select.read()}") diff --git a/docs/examples/basic-widgets/text_widget_basic.py b/docs/examples/basic-widgets/text_widget_basic.py new file mode 100644 index 00000000..8a043323 --- /dev/null +++ b/docs/examples/basic-widgets/text_widget_basic.py @@ -0,0 +1,24 @@ +"""Basic Text Widget Example + +This example demonstrates how to use the Text widget to extract text content. +""" + +from widgetastic.widget import Text +from widgetastic.exceptions import NoSuchElementException + +# In-line Initialization of Text widget +main_title = Text(parent=browser, locator=".//h1[@id='wt-core-title']") # noqa: F821 + +# Widget operations +print(f"Title is displayed: {main_title.is_displayed}") +print(f"Title is enabled: {main_title.is_enabled}") +print(f"Using .text property: '{main_title.text}'") +print(f"Using .read() method: '{main_title.read()}'") + +# Handling Non-Existing Elements +non_existing_element = Text(browser, locator='.//div[@id="non-existing-element"]') # noqa: F821 +print(f"Non-existing element is displayed: {non_existing_element.is_displayed}") +try: + non_existing_element.read() +except NoSuchElementException: + print("NoSuchElementException raised as expected") diff --git a/docs/examples/basic-widgets/textinput_basic.py b/docs/examples/basic-widgets/textinput_basic.py new file mode 100644 index 00000000..8edfcbb3 --- /dev/null +++ b/docs/examples/basic-widgets/textinput_basic.py @@ -0,0 +1,17 @@ +"""Basic TextInput Widget Example + +This example demonstrates basic TextInput operations. +""" + +from widgetastic.widget import TextInput + +# Inline initialization for learning +text_input = TextInput(parent=browser, id="input") # noqa: F821 + +# Widget operations +print(f"Text input is displayed: {text_input.is_displayed}") +print(f"Text input is enabled: {text_input.is_enabled}") + +text_input.fill("Hello World") +print(f"After fill, .value returns: '{text_input.value}'") +print(f"After fill, .read() returns: '{text_input.read()}'") diff --git a/docs/examples/basic-widgets/textinput_different_types.py b/docs/examples/basic-widgets/textinput_different_types.py new file mode 100644 index 00000000..092fe12d --- /dev/null +++ b/docs/examples/basic-widgets/textinput_different_types.py @@ -0,0 +1,17 @@ +"""TextInput with Different Element Types + +This example shows how TextInput works with different HTML input types. +""" + +from widgetastic.widget import TextInput + +# Number input +number_input = TextInput(parent=browser, locator='.//input[@id="input_number"]') # noqa: F821 +number_input.fill("42") +print(f"Number input read value: {number_input.read()}") + +# Textarea (multi-line) +textarea = TextInput(parent=browser, id="textarea_input") # noqa: F821 +multiline_text = "Line 1\nLine 2\nLine 3" +textarea.fill(multiline_text) +print(f"Textarea read value: {textarea.read()}") diff --git a/docs/examples/basic-widgets/textinput_state_management.py b/docs/examples/basic-widgets/textinput_state_management.py new file mode 100644 index 00000000..69f4f1d2 --- /dev/null +++ b/docs/examples/basic-widgets/textinput_state_management.py @@ -0,0 +1,22 @@ +"""TextInput State Management Example + +This example demonstrates checking widget state and fill behavior. +""" + +from widgetastic.widget import TextInput + +# Check if element exists and is accessible +enabled_input = TextInput(parent=browser, id="input1") # noqa: F821 +disabled_input = TextInput(parent=browser, name="input1_disabled") # noqa: F821 + +print(f"Enabled input is displayed: {enabled_input.is_displayed}") +print(f"Enabled input is enabled: {enabled_input.is_enabled}") +print(f"Disabled input is enabled: {disabled_input.is_enabled}") + +# Fill success checking +result1 = enabled_input.fill("new value") +print(f"First fill('new value') returned: {result1}") + +# Try to fill same value - no change detected and returns False +result2 = enabled_input.fill("new value") +print(f"Second fill('new value') returned: {result2}") diff --git a/docs/examples/browser_setup.py b/docs/examples/browser_setup.py new file mode 100644 index 00000000..8b52040a --- /dev/null +++ b/docs/examples/browser_setup.py @@ -0,0 +1,29 @@ +import inspect +import os +from pathlib import Path +from playwright.sync_api import sync_playwright +from widgetastic.browser import Browser + + +def setup_browser(): + """Setup browser with widgetastic testing page.""" + + # Initialize Playwright + p = sync_playwright().start() + headless = os.getenv("PLAYWRIGHT_HEADLESS", "false").lower() == "true" + browser_instance = p.chromium.launch(headless=headless) + context = browser_instance.new_context(viewport={"width": 1920, "height": 1080}) + page = context.new_page() + wt_browser = Browser(page) + + # Navigate to testing page + base_path = Path(inspect.getfile(Browser)).parent.parent.parent + test_page_path = base_path / "testing" / "html" / "testing_page.html" + test_page_url = test_page_path.as_uri() + wt_browser.goto(test_page_url, wait_until="load") + + return wt_browser + + +# Usage +browser = setup_browser() diff --git a/docs/examples/conftest.py b/docs/examples/conftest.py new file mode 100644 index 00000000..752e3efa --- /dev/null +++ b/docs/examples/conftest.py @@ -0,0 +1,146 @@ +"""Pytest configuration for documentation examples.""" + +import os +import pytest +from pathlib import Path +import sys + +EXCLUDED_NAMES = {"__init__.py", "conftest.py"} + +# Add project source to Python path for widgetastic imports +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root / "src")) + +# Add examples directory to path for browser_setup import +examples_dir = Path(__file__).parent +sys.path.insert(0, str(examples_dir)) + + +def pytest_addoption(parser): + """Add command-line options for example tests.""" + parser.addoption( + "--headless", + action="store_true", + default=False, + help="Run tests in headless mode (no browser window). Default is headed mode locally, headless in CI.", + ) + + +def pytest_ignore_collect(collection_path, config): + """Ignore standard Python files - we collect them manually.""" + if not isinstance(collection_path, Path): + collection_path = Path(collection_path) + + if collection_path.name in EXCLUDED_NAMES: + return True + + if collection_path.suffix == ".py": + return True + + return False + + +def pytest_collect_directory(path, parent): + """Collect example files from directories.""" + if not isinstance(path, Path): + path = Path(path) + + try: + path.relative_to(Path(examples_dir)) + except ValueError: + return None + + return ExampleDirectory.from_parent(parent, path=path) + + +class ExampleDirectory(pytest.Directory): + """Directory collector for example files.""" + + def collect(self): + for py_file in self.path.glob("*.py"): + if py_file.name not in EXCLUDED_NAMES: + yield ExampleItem.from_parent(self, name=py_file.name, example_file=py_file) + + for subdir in self.path.iterdir(): + if subdir.is_dir() and not subdir.name.startswith(("_", ".")): + yield ExampleDirectory.from_parent(self, path=subdir) + + +class ExampleItem(pytest.Item): + """Test item for a single example file.""" + + def __init__(self, name, parent, example_file): + super().__init__(name, parent) + self.example_file = ( + Path(example_file) if not isinstance(example_file, Path) else example_file + ) + + def runtest(self): + """Execute the example file.""" + import subprocess + + with open(self.example_file, "r") as f: + code = f.read() + + has_own_browser = "sync_playwright" in code + + if has_own_browser: + # Validate the file path is within the examples directory for security + try: + resolved_path = self.example_file.resolve() + examples_path = Path(examples_dir).resolve() + resolved_path.relative_to(examples_path) + except (ValueError, OSError): + raise ValueError( + f"Example file path {self.example_file} is outside the examples directory" + ) + + # Ensure it's a Python file + if resolved_path.suffix != ".py": + raise ValueError(f"Example file must be a Python file: {self.example_file}") + + result = subprocess.run( + [sys.executable, str(resolved_path)], + cwd=str(resolved_path.parent), + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise Exception( + f"Example failed with exit code {result.returncode}\n" + f"STDOUT:\n{result.stdout}\n" + f"STDERR:\n{result.stderr}" + ) + if result.stdout: + print(result.stdout, end="") + return + + namespace = { + "__name__": "__main__", + "__file__": str(self.example_file), + "__builtins__": __builtins__, + "browser": self.session.config._browser_instance, + } + + exec(compile(code, str(self.example_file), "exec"), namespace) + + def repr_failure(self, excinfo): + return f"Example {self.name} failed: {excinfo.value}" + + def reportinfo(self): + return self.example_file, 0, f"example: {self.name}" + + +def pytest_configure(config): + """Setup browser instance for all tests.""" + # Determine headless mode: --headless flag OR CI environment + headless = config.getoption("--headless") or os.getenv("CI", "false").lower() == "true" + + # Set environment variable for browser_setup to use + os.environ["PLAYWRIGHT_HEADLESS"] = str(headless).lower() + + from browser_setup import browser + + browser.refresh(wait_until="load") + config._browser_instance = browser diff --git a/docs/examples/fill-strategies/default_fill_strategy_examples.py b/docs/examples/fill-strategies/default_fill_strategy_examples.py new file mode 100644 index 00000000..02ce7d35 --- /dev/null +++ b/docs/examples/fill-strategies/default_fill_strategy_examples.py @@ -0,0 +1,95 @@ +# Example: Basic Usage +"""DefaultFillViewStrategy Examples + +This comprehensive example demonstrates all aspects of DefaultFillViewStrategy. +""" + +from widgetastic.utils import DefaultFillViewStrategy +from widgetastic.widget import View, TextInput, Checkbox, Widget + + +class BasicForm(View): + input1 = TextInput(name="input1") + input2 = TextInput(name="fill_with_2") + checkbox1 = Checkbox(id="input2") + + # Explicitly set the default strategy (optional - it's the default) + fill_strategy = DefaultFillViewStrategy() + + +# Create view instance +view = BasicForm(browser) # noqa: F821 + +# Fill multiple widgets at once +changed = view.fill({"input1": "test_value", "checkbox1": True}) + +print(f"Fill changed values: {changed}") +print(f"Current values: {view.read()}") +# End Example: Basic Usage + +# Example: Filtering None Values +values_with_none = { + "input1": "value1", + "input2": None, # This will be filtered out + "checkbox1": True, +} + +view.fill(values_with_none) +print(f"After filling with None values: {view.read()}") +# End Example: Filtering None Values + +# Example: Handling Extra Keys +import logging # noqa: E402 + +logging.basicConfig(level=logging.WARNING) + +values_with_extras = { + "input1": "value1", + "nonexistent_widget": "value2", # This doesn't exist + "another_extra": "value3", # This doesn't exist either +} + +# When filling, you'll get a warning in logs: +# "Extra values that have no corresponding fill fields passed: another_extra, nonexistent_widget" +view.fill(values_with_extras) +# End Example: Handling Extra Keys + + +# Example: Handling Widgets Without Fill +class NoFillWidget(Widget): + """Widget without fill method.""" + + pass + + +class TestForm(View): + input1 = TextInput(name="input1") + no_fill_widget = NoFillWidget() + input2 = TextInput(name="fill_with_2") + + fill_strategy = DefaultFillViewStrategy() + + +test_view = TestForm(browser) # noqa: F821 + +# Fill operation will skip no_fill_widget and log a warning +values = { + "input1": "value1", + "no_fill_widget": "will_skip", # This will be skipped + "input2": "value2", +} + +result = test_view.fill(values) +print(f"Fill result: {result}") +print(f"Current values: {test_view.read()}") +# End Example: Handling Widgets Without Fill + +# Example: Change Detection +# First fill - values are new, so returns True +result1 = view.fill({"input1": "test_value", "checkbox1": True}) +print(f"First fill result: {result1}") + +# Second fill with same values - no change, returns False +result2 = view.fill({"input1": "test_value", "checkbox1": True}) +print(f"Second fill result: {result2}") +# End Example: Change Detection diff --git a/docs/examples/fill-strategies/strategy_inheritance_examples.py b/docs/examples/fill-strategies/strategy_inheritance_examples.py new file mode 100644 index 00000000..c13473e5 --- /dev/null +++ b/docs/examples/fill-strategies/strategy_inheritance_examples.py @@ -0,0 +1,39 @@ +"""Strategy Inheritance Examples + +This example demonstrates how child views inherit parent's fill strategy. +""" + +# Example: Without Inheritance +from widgetastic.utils import WaitFillViewStrategy +from widgetastic.widget import View, TextInput + + +# Example: Without respect_parent (default behavior) +class ParentViewNoInherit(View): + fill_strategy = WaitFillViewStrategy(wait_widget="10s") + + @View.nested + class ChildView(View): + input1 = TextInput(name="input1") + + +parent_view = ParentViewNoInherit(browser) # noqa: F821 +print(f"Parent strategy: {type(parent_view.fill_strategy).__name__}") +print(f"Child strategy: {type(parent_view.ChildView.fill_strategy).__name__}") +# End Example: Without Inheritance + + +# Example: With Inheritance +# Example: With respect_parent=True +class ParentViewWithInherit(View): + fill_strategy = WaitFillViewStrategy(respect_parent=True, wait_widget="10s") + + @View.nested + class ChildView(View): + input1 = TextInput(name="input1") + + +parent_view2 = ParentViewWithInherit(browser) # noqa: F821 +print(f"Parent strategy: {type(parent_view2.fill_strategy).__name__}") +print(f"Child strategy: {type(parent_view2.ChildView.fill_strategy).__name__}") +# End Example: With Inheritance diff --git a/docs/examples/fill-strategies/wait_fill_strategy_examples.py b/docs/examples/fill-strategies/wait_fill_strategy_examples.py new file mode 100644 index 00000000..4bd1f15a --- /dev/null +++ b/docs/examples/fill-strategies/wait_fill_strategy_examples.py @@ -0,0 +1,44 @@ +# Example: Basic Usage +"""WaitFillViewStrategy Examples + +This comprehensive example demonstrates WaitFillViewStrategy usage. +""" + +from widgetastic.utils import WaitFillViewStrategy +from widgetastic.widget import View, TextInput, Checkbox + + +class DynamicForm(View): + input1 = TextInput(name="input1") + checkbox1 = Checkbox(id="input2") + + # Use wait strategy with default 5-second timeout + fill_strategy = WaitFillViewStrategy() + + +view = DynamicForm(browser) # noqa: F821 + +# Fill operation will wait for each widget to be displayed +changed = view.fill({"input1": "wait_test_value", "checkbox1": True}) + +print(f"Fill changed values: {changed}") +print(f"Current values: {view.read()}") +# End Example: Basic Usage + + +# Example: Custom Wait Timeout +class DynamicFormCustomTimeout(View): + input1 = TextInput(name="input1") + input2 = TextInput(name="fill_with_2") + checkbox1 = Checkbox(id="input2") + + # Custom 10-second timeout per widget + fill_strategy = WaitFillViewStrategy(wait_widget="10s") + + +view_custom = DynamicFormCustomTimeout(browser) # noqa: F821 + +# Each widget will wait up to 10 seconds to be displayed +view_custom.fill({"input1": "custom_wait_test", "input2": "another_value"}) +print(f"Current values: {view_custom.read()}") +# End Example: Custom Wait Timeout diff --git a/docs/examples/getting-started/batch_fill_example.py b/docs/examples/getting-started/batch_fill_example.py new file mode 100644 index 00000000..06881b61 --- /dev/null +++ b/docs/examples/getting-started/batch_fill_example.py @@ -0,0 +1,18 @@ +"""Batch Fill Example + +This example demonstrates filling a form in a single operation. +This is a code snippet that assumes form_view is already created. +See first_script.py for the complete example. +""" + +# We can fill the form at single shot. Widgetastic will fill the form in the order of the widgets. +# This example assumes form_view is already created (see first_script.py for full context) +data = { + "custname": "John Doe", + "telephone": "1234567890", + "email": "john.doe@example.com", + "pizza_size": {"small": True}, + "pizza_toppings": {"bacon": True}, + "delivery_instructions": "Hello from Widgetastic!", +} +# form_view.fill(data) # Uncomment when form_view is available diff --git a/docs/examples/getting-started/first_script.py b/docs/examples/getting-started/first_script.py new file mode 100644 index 00000000..a87a2165 --- /dev/null +++ b/docs/examples/getting-started/first_script.py @@ -0,0 +1,81 @@ +"""Complete First Script Example + +This is a complete, working example that demonstrates core widgetastic concepts. +""" + +# first_script.py +import json +import os + +from playwright.sync_api import sync_playwright +from widgetastic.browser import Browser +from widgetastic.widget import View, Text, TextInput, Checkbox + + +# Define your widgets and views i.e. Modeling of the testing page. +class DemoFormView(View): + # Define the form elements as widgets + custname = TextInput(locator='.//input[@name="custname"]') + telephone = TextInput(locator='.//input[@name="custtel"]') + email = TextInput(locator='.//input[@name="custemail"]') + + @View.nested + class pizza_size(View): # noqa + small = Checkbox(locator=".//input[@value='small']") + medium = Checkbox(locator=".//input[@value='medium']") + large = Checkbox(locator=".//input[@value='large']") + + @View.nested + class pizza_toppings(View): # noqa + bacon = Checkbox(locator=".//input[@value='bacon']") + extra_cheese = Checkbox(locator=".//input[@value='cheese']") + onion = Checkbox(locator=".//input[@value='onion']") + mushroom = Checkbox(locator=".//input[@value='mushroom']") + + delivery_instructions = TextInput(locator='.//textarea[@name="comments"]') + submit_order = Text(".//button[text()='Submit order']") + + response = Text( + ".//body" + ) # After submitting the form, we will get the response in the body of the page. + + +# Step: Main automation logic where actualy we are interacting with the page. +def main(): + # Get headless mode from environment (set by conftest or CI) + headless = os.getenv("PLAYWRIGHT_HEADLESS", "false").lower() == "true" + + with sync_playwright() as playwright: + # Launch browser using Playwright + browser = playwright.chromium.launch(headless=headless) + # Tip: Use slow_mo for debugging: browser = playwright.chromium.launch(headless=False, slow_mo=500) + page = browser.new_page() + + # Create widgetastic browser instance + wt_browser = Browser(page) + + # Navigate to the testing page. + wt_browser.url = "https://httpbin.org/forms/post" + + # Initialize the view i.e. Model of the testing page. + form_view = DemoFormView(wt_browser) + + # Fill individual fields + form_view.custname.fill("John Doe") + form_view.telephone.fill("1234567890") + form_view.email.fill("john.doe@example.com") + form_view.pizza_size.small.fill(True) + form_view.pizza_toppings.bacon.fill(True) + form_view.delivery_instructions.fill("Hello from Widgetastic!") + + form_view.submit_order.click() + + response_data = json.loads(form_view.response.text) + print("Response data:") + print(json.dumps(response_data, indent=4)) + # Close the browser + browser.close() + + +if __name__ == "__main__": + main() diff --git a/docs/examples/getting-started/test_installation.py b/docs/examples/getting-started/test_installation.py new file mode 100644 index 00000000..5197e74b --- /dev/null +++ b/docs/examples/getting-started/test_installation.py @@ -0,0 +1,32 @@ +# test_installation.py + +import os +from playwright.sync_api import sync_playwright +from widgetastic.browser import Browser +from widgetastic.widget import View, Text + + +class TestView(View): + title = Text("title") + + +def test_widgetastic(): + # Get headless mode from environment (set by conftest or CI) + headless = os.getenv("PLAYWRIGHT_HEADLESS", "false").lower() == "true" + + with sync_playwright() as p: + browser = p.chromium.launch(headless=headless) + page = browser.new_page() + page.goto("https://example.com") + + wt_browser = Browser(page) + view = TestView(wt_browser) + + print(f"Page title: {view.title.text}") + print("✅ Widgetastic is working correctly!") + + browser.close() + + +if __name__ == "__main__": + test_widgetastic() diff --git a/docs/examples/iframe-handling/basic_iframe.py b/docs/examples/iframe-handling/basic_iframe.py new file mode 100644 index 00000000..847a1541 --- /dev/null +++ b/docs/examples/iframe-handling/basic_iframe.py @@ -0,0 +1,42 @@ +"""Basic IFrame Access + +This example demonstrates accessing elements inside an iframe. +""" + +from widgetastic.widget import View, Text, Select + + +class BasicIFrameView(View): + # The FRAME attribute specifies the iframe locator + FRAME = '//iframe[@name="some_iframe"]' + + # Widgets inside the iframe + iframe_title = Text(".//h3") + select1 = Select(id="iframe_select1") + select2 = Select(name="iframe_select2") + + +iframe_view = BasicIFrameView(browser) # noqa: F821 + +# Test basic iframe access +print(f"IFrame displayed: {iframe_view.is_displayed}") +print(f"IFrame title: {iframe_view.iframe_title.read()}") + +# Interact with iframe widgets +current_selection = iframe_view.select1.read() +print(f"Current selection: {current_selection}") + +# Change selection +iframe_view.select1.fill("Bar") +print(f"New selection: {iframe_view.select1.read()}") + +# Working with multi-select in iframe +print(f"Multi-select options: {iframe_view.select2.all_options}") + +# Select multiple options +iframe_view.select2.fill(["Foo", "Baz"]) +selected = iframe_view.select2.read() +print(f"Multi-selected: {selected}") + +# Clean up: Return to main frame +browser.switch_to_main_frame() # noqa: F821 diff --git a/docs/examples/iframe-handling/context_isolation.py b/docs/examples/iframe-handling/context_isolation.py new file mode 100644 index 00000000..cde9ff15 --- /dev/null +++ b/docs/examples/iframe-handling/context_isolation.py @@ -0,0 +1,44 @@ +"""IFrame Context Isolation + +This example demonstrates that iframe contexts are completely isolated. +""" + +from widgetastic.widget import View, Text, Select, Checkbox + + +class MainPageView(View): + # Elements in main page context + main_title = Text("h1#wt-core-title") + main_checkbox = Checkbox(id="switchabletesting-3") + + +class IFrameView(View): + FRAME = '//iframe[@name="some_iframe"]' + iframe_title = Text(".//h3") + iframe_select = Select(id="iframe_select1") + + +main_view = MainPageView(browser) # noqa: F821 +iframe_view = IFrameView(browser) # noqa: F821 + +# Both contexts work independently +print(f"Main page title: {main_view.main_title.read()}") +print(f"IFrame title: {iframe_view.iframe_title.read()}") + +# Interactions don't affect each other +print("Testing context isolation:") +main_view.main_checkbox.fill(True) +iframe_view.iframe_select.fill("Bar") + +# Verify isolation - both maintain their states +main_checkbox_state = main_view.main_checkbox.read() +iframe_select_state = iframe_view.iframe_select.read() + +print(f"Main checkbox state: {main_checkbox_state}") +print(f"IFrame select state: {iframe_select_state}") + +if main_checkbox_state is True and iframe_select_state == "Bar": + print("✓ Context isolation verified") + +# Clean up: Return to main frame +browser.switch_to_main_frame() # noqa: F821 diff --git a/docs/examples/iframe-handling/nested_iframe.py b/docs/examples/iframe-handling/nested_iframe.py new file mode 100644 index 00000000..3999a26b --- /dev/null +++ b/docs/examples/iframe-handling/nested_iframe.py @@ -0,0 +1,45 @@ +"""Nested IFrame Navigation + +This example demonstrates handling nested iframes (iframe within iframe). +""" + +from widgetastic.widget import View, Text, Select, TextInput + + +class NestedIFrameView(View): + # First level iframe + FRAME = '//iframe[@name="some_iframe"]' + iframe_title = Text(".//h3") + + # Nested iframe class (iframe within iframe) + @View.nested + class nested_iframe(View): # noqa + FRAME = './/iframe[@name="another_iframe"]' + nested_title = Text(".//h3") + nested_select = Select(id="iframe_select3") + + # Deeply nested view within the nested iframe + @View.nested + class deep_nested(View): # noqa + ROOT = './/div[@id="nested_view"]' + nested_input = TextInput(name="input222") + + +nested_view = NestedIFrameView(browser) # noqa: F821 + +# Access each level of nesting +print(f"Level 1 iframe: {nested_view.iframe_title.read()}") +print(f"Level 2 iframe: {nested_view.nested_iframe.nested_title.read()}") +print(f"Nested select: {nested_view.nested_iframe.nested_select.read()}") + +# Access deeply nested input +nested_input_value = nested_view.nested_iframe.deep_nested.nested_input.read() +print(f"Deep nested input: {nested_input_value}") + +# Fill deeply nested input +nested_view.nested_iframe.deep_nested.nested_input.fill("Updated Value") +updated_value = nested_view.nested_iframe.deep_nested.nested_input.read() +print(f"Updated nested input: {updated_value}") + +# Clean up: Return to main frame +browser.switch_to_main_frame() # noqa: F821 diff --git a/docs/examples/ouia/creating_ouia_views.py b/docs/examples/ouia/creating_ouia_views.py new file mode 100644 index 00000000..d09aa952 --- /dev/null +++ b/docs/examples/ouia/creating_ouia_views.py @@ -0,0 +1,31 @@ +"""Creating OUIA Views + +This example demonstrates creating OUIA views as containers for OUIA widgets. +""" + +from widgetastic.ouia import OUIAGenericView, OUIAGenericWidget + + +class Button(OUIAGenericWidget): + OUIA_COMPONENT_TYPE = "PF/Button" + + +class TestView(OUIAGenericView): + """OUIA view containing multiple OUIA widgets.""" + + OUIA_COMPONENT_TYPE = "TestView" + OUIA_ID = "ouia" # Optional: default component_id for the view + + button = Button(component_id="This is a button") + + +view = TestView(browser) # noqa: F821 + +# OUIA_COMPONENT_TYPE is used to generate ROOT for this view +print(f"ROOT locator for this view: {view.ROOT}") + +print(f"View is displayed: {view.is_displayed}") + +print("Clicking button inside OUIA view...") +view.button.click() +print("Button clicked successfully") diff --git a/docs/examples/ouia/creating_ouia_widgets.py b/docs/examples/ouia/creating_ouia_widgets.py new file mode 100644 index 00000000..7a20d2f2 --- /dev/null +++ b/docs/examples/ouia/creating_ouia_widgets.py @@ -0,0 +1,25 @@ +"""Creating OUIA Widgets + +This example demonstrates creating and using OUIA-compatible widgets. +""" + +from widgetastic.ouia import OUIAGenericWidget +from widgetastic.widget import View + + +class Button(OUIAGenericWidget): + """OUIA Button widget following PF (PatternFly) namespace.""" + + OUIA_COMPONENT_TYPE = "PF/Button" + + +class Details(View): + button = Button(component_id="This is a button") + + +view = Details(browser) # noqa: F821 + +print(f"Button is displayed: {view.button.is_displayed}") +print("Clicking button...") +view.button.click() +print("Button clicked successfully") diff --git a/docs/examples/ouia/ouia_complete_example.py b/docs/examples/ouia/ouia_complete_example.py new file mode 100644 index 00000000..80835d4f --- /dev/null +++ b/docs/examples/ouia/ouia_complete_example.py @@ -0,0 +1,41 @@ +"""Complete OUIA Example + +This example demonstrates a comprehensive OUIA setup with multiple widgets. +""" + +from widgetastic.ouia import OUIAGenericView, OUIAGenericWidget +from widgetastic.ouia.checkbox import Checkbox +from widgetastic.ouia.input import TextInput +from widgetastic.ouia.text import Text + + +# Define custom OUIA widget +class Button(OUIAGenericWidget): + OUIA_COMPONENT_TYPE = "PF/Button" + + +# Create comprehensive OUIA view +class TestView(OUIAGenericView): + OUIA_COMPONENT_TYPE = "TestView" + OUIA_ID = "ouia" + + button = Button(component_id="This is a button") + text = Text(component_id="unique_id", component_type="Text") + text_input = TextInput(component_id="unique_id", component_type="TextInput") + checkbox = Checkbox(component_id="unique_id", component_type="CheckBox") + + +# Use the view +view = TestView(browser) # noqa: F821 + +print("Testing OUIA widgets:") +view.button.click() +print("✓ Button clicked successfully") + +view.text_input.fill("Test") +print(f"✓ Text input value: {view.text_input.read()}") + +view.checkbox.fill(True) +print(f"✓ Checkbox checked: {view.checkbox.read()}") + +print(f"✓ Text widget value: {view.text.read()}") diff --git a/docs/examples/ouia/ouia_safety_attribute.py b/docs/examples/ouia/ouia_safety_attribute.py new file mode 100644 index 00000000..e300209a --- /dev/null +++ b/docs/examples/ouia/ouia_safety_attribute.py @@ -0,0 +1,25 @@ +"""OUIA Safety Attribute + +This example demonstrates using the data-ouia-safe attribute. +""" + +from widgetastic.ouia import OUIAGenericWidget + +# Create OUIA widgets +button = OUIAGenericWidget( + parent=browser, # noqa: F821 + component_id="This is a button", + component_type="PF/Button", +) + +select = OUIAGenericWidget(parent=browser, component_id="some_id", component_type="PF/Select") # noqa: F821 + +# Check if components are in a static state (no animations) +print(f"Button is safe: {button.is_safe}") +print(f"Select is safe: {select.is_safe}") + +# You can wait for a component to be safe before interacting +print("Waiting for button to be safe before clicking...") +if button.is_safe: + button.click() + print("Button clicked after verifying it's safe") diff --git a/docs/examples/table-widget/accessing_cells.py b/docs/examples/table-widget/accessing_cells.py new file mode 100644 index 00000000..a337e326 --- /dev/null +++ b/docs/examples/table-widget/accessing_cells.py @@ -0,0 +1,27 @@ +"""Accessing Cells in Different Ways + +This example demonstrates multiple methods to access table cells. +""" + +from widgetastic.widget import Table + +table = Table(parent=browser, locator="#with-thead") # noqa: F821 +row = table[0] # Get first row + +# Method 1: By index (0-based) +cell = row[0] +print(f"Cell by index [0]: {cell.text}") + +# Method 2: By column name (exact match) +cell = row["Column 1"] +print(f"Cell by column name 'Column 1': {cell.text}") + +# Method 3: By attributized name (Django-style) +# Column names are automatically converted: "Column 1" -> "column_1" +cell = row.column_1 +print(f"Cell by attributized name 'column_1': {cell.text}") + +# You can also click on cells +print("Clicking on column_1 cell...") +row.column_1.click() +print("Cell clicked successfully") diff --git a/docs/examples/table-widget/associative_column_filling.py b/docs/examples/table-widget/associative_column_filling.py new file mode 100644 index 00000000..eb7679b9 --- /dev/null +++ b/docs/examples/table-widget/associative_column_filling.py @@ -0,0 +1,34 @@ +"""Associative Column Filling + +This example demonstrates filling rows using an associative column as a key. +""" + +from widgetastic.widget import Table, TextInput + +# The edge_case_test_table has a "Status" column with unique values +# Use "Status" column as the associative column +status_table = Table( + parent=browser, # noqa: F821 + locator="#edge_case_test_table", + column_widgets={ + "Input": TextInput(locator="./input"), + }, + assoc_column="Status", +) + +# Read the table - returns a dictionary keyed by Status value +data = status_table.read() +print("Table data (keyed by Status):") +for status, row_data in data.items(): + print(f" {status}: {row_data}") + +# Fill rows by their Status value +print("Filling rows by Status:") +status_table.fill( + {"Active": {"Input": "new_active_value"}, "Inactive": {"Input": "new_inactive_value"}} +) + +# Read back to verify +updated_data = status_table.read() +print(f"After fill - Active row Input: {updated_data['Active']['Input']}") +print(f"After fill - Inactive row Input: {updated_data['Inactive']['Input']}") diff --git a/docs/examples/table-widget/basic_table_reading.py b/docs/examples/table-widget/basic_table_reading.py new file mode 100644 index 00000000..c9b85857 --- /dev/null +++ b/docs/examples/table-widget/basic_table_reading.py @@ -0,0 +1,31 @@ +"""Basic Table - Reading Data + +This example demonstrates reading data from a standard table with headers. +""" + +from widgetastic.widget import Table + +# Initialize the table widget +table = Table(parent=browser, locator="#with-thead") # noqa: F821 + +# Get table information +print(f"Headers: {table.headers}") +print(f"Number of rows: {len(list(table))}") + +# Read all rows +print("All rows:") +for row in table: + print([cell.text for header, cell in row]) + +# Access a specific row (first row is index 0) +first_row = table[0] +print(f"First row data: {first_row.read()}") + +# Access a specific cell (row 0, column 0) +cell = table[0][0] +print(f"First cell text: {cell.text}") + +# Read the entire table as a list of dictionaries +all_data = table.read() +print(f"Table has {len(all_data)} rows") +print(f"First row from read(): {all_data[0]}") diff --git a/docs/examples/table-widget/complex_table_merged_cells.py b/docs/examples/table-widget/complex_table_merged_cells.py new file mode 100644 index 00000000..6e740af6 --- /dev/null +++ b/docs/examples/table-widget/complex_table_merged_cells.py @@ -0,0 +1,29 @@ +"""Complex Table with Merged Cells + +This example demonstrates handling tables with rowspan/colspan cells. +""" + +from widgetastic.widget import Table, TextInput + +# Create the table with column_widgets +complex_table = Table( + parent=browser, # noqa: F821 + locator="#rowcolspan_table", + column_widgets={ + "Widget": TextInput(locator="./input"), + }, +) + +# Get headers +print(f"Headers: {complex_table.headers}") + +# Read the table - merged cells are handled automatically +data = complex_table.read() +print(f"Table has {len(data)} rows") +print(f"First row: {data[0]}") + +# Access and fill a widget in a merged cell +row = complex_table[7] # Access row 8 +widget_cell = row["Widget"] +widget_cell.widget.fill("test value") +print(f"Widget value after fill: {widget_cell.widget.read()}") diff --git a/docs/examples/table-widget/finding_rows.py b/docs/examples/table-widget/finding_rows.py new file mode 100644 index 00000000..cd204891 --- /dev/null +++ b/docs/examples/table-widget/finding_rows.py @@ -0,0 +1,39 @@ +"""Finding Rows by Content + +This example demonstrates different methods to search for specific rows. +""" + +from widgetastic.widget import Table + +table = Table(parent=browser, locator="#with-thead") # noqa: F821 + +# Method 1: Keyword-based filtering (Django-style) +print("Finding rows with keyword filters:") +rows = list(table.rows(column_1="qwer")) +print(f"Rows where Column 1 equals 'qwer': {len(rows)}") + +rows_containing = list(table.rows(column_2__contains="foo")) +print(f"Rows with 'foo' in Column 2: {len(rows_containing)}") + +rows_starting = list(table.rows(column_2__startswith="bar")) +print(f"Rows starting with 'bar' in Column 2: {len(rows_starting)}") + +rows_ending = list(table.rows(column_2__endswith="_y")) +print(f"Rows ending with '_y' in Column 2: {len(rows_ending)}") + +# Find a single row (returns first match or raises RowNotFound) +row = table.row(column_1="qwer") +print(f"Single row where column_1='qwer': {row.read()}") + +# Method 2: Tuple-based filtering +print("Tuple-based filtering:") +rows = list(table.rows((0, "asdf"))) +print(f"Rows where column 0 equals 'asdf': {len(rows)}") + +rows = list(table.rows((1, "contains", "bar"))) +print(f"Rows where column 1 contains 'bar': {len(rows)}") + +# Method 3: Row attribute filtering +print("Row attribute filtering:") +rows = list(table.rows(_row__attr=("data-test", "abc-123"))) +print(f"Rows with data-test='abc-123': {len(rows)}") diff --git a/docs/examples/table-widget/table_with_widgets.py b/docs/examples/table-widget/table_with_widgets.py new file mode 100644 index 00000000..5a0e9660 --- /dev/null +++ b/docs/examples/table-widget/table_with_widgets.py @@ -0,0 +1,37 @@ +"""Table with Embedded Widgets + +This example demonstrates working with tables that have input fields in cells. +""" + +from widgetastic.widget import Table, TextInput + +# Create the table with column_widgets +# Tell the table which columns have widgets +widget_table = Table( + parent=browser, # noqa: F821 + locator="#withwidgets", + column_widgets={ + "Column 2": TextInput(locator="./input"), + "Column 3": TextInput(locator="./input"), + }, +) + +# Read the table (widgets are automatically read when present) +data = widget_table.read() +print(f"Table data: {data[0]}") + +# Access and fill a widget in a specific cell +first_row = widget_table[0] +column2_cell = first_row["Column 2"] + +# Fill the input +column2_cell.widget.fill("new value") +print(f"Value after filling: {column2_cell.widget.read()}") + +# The read() method handles this automatically +print(f"Cell value using read(): {column2_cell.read()}") + +# Fill multiple rows at once +print("Filling multiple rows:") +widget_table.fill([{"Column 2": "value1"}, {"Column 3": "value3"}]) +print(f"After batch fill: {widget_table.read()}") diff --git a/docs/examples/version-picking/version_picking.py b/docs/examples/version-picking/version_picking.py new file mode 100644 index 00000000..713371a9 --- /dev/null +++ b/docs/examples/version-picking/version_picking.py @@ -0,0 +1,84 @@ +# Example: Setting Up Version Picking Environment +"""Basic Version Picking + +This example demonstrates version-dependent widget definitions. +""" + +import inspect +import os +from pathlib import Path +from playwright.sync_api import sync_playwright +from widgetastic.browser import Browser +from widgetastic.utils import VersionPick, Version +from widgetastic.widget import View, Text, TextInput + + +# Browser setup (from previous example) +class BrowserV1(Browser): + @property + def product_version(self): + return Version("1.0.0") + + +class BrowserV2(Browser): + @property + def product_version(self): + return Version("2.1.0") + + +def get_pw_and_browser(version: str = "v1"): + # Get headless mode from environment (set by conftest or CI) + headless = os.getenv("PLAYWRIGHT_HEADLESS", "false").lower() == "true" + + p = sync_playwright().start() + browser_instance = p.chromium.launch(headless=headless) + context = browser_instance.new_context() + page = context.new_page() + + base_path = Path(inspect.getfile(Browser)).parent.parent.parent + test_page_path = base_path / "testing" / "html" / "testing_page.html" + test_page_url = test_page_path.as_uri() + page.goto(test_page_url) + + if version == "v1": + return p, BrowserV1(page) + else: + return p, BrowserV2(page) + + +# End of Example: Setting Up Version Picking Environment + + +# Example: Basic Version Picking +# Create versioned view +class VersionedView(View): + input_field = VersionPick( + { + Version.lowest(): TextInput(name="fill_with_1"), # Default/fallback (v1.x) + "2.0.0": TextInput(name="fill_with_2"), # Version 2.0.0+ + } + ) + click_button = VersionPick( + { + Version.lowest(): Text("#fill_with_button_1"), # Default/fallback (v1.x) + "2.0.0": Text("#fill_with_button_2"), # Version 2.0.0+ + } + ) + + +# Test with version 1.0.0 browser +pw, browser_v1 = get_pw_and_browser("v1") +view = VersionedView(browser_v1) +print(f"Browser version (v1): {browser_v1.product_version}") +print(f"Input locator (v1): {view.input_field.locator}") +print(f"Button locator (v1): {view.click_button.locator}") +pw.stop() + +# Test with version 2.1.0 browser +pw, browser_v2 = get_pw_and_browser("v2") +view = VersionedView(browser_v2) +print(f"Browser version (v2): {browser_v2.product_version}") +print(f"Input locator (v2): {view.input_field.locator}") +print(f"Button locator (v2): {view.click_button.locator}") +pw.stop() +# End of Example: Basic Version Picking diff --git a/docs/examples/views/basic_view.py b/docs/examples/views/basic_view.py new file mode 100644 index 00000000..9f240132 --- /dev/null +++ b/docs/examples/views/basic_view.py @@ -0,0 +1,26 @@ +"""Basic View Example + +This example demonstrates creating and using a basic View. +""" + +from widgetastic.widget import View, Text + + +class TestingPageView(View): + # Read the main page title + main_title = Text(locator=".//h1[@id='wt-core-title']") + # Read the sub title + sub_title = Text(locator='.//p[@class="subtitle"]') + # Define non existing element + non_existing_element = Text(locator='.//div[@id="non-existing-element"]') + + +page = TestingPageView(browser) # noqa: F821 + +# Check if element exist on page or not +print(f"Main title is displayed: {page.main_title.is_displayed}") +print(f"Non-existing element is displayed: {page.non_existing_element.is_displayed}") + +# Reading text content +print(f"Page title: {page.main_title.read()}") +print(f"Sub title: {page.sub_title.read()}") diff --git a/docs/examples/views/batch_operations.py b/docs/examples/views/batch_operations.py new file mode 100644 index 00000000..4caa165f --- /dev/null +++ b/docs/examples/views/batch_operations.py @@ -0,0 +1,30 @@ +"""View Batch Operations Example + +This example demonstrates batch fill and read operations on views. +""" + +from widgetastic.widget import View, TextInput, Checkbox + + +class NormalView(View): + ROOT = "#normal_view_container" + + name = TextInput(id="normal_name") + email = TextInput(id="normal_email") + terms = Checkbox(id="normal_terms") + + +# Fill multiple fillable widgets at once +form = NormalView(browser) # noqa: F821 +form_data = { + "name": "Foo Bar", + "email": "foo.bar@example.com", + "terms": True, +} + +print(f"Filling form with: {form_data}") +form.fill(form_data) + +# Read all fillable widgets in the view +current_values = form.read() +print(f"Current form values: {current_values}") diff --git a/docs/examples/views/conditional_switchable_view.py b/docs/examples/views/conditional_switchable_view.py new file mode 100644 index 00000000..65568627 --- /dev/null +++ b/docs/examples/views/conditional_switchable_view.py @@ -0,0 +1,69 @@ +"""ConditionalSwitchableView Example + +This example demonstrates using ConditionalSwitchableView to handle dynamic UI sections. +""" + +from widgetastic.widget import ConditionalSwitchableView, View, TextInput, Select, Checkbox + + +class ConditionalSwitchableViewTesting(View): + ROOT = "#conditional_form_container" + + foo = TextInput(name="foo_value") # For multi-widget reference + action_type = Select(name="action_type") + + action_form = ConditionalSwitchableView(reference="action_type") + + # Simple value matching. If Action type 1 is selected in the select, use this view. + # And if the action_type value does not get matched, use this view as default + @action_form.register("Action type 1", default=True) + class ActionType1Form(View): + ROOT = "#action_form_1" + widget = TextInput(name="action1_widget") + options = Select(name="action1_options") + enabled = Checkbox(name="action1_enabled") + + # You can use a callable to declare the widget values to compare + @action_form.register(lambda action_type: action_type == "Action type 2") + class ActionType2Form(View): + ROOT = "#action_form_2" + widget = TextInput(name="action2_widget") + priority = Select(name="action2_priority") + notes = TextInput(name="action2_notes") + + # With callable, you can use values from multiple widgets + @action_form.register( + lambda action_type, foo: action_type == "Action type 3" and foo == "special" + ) + class ActionType3Form(View): + ROOT = "#action_form_3" + widget = TextInput(name="action3_widget") + config = TextInput(name="action3_config") + mode = Select(name="action3_mode") + + +view = ConditionalSwitchableViewTesting(browser) # noqa: F821 + +# Switch content by changing selector +print("Filling Action type 1 form:") +view.action_type.fill("Action type 1") +view.action_form.widget.fill("Test input for type 1") +view.action_form.options.fill("Option 1") +view.action_form.enabled.fill(True) +print(f"Action form values: {view.action_form.read()}") + +# Switch to action type 2 content +print("Filling Action type 2 form:") +view.action_type.fill("Action type 2") +view.action_form.widget.fill("Test input for type 2") +view.action_form.priority.fill("High") +view.action_form.notes.fill("Important notes") +print(f"Action form values: {view.action_form.read()}") + +# Switch to action type 3 with multi-widget condition +print("Filling Action type 3 form (requires foo='special'):") +view.foo.fill("special") # Required for condition +view.action_type.fill("Action type 3") +view.action_form.widget.fill("Test input for type 3") +view.action_form.config.fill("advanced config") +print(f"Action form values: {view.action_form.read()}") diff --git a/docs/examples/views/nested_parametrized_view.py b/docs/examples/views/nested_parametrized_view.py new file mode 100644 index 00000000..ae6907bb --- /dev/null +++ b/docs/examples/views/nested_parametrized_view.py @@ -0,0 +1,53 @@ +"""Nested Parametrized View Example + +This example demonstrates nesting a parametrized view inside another view. +""" + +from widgetastic.utils import ParametrizedLocator, ParametrizedString +from widgetastic.widget import ParametrizedView, TextInput, Checkbox, View, Text + + +class ParametrizedViewTesting(View): + """Parametrized View under View testing.""" + + ROOT = ".//div[contains(@class, 'parametrized-view')]" + title = Text(locator=".//div[@class='widget-title']") + + class thing_container_view(ParametrizedView): # noqa + # Defining one parameter + PARAMETERS = ("thing_id",) + # ParametrizedLocator coerces to a string upon access + ROOT = ParametrizedLocator(".//div[@id={thing_id|quote}]") + + # Widget definition processed with parameters + the_widget = TextInput(name=ParametrizedString("asdf_{thing_id}")) + description = TextInput(name=ParametrizedString("desc_{thing_id}")) + active = Checkbox(name=ParametrizedString("active_{thing_id}")) + + +# We create the root view +view = ParametrizedViewTesting(browser) # noqa: F821 + +# Now if it was an ordinary nested view, view.thing_container_view.the_widget would give us the +# nested view instance directly and then the the_widget widget. But this is a parametrized view +# and it will give us an intermediate object whose task is to collect the parameters upon +# calling and then pass them through into the real view object. + +# This example will be invoking the parametrized view with the exactly same param like the +# previous example: +print("Accessing parametrized container 'foo'") +foo_container = view.thing_container_view("foo") + +# So, when we have that view, you can use it as you are used to +view.thing_container_view("foo").the_widget.fill("Test for foo") +print(f"Filled foo container: {view.thing_container_view('foo').the_widget.read()}") + +view.thing_container_view("bar").the_widget.fill("Test for bar") +print(f"Filled bar container: {view.thing_container_view('bar').the_widget.read()}") + +view.thing_container_view("baz").the_widget.fill("Test for baz") +print(f"Filled baz container: {view.thing_container_view('baz').the_widget.read()}") + +# Or with keyword params +view.thing_container_view(thing_id="foo").the_widget.fill("Test for foo with keyword") +print(f"Using keyword param: {view.thing_container_view(thing_id='foo').the_widget.read()}") diff --git a/docs/examples/views/nested_view_attribute_assignment.py b/docs/examples/views/nested_view_attribute_assignment.py new file mode 100644 index 00000000..9d3e8bbb --- /dev/null +++ b/docs/examples/views/nested_view_attribute_assignment.py @@ -0,0 +1,48 @@ +"""Nested Views - Attribute Assignment Approach + +This example demonstrates creating nested views using View.nested(). +""" + +from widgetastic.widget import View, Text, TextInput, Checkbox + + +class NormalViewTesting(View): + """Normal View under View testing.""" + + ROOT = ".//div[contains(@class, 'normal-view')]" + title = Text(locator=".//div[@class='widget-title']") + name = TextInput(id="normal_name") + email = TextInput(id="normal_email") + terms = Checkbox(id="normal_terms") + submit = Text(locator=".//button[@id='normal_submit']") + + +class ParametrizedViewTesting(View): + """Parametrized View under View testing.""" + + ROOT = ".//div[contains(@class, 'parametrized-view')]" + title = Text(locator=".//div[@class='widget-title']") + # Some other widgets + + +class ConditionalSwitchableViewTesting(View): + """Conditional Switchable View under View testing.""" + + ROOT = ".//div[contains(@class, 'conditional-switchable-view')]" + title = Text(locator=".//div[@class='widget-title']") + # Some other widgets + + +class ViewTesting(View): + normal_view = View.nested(NormalViewTesting) + parametrized_view = View.nested(ParametrizedViewTesting) + conditional_switchable_view = View.nested(ConditionalSwitchableViewTesting) + + +# Access nested elements +view = ViewTesting(browser) # noqa: F821 + +print(f"Normal view is displayed: {view.normal_view.is_displayed}") +print(f"Normal view title: {view.normal_view.title.read()}") +print(f"Parametrized view title: {view.parametrized_view.title.read()}") +print(f"Conditional switchable view: {view.conditional_switchable_view.read()}") diff --git a/docs/examples/views/nested_view_inner_classes.py b/docs/examples/views/nested_view_inner_classes.py new file mode 100644 index 00000000..90e3337d --- /dev/null +++ b/docs/examples/views/nested_view_inner_classes.py @@ -0,0 +1,44 @@ +"""Nested Views - Inner Classes Approach + +This example demonstrates creating nested views using @View.nested decorator. +""" + +from widgetastic.widget import View, Text, TextInput, Checkbox + + +class ViewTesting(View): + @View.nested + class normal_view(View): # noqa + """Normal View under View testing.""" + + ROOT = ".//div[contains(@class, 'normal-view')]" + title = Text(locator=".//div[@class='widget-title']") + name = TextInput(id="normal_name") + email = TextInput(id="normal_email") + terms = Checkbox(id="normal_terms") + submit = Text(locator=".//button[@id='normal_submit']") + + @View.nested + class parametrized_view(View): # noqa + """Parametrized View under View testing.""" + + ROOT = ".//div[contains(@class, 'parametrized-view')]" + title = Text(locator=".//div[@class='widget-title']") + # Some other widgets + + @View.nested + class conditional_switchable_view(View): # noqa + """Conditional Switchable View under View testing.""" + + ROOT = ".//div[contains(@class, 'conditional-switchable-view')]" + title = Text(locator=".//div[@class='widget-title']") + # Some other widgets + + +# Access nested elements +view = ViewTesting(browser) # noqa: F821 + +print(f"Normal view is displayed: {view.normal_view.is_displayed}") +print(f"Normal view title: {view.normal_view.title.read()}") +print(f"Parametrized view title: {view.parametrized_view.title.read()}") +print(f"Conditional switchable view: {view.conditional_switchable_view.read()}") diff --git a/docs/examples/views/parametrized_view.py b/docs/examples/views/parametrized_view.py new file mode 100644 index 00000000..68f67d31 --- /dev/null +++ b/docs/examples/views/parametrized_view.py @@ -0,0 +1,37 @@ +"""ParametrizedView Example + +This example demonstrates using ParametrizedView to handle repeated UI patterns. +""" + +from widgetastic.utils import ParametrizedLocator, ParametrizedString +from widgetastic.widget import ParametrizedView, TextInput, Checkbox + + +class ThingContainerView(ParametrizedView): + # Defining one parameter + PARAMETERS = ("thing_id",) + # ParametrizedLocator coerces to a string upon access + # It follows similar formatting syntax as .format + # You can use the xpath quote filter as shown + ROOT = ParametrizedLocator(".//div[@id={thing_id|quote}]") + + # Widget definition *args and values of **kwargs (only the first level) are processed as well + the_widget = TextInput(name=ParametrizedString("asdf_{thing_id}")) + description = TextInput(name=ParametrizedString("desc_{thing_id}")) + active = Checkbox(name=ParametrizedString("active_{thing_id}")) + + +# Then for invoking this. create a view for foo. +view = ThingContainerView(browser, additional_context={"thing_id": "foo"}) # noqa: F821 + +# Fill the foo container +print("Filling container 'foo':") +view.the_widget.fill("Test input for foo") +view.description.fill("Description for foo") +view.active.fill(True) +print(f"Foo container values: {view.read()}") + +# Create parametrized view for bar +bar_view = ThingContainerView(browser, additional_context={"thing_id": "bar"}) # noqa: F821 +bar_view.the_widget.fill("Test input for bar") +print(f"Bar container widget value: {bar_view.the_widget.read()}") diff --git a/docs/examples/views/parametrized_view_iteration.py b/docs/examples/views/parametrized_view_iteration.py new file mode 100644 index 00000000..ae55b2ff --- /dev/null +++ b/docs/examples/views/parametrized_view_iteration.py @@ -0,0 +1,41 @@ +"""Parametrized View Iteration Example + +This example demonstrates iterating through all occurrences of a parametrized view. +""" + +from widgetastic.utils import ParametrizedLocator, ParametrizedString +from widgetastic.widget import ParametrizedView, TextInput, Checkbox, View, Text + + +class ParametrizedViewTesting(View): + """Parametrized View under View testing.""" + + ROOT = ".//div[contains(@class, 'parametrized-view')]" + title = Text(locator=".//div[@class='widget-title']") + + class thing_container_view(ParametrizedView): # noqa + # Defining one parameter + PARAMETERS = ("thing_id",) + # ParametrizedLocator coerces to a string upon access + ROOT = ParametrizedLocator(".//div[@id={thing_id|quote}]") + + # Widget definition processed with parameters + the_widget = TextInput(name=ParametrizedString("asdf_{thing_id}")) + description = TextInput(name=ParametrizedString("desc_{thing_id}")) + active = Checkbox(name=ParametrizedString("active_{thing_id}")) + + @classmethod + def all(cls, browser): + # Get all the thing_id values from the page + elements = browser.elements(".//div[@class='thing']") + # Return a list of tuples, each containing the thing_id value + return [(browser.get_attribute("id", el),) for el in elements] + + +# We create the root view +view = ParametrizedViewTesting(browser) # noqa: F821 + +print("Iterating through all thing containers:") +for container_view in view.thing_container_view: + container_view.the_widget.fill("do something with the widget") + print(f"Container values: {container_view.read()}") diff --git a/docs/examples/views/root_locator_scoping.py b/docs/examples/views/root_locator_scoping.py new file mode 100644 index 00000000..82fd659a --- /dev/null +++ b/docs/examples/views/root_locator_scoping.py @@ -0,0 +1,22 @@ +"""ROOT Locator Scoping Example + +This example demonstrates how ROOT locators scope widget searches. +""" + +from widgetastic.widget import View, Text, TextInput + + +class NormalViewTesting(View): + ROOT = ".//div[contains(@class, 'normal-view')]" # All widgets scoped to this section + + # These widgets are found within `ROOT`. + title = Text(locator=".//div[@class='widget-title']") + name = TextInput(id="normal_name") + + +# Without ROOT, widgets would search the entire page +# With ROOT, widgets only search within .//div[contains(@class, 'normal-view')]. + +view = NormalViewTesting(browser) # noqa: F821 +print(f"View title: {view.title.read()}") +print(f"Name input is displayed: {view.name.is_displayed}") diff --git a/docs/examples/views/simple_conditional_widget.py b/docs/examples/views/simple_conditional_widget.py new file mode 100644 index 00000000..a1713c8c --- /dev/null +++ b/docs/examples/views/simple_conditional_widget.py @@ -0,0 +1,32 @@ +"""Simple Conditional Widget Registration Example + +This example demonstrates registering a simple widget directly with ConditionalSwitchableView. +""" + +from widgetastic.widget import ConditionalSwitchableView, View, TextInput, Select + + +class SimpleConditionalWidgetView(View): + bar = Select( + name="bar" + ) # Reference widget; depends on the value of this widget we will decide widget to use. + + conditional_widget = ConditionalSwitchableView(reference="bar") + + # Register simple widget directly without creating a class + conditional_widget.register( + "Action type 1", default=True, widget=TextInput(name="simple_widget") + ) + + +view = SimpleConditionalWidgetView(browser) # noqa: F821 + +# When bar is set to 'Action type 1', conditional_widget becomes available. +view.bar.fill("Action type 1") +print(f"Conditional widget is displayed (Action type 1): {view.conditional_widget.is_displayed}") +view.conditional_widget.fill("Direct widget input") +print("Filled conditional widget with: 'Direct widget input'") + +# When bar is set to 'Other', conditional_widget becomes unavailable. +view.bar.fill("Other") +print(f"Conditional widget is displayed (Other): {view.conditional_widget.is_displayed}") diff --git a/docs/examples/views/view_lifecycle_hooks.py b/docs/examples/views/view_lifecycle_hooks.py new file mode 100644 index 00000000..e026e7d6 --- /dev/null +++ b/docs/examples/views/view_lifecycle_hooks.py @@ -0,0 +1,41 @@ +"""View Lifecycle Hooks Example + +This example demonstrates using before_fill and after_fill hooks. +""" + +from widgetastic.widget import View, TextInput, Checkbox + + +class FormView(View): + ROOT = "#normal_view_container" + name = TextInput(id="normal_name") + email = TextInput(id="normal_email") + terms = Checkbox(id="normal_terms") + + def before_fill(self, values): + """Called right before filling starts.""" + # self.logger.info(f"About to fill form with: {values}") + print(f"About to fill form with: {values}") + + # You can validate values, prepare the form, etc. + # Return value is ignored + + def after_fill(self, was_change): + """Called right after filling completes.""" + if was_change: + # self.logger.info("Form was successfully filled with new values") + print("Form was successfully filled with new values") + # Could wait for form updates, verify changes, etc. + else: + # self.logger.debug("No changes were made to the form") + print("No changes were made to the form") + # Return value is ignored + + +form = FormView(browser) # noqa: F821 +form.fill({"name": "John", "email": "john@example.com", "terms": True}) +# before_fill is called first, then widgets are filled, then after_fill is called + +# Read all fillable widgets in the view +current_values = form.read() +print(current_values) diff --git a/docs/examples/views/view_state_checking.py b/docs/examples/views/view_state_checking.py new file mode 100644 index 00000000..42e7bdac --- /dev/null +++ b/docs/examples/views/view_state_checking.py @@ -0,0 +1,40 @@ +"""View State Checking Examples + +This example demonstrates how ROOT locators affect is_displayed behavior. +""" + +from widgetastic.widget import View, TextInput + + +# Example 1: Without ROOT locator +class NormalView(View): + # Without root locator, it will be considered as displayed every time. + name = TextInput(id="normal_name") + + +view = NormalView(browser) # noqa: F821 +print(f"View without ROOT - name is displayed: {view.name.is_displayed}") + + +# Example 2: With ROOT locator +class NormalViewWithRoot(View): + ROOT = "#normal_view_container" + name = TextInput(id="normal_name") + + +view_with_root = NormalViewWithRoot(browser) # noqa: F821 +print(f"View with ROOT - name is displayed: {view_with_root.name.is_displayed}") + + +# Example 3: Custom is_displayed property +class NormalViewCustom(View): + name = TextInput(id="normal_name") + + @property + def is_displayed(self): + # We can take support of other widgets to check if the view is displayed + return self.name.is_displayed + + +view_custom = NormalViewCustom(browser) # noqa: F821 +print(f"View with custom is_displayed: {view_custom.is_displayed}") diff --git a/docs/examples/window-management/basic_windows_management.py b/docs/examples/window-management/basic_windows_management.py new file mode 100644 index 00000000..00bcd566 --- /dev/null +++ b/docs/examples/window-management/basic_windows_management.py @@ -0,0 +1,74 @@ +# Setup: Basic Windows Management +"""Basic Windows Management Example +This example demonstrates creating and managing multiple browser windows. +""" + +import inspect +import os +from pathlib import Path +from playwright.sync_api import sync_playwright +from widgetastic.browser import Browser, WindowManager + + +def setup_window_manager(): + """Setup WindowManager and base path.""" + # Get headless mode from environment (set by conftest or CI) + headless = os.getenv("PLAYWRIGHT_HEADLESS", "false").lower() == "true" + + base_path = Path(inspect.getfile(Browser)).parent.parent.parent + p = sync_playwright().start() + browser_instance = p.chromium.launch(headless=headless) + context = browser_instance.new_context() + page = context.new_page() + + return WindowManager(context, page), p, base_path + + +window_manager, pw, base_path = setup_window_manager() +# End of Setup + +# Example: Creating New Windows +# Example: Creating New Windows + +# Get external test page URL +external_page_path = base_path / "testing" / "html" / "external_test_page.html" +external_url = external_page_path.as_uri() + +# Current browser +initial_browser = window_manager.current +print(f"Initial browser URL: {initial_browser.url}") +print(f"Total browsers: {len(window_manager.all_browsers)}") + +# Create new window/browser with focus (becomes current) +new_browser = window_manager.new_browser(external_url, focus=True) +print(f"\nNew browser URL: {new_browser.url}") +print(f"Current browser changed: {window_manager.current is new_browser}") +print(f"Total browsers: {len(window_manager.all_browsers)}") + +# Create background window/browser (doesn't change focus) +bg_browser = window_manager.new_browser(external_url, focus=False) +print(f"\nCurrent browser unchanged: {window_manager.current is new_browser}") +print(f"Total browsers: {len(window_manager.all_browsers)}") + + +# End of Example: Creating New Windows + +# Example: Switching Between Windows +# Example: Switching Between Windows +print(f"Initial browsers created: {len(window_manager.all_browsers)}") + +# Switch to different browser by instance +window_manager.switch_to(bg_browser) +print(f"Switched to background browser: {window_manager.current is bg_browser}") + +# Switch back to original browser +window_manager.switch_to(initial_browser) +print(f"Switched back to initial browser: {window_manager.current is initial_browser}") + +# Switch by page instance +window_manager.switch_to(new_browser.page) +print(f"Switched using page reference: {window_manager.current.page is new_browser.page}") + +# Close playwright instance +pw.stop() +# End of Example: Switching Between Windows diff --git a/docs/examples/window-management/handling_popups.py b/docs/examples/window-management/handling_popups.py new file mode 100644 index 00000000..a3c2e206 --- /dev/null +++ b/docs/examples/window-management/handling_popups.py @@ -0,0 +1,98 @@ +# Example: Handling Popups and New Tabs +"""Handling Popups and New Tabs + +This example demonstrates handling JavaScript popups using expect_new_page(). +""" + +import inspect +import os +from pathlib import Path +from playwright.sync_api import sync_playwright +from widgetastic.browser import Browser, WindowManager +from widgetastic.widget import View, Text + + +def setup_window_manager(): + """Setup WindowManager with popup test page.""" + # Get headless mode from environment (set by conftest or CI) + headless = os.getenv("PLAYWRIGHT_HEADLESS", "false").lower() == "true" + + base_path = Path(inspect.getfile(Browser)).parent.parent.parent + + p = sync_playwright().start() + browser_instance = p.chromium.launch(headless=headless) + context = browser_instance.new_context() + page = context.new_page() + return WindowManager(context, page), p, base_path + + +window_manager, pw, base_path = setup_window_manager() + + +class PopupPageView(View): + """View for popup_test_page.html""" + + open_popup_button = Text("#open-popup") + open_tab_button = Text("#open-new-tab") + external_link = Text("#external-link") + + +# Navigate to popup test page +popup_page_path = base_path / "testing" / "html" / "popup_test_page.html" +initial_browser = window_manager.current +initial_browser.url = popup_page_path.as_uri() +popup_view = PopupPageView(initial_browser) + +print(f"Initial browser count: {len(window_manager.all_browsers)}") + +# Handle JavaScript popup window +with window_manager.expect_new_page(timeout=5.0) as popup_browser: + popup_view.open_popup_button.click() + +print(f"Popup browser URL: {popup_browser.url}") +print(f"Popup browser title: {popup_browser.title}") +print(f"Total browsers: {len(window_manager.all_browsers)}") + +# Handle new tab opened by JavaScript +with window_manager.expect_new_page(timeout=5.0) as new_tab_browser: + popup_view.open_tab_button.click() + +print(f"New tab URL: {new_tab_browser.url}") + +# Handle link with target="_blank" +with window_manager.expect_new_page(timeout=5.0) as external_browser: + popup_view.external_link.click() + +print(f"External browser URL: {external_browser.url}") + +# Clean up opened browsers +window_manager.close_browser(popup_browser) +window_manager.close_browser(new_tab_browser) +window_manager.close_browser(external_browser) + +print(f"After cleanup: {len(window_manager.all_browsers)} browsers") + +# End of Example: Handling Popups and New Tabs + +# Example: Working with all_browsers Property +# Example: Working with all_browsers Property. + +# Get all active browsers with automatic cleanup +all_browsers = window_manager.all_browsers +print(f"Currently managing {len(all_browsers)} windows") + +# open one more page external test page +external_page_path = base_path / "testing" / "html" / "external_test_page.html" +test_browser = window_manager.new_browser(external_page_path.as_uri(), focus=False) +print(f"Before close: {len(window_manager.all_browsers)} browsers") + +# Iterate through all browsers +for i, browser in enumerate(window_manager.all_browsers): + print(f"Window {i}: {browser.title} - {browser.url}") + +window_manager.close_browser(test_browser) +print(f"After close: {len(window_manager.all_browsers)} browsers") + +# Close playwright instance +pw.stop() +# End of Example: Working with all_browsers Property diff --git a/docs/getting-started/concepts.rst b/docs/getting-started/concepts.rst new file mode 100644 index 00000000..54ffa28b --- /dev/null +++ b/docs/getting-started/concepts.rst @@ -0,0 +1,376 @@ +============== +Core Concepts +============== + +Understanding the fundamental concepts of widgetastic.core is essential for effective UI automation. +This guide introduces the key ideas that make widgetastic powerful and flexible. + +The Widget Philosophy +===================== + +**What is a Widget?** + +In widgetastic, a *widget* represents any interactive or non-interactive element on a web page. +Unlike traditional automation approaches that work directly with raw elements, widgets provide +a higher-level, object-oriented abstraction. + +.. code-block:: python + + # Traditional approach (raw elements) + element = page.locator("#username") + element.fill("john_doe") + + # Widgetastic approach (widget abstraction) + username = TextInput("#username") + username.fill("john_doe") + +**Benefits of the Widget Approach** + +- **Reusability & DRY Principle**: Define a widget once and reuse it across your entire test suite. No need to rewrite locators and interaction logic multiple times. +- **Maintainability & Single Source of Truth**: When UI changes, update the widget definition in one place. All tests using that widget automatically benefit from the fix. +- **Readability**: Code reads like natural language +- **Consistency & Standardization**: All widgets follow the same interface patterns (``read()``, ``fill()``, ``click()``), reducing cognitive load and learning curve. +- **Robustness & Error Handling**: Widgets include built-in intelligence for element selection, waiting, and error handling. +- **Testability & Debugging**: Widget-based architecture makes tests easier to debug with clear hierarchical structure and meaningful logging. +- **Separation of Concerns**: Business logic stays in tests, UI interaction details stay in widgets, and page structure stays in views. +- **Introspection & Dynamic Behavior**: Widgets can inspect their state and adapt behavior based on current conditions. +- **Scalability for Large Applications**: Widget approach scales naturally as applications grow, supporting complex hierarchies and component reuse patterns. +- **Customization & Extension**: Easy to extend base widgets with application-specific behavior while maintaining the core interface. + +The View Paradigm +================== + +**What is a View?** + +A *view* is a container that groups related widgets together, typically representing a page, +dialog, or section of a web application. Views provide structure and context for widgets. + +.. code-block:: python + + class LoginView(View): + username = TextInput("#username") + password = TextInput("#password") + submit_button = Text("//button[@type='submit']") # You can use Button widget from patternfly library + error_message = Text(".error-message") + +**Understanding Views** + +A View is essentially a specialized widget that acts as a container for other widgets. While it's +technically a widget itself (inheriting widget capabilities), its primary purpose is to organize +and manage collections of child widgets that belong together logically. + +**Think of it as:** + +* **A Widget Container** - Groups related widgets into logical units +* **A Page Representation** - Models entire web pages or sections +* **A Context Provider** - Gives widgets context about where they exist +* **An Organization Tool** - Structures complex UIs into manageable components + +**View Hierarchy** + +Views can be nested to represent complex UI structures: + +.. code-block:: python + + class ApplicationView(View): + header = HeaderView() + sidebar = SidebarView() + + class content(View): + ROOT = "#main-content" + + class user_form(View): + ROOT = ".user-form" + name = TextInput("#name") + email = TextInput("#email") + +The Browser Wrapper +=================== + +**Enhanced Browser Functionality** + +Widgetastic's ``Browser`` class wraps Playwright's ``Page`` with additional intelligence: + +* **Smart Element Selection**: Chooses visible, interactable elements when multiple matches exist +* **Robust Text Handling**: Extracts text reliably regardless of CSS styling +* **Network Activity Monitoring**: Waits for page stability before interactions +* **Frame Context Management**: Seamless iframe handling + +.. code-block:: python + + # Create a widgetastic browser from a Playwright page + from widgetastic.browser import Browser + + wt_browser = Browser(playwright_page) + +**Automatic Parent Injection** + +Widgets automatically receive their parent context, enabling proper element scoping: + +.. code-block:: python + + class MyView(View): + ROOT = "#my-section" + username_input = TextInput("//input[@name='username']") # Automatically scoped to #my-section + +Locators and Smart Detection +============================= + +**SmartLocator System** + +Widgetastic's ``SmartLocator`` class provides intelligent locator resolution that automatically detects locator types and converts them for Playwright compatibility. +This eliminates the need to explicitly specify locator strategies in most cases. + +.. code-block:: python + + from widgetastic.locator import SmartLocator + + # String auto-detection - SmartLocator detects the strategy + loc1 = SmartLocator("#submit-btn") # CSS selector detected + loc2 = SmartLocator("//div[@id='modal']") # XPath detected + loc3 = SmartLocator("div.container") # CSS selector detected + + # Explicit formats for precise control + loc4 = SmartLocator(text="Click Me") # Keyword argument + loc5 = SmartLocator({"role": "button"}) # Dictionary format + loc6 = SmartLocator("xpath", "//button[1]") # Tuple format + +**Automatic Strategy Detection** + +SmartLocator uses pattern matching to detect locator strategies: + +* **CSS Detection**: ``#myid``, ``.myclass``, ``div#id.class`` +* **XPath Detection**: ``//div``, ``./span``, ``(//a)[1]``, ``/html/body`` +* **Fallback**: Complex selectors like ``div > span`` default to CSS + +**Supported Locator Strategies** + +* **CSS**: ``#id``, ``.class``, ``tag#id.class``, ``div > span`` +* **XPath**: ``//div``, ``./span``, ``(//a)[1]``, ``.//input`` +* **Text**: ``text="Click Me"`` - finds elements containing text +* **ID**: ``id="my-element"`` - finds by element ID +* **Role**: ``role="button"`` - finds by ARIA role +* **Data attributes**: ``data-testid="element"`` - test automation attributes +* **Other attributes**: ``placeholder``, ``title``, ``name`` + +**Widget Integration** + +Widgets automatically use SmartLocator for their locator arguments: + +.. note:: + **Widget Initialization Arguments** + + The arguments required to initialize a widget depend on its specific implementation. + Always check the widget's documentation to understand what it needs for initialization - + some widgets require ``id``, others need ``locator``, ``text``, or other specific parameters. + Each widget type has its own initialization signature. + + +The Read/Fill Interface +======================= + +**Consistent Data Operations** + +Every widget implements a standardized interface for data interaction: + +**Read Interface** + +.. code-block:: python + + # Read individual widget + username_value = username_widget.read() + + # Read entire view (returns dictionary) + form_data = login_view.read() + # Returns: {"username": "john_doe", "password": "secret", ...} + +**Fill Interface** + +.. code-block:: python + + # Fill individual widget + changed = username_widget.fill("new_value") # Returns True/False + + # Fill entire view + login_view.fill({ + "username": "john_doe", + "password": "secret123" + }) + +**Fill Contract** + +All widgets follow these rules: +* ``fill()`` returns ``True`` if the value changed, ``False`` otherwise +* ``widget.fill(widget.read())`` should always work (idempotent) +* ``read()`` returns values compatible with ``fill()`` + +Element Lifecycle and Caching +============================== + +**Lazy Element Resolution** + +Widgets don't store raw element references, preventing stale element issues: + +.. code-block:: python + + class MyView(View): + username = TextInput("#name") # This creates a widget descriptor + + view = MyView(browser) + # Element is only located when accessed: + view.username.fill("John Doe") # NOW the element is found and filled + +**Widget Caching** + +Widget instances are cached per view for performance: + +.. code-block:: python + + view = MyView(browser) + username1 = view.username # Creates and caches widget instance + username2 = view.username # Returns cached instance (same object) + assert username1 is username2 # True + +Version Picking +=============== + +**Handling UI Evolution** + +Applications change over time. Version picking allows widgets to adapt to different versions: + +.. code-block:: python + + from widgetastic.utils import VersionPick, Version + + class MyView(View): + submit_button = VersionPick({ + Version.lowest(): Text("//input[@value='Submit']"), # Old version + "2.0.0": Text("//button[contains(@class, 'submit')]"), # New version + }) + +**Automatic Resolution** + +The browser's ``product_version`` property determines which widget is used: + +.. code-block:: python + + class MyBrowser(Browser): + @property + def product_version(self): + return "2.1.0" # Widget for version 2.0.0+ will be selected + +Parametrized Views +================== + +**Dynamic View Creation** + +For repeated UI patterns that differ only in parameters: + +.. code-block:: python + + from widgetastic.utils import ParametrizedLocator + + class UserRow(ParametrizedView): + PARAMETERS = ("user_id",) + ROOT = ParametrizedLocator("//tr[@data-user-id='{user_id}']") + + name = Text(".//td[1]") + email = Text(".//td[2]") + actions = Button(".//button") + +**Usage** + +.. code-block:: python + + # Create parametrized instance + user_row = UserRow(browser, user_id="123") + + # Or use in nested view + class UsersView(View): + class user_row(ParametrizedView): + PARAMETERS = ("user_id",) + # ... widget definitions + + view = UsersView(browser) + john_row = view.user_row("john123") + +Conditional Views +================= + +**Context-Dependent UI** + +Some UI sections change based on user selections or application state: + +.. code-block:: python + + from widgetastic.widget import ConditionalSwitchableView + + class FormView(View): + user_type = Select("#user-type") + + user_details = ConditionalSwitchableView(reference="user_type") + + @user_details.register("admin") + class AdminDetails(View): + admin_key = TextInput("#admin-key") + permissions = Select("#permissions") + + @user_details.register("regular") + class RegularDetails(View): + department = Select("#department") + manager = TextInput("#manager") + +OUIA Support +============ + +**Accessibility-First Automation** + +OUIA (Open UI Automation) enables automation through standardized data attributes: + +.. code-block:: python + + from widgetastic.ouia import OUIAGenericWidget + + class Button(OUIAGenericWidget): + pass + + # Locates elements by data-ouia-component-type and data-ouia-component-id + save_button = Button(component_id="save-user") + +Error Handling and Logging +=========================== + +**Built-in Error Management** + +Widgetastic provides meaningful error messages and comprehensive logging: + +.. code-block:: python + + from widgetastic.exceptions import NoSuchElementException + + try: + widget.click() + except NoSuchElementException as e: + logger.error(f"Element not found: {e}") + +**Hierarchical Logging** + +Every widget gets a logger that shows its position in the widget hierarchy: + +.. code-block:: text + + [MyView/user_form/username]: Filled 'john_doe' to value 'admin' with result True + [MyView/user_form/submit_button]: Click started + [MyView/user_form/submit_button]: Click (elapsed 234 ms) + +Key Takeaways +============= + +1. **Widgets** represent UI elements with consistent read/fill interfaces +2. **Views** group related widgets and provide structure +3. **SmartLocators** automatically handle different locator types +4. **Lazy resolution** prevents stale element issues +5. **Version picking** adapts to application changes +6. **Parametrized views** handle repeated UI patterns +7. **Conditional views** adapt to dynamic UI sections +8. **OUIA support** enables accessibility-driven automation diff --git a/docs/getting-started/first-steps.rst b/docs/getting-started/first-steps.rst new file mode 100644 index 00000000..b1494df3 --- /dev/null +++ b/docs/getting-started/first-steps.rst @@ -0,0 +1,152 @@ +=========== +First Steps +=========== + +Ready to write your first widgetastic automation script? This guide walks you through creating a complete, +working example that demonstrates the core concepts in action. + +Your First Script +================= + +Let's create a script that automates a simple web form. We'll use a public testing website to ensure +the example works reliably. + +**Complete Example** + +.. literalinclude:: ../examples/getting-started/first_script.py + :language: python + :linenos: + +.. note:: + + Core widgetastic provides minimal widgets (Text, TextInput, Checkbox, etc.). For specialized widgets like Buttons, Modals, Charts, use extensions like `widgetastic-patternfly5 `_. + +**Running the Script** + +Save the code as ``first_script.py`` and run it: + +.. code-block:: bash + + python first_script.py + +You should see the browser open, the form get filled out automatically, and output showing the progress. + +Breaking Down the Example +========================= + +Let's examine each part of the script in detail: + +**1. Import Statements** + +.. code-block:: python + + import json + from playwright.sync_api import sync_playwright + from widgetastic.browser import Browser + from widgetastic.widget import View, Text, TextInput, Checkbox + +* ``json`` - For parsing the response data after form submission +* ``sync_playwright`` - Playwright's synchronous API for browser automation +* ``Browser`` - Widgetastic's enhanced browser wrapper +* Widget classes - Individual UI component types (Text, TextInput, Checkbox) + +**2. View Definition with Nested Views** + +.. code-block:: python + + class DemoFormView(View): + # Basic form fields + custname = TextInput(locator='.//input[@name="custname"]') + telephone = TextInput(locator='.//input[@name="custtel"]') + email = TextInput(locator='.//input[@name="custemail"]') + + # Nested view for pizza size options + @View.nested + class pizza_size(View): + small = Checkbox(locator=".//input[@value='small']") + medium = Checkbox(locator=".//input[@value='medium']") + large = Checkbox(locator=".//input[@value='large']") + + # Nested view for pizza toppings + @View.nested + class pizza_toppings(View): + bacon = Checkbox(locator=".//input[@value='bacon']") + extra_cheese = Checkbox(locator=".//input[@value='cheese']") + # ... more toppings + +This creates a hierarchical view structure: +* **Basic fields** use XPath locators to find form inputs. +* **Nested views** group related elements (pizza size, toppings) using ``@View.nested`` +* **Checkbox widgets** handle radio buttons and checkboxes for selections + +**3. Browser Setup and Navigation** + +.. code-block:: python + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=False) + page = browser.new_page() + wt_browser = Browser(page) + wt_browser.url = "https://httpbin.org/forms/post" + +This creates the browser hierarchy and navigates to the test form: +* Playwright browser → Playwright page → Widgetastic browser +* Uses httpbin.org for reliable testing (no custom setup required) + +**4. Form Interaction and Data Handling** + +.. code-block:: python + + # Initialize the view + form_view = DemoFormView(wt_browser) + + # Fill individual fields + form_view.custname.fill("John Doe") + form_view.telephone.fill("1234567890") + form_view.email.fill("john.doe@example.com") + + # Select pizza options using nested views + form_view.pizza_size.small.fill(True) + form_view.pizza_toppings.bacon.fill(True) + + form_view.delivery_instructions.fill("Hello from Widgetastic!") + + # Submit and handle response + form_view.submit_order.click() + response_data = json.loads(form_view.response.text) + + +**We can fill the form in a single shot. Widgetastic will fill the form in the order of the widgets.** + +.. literalinclude:: ../examples/getting-started/batch_fill_example.py + :language: python + :linenos: + + + +Adding Logging +============== + +Enable logging to see what widgetastic is doing: + +.. code-block:: python + + import logging + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + # Create browser with logger + logger = logging.getLogger("my_automation") + wt_browser = MyBrowser(page, logger=logger) + +This will show detailed logs of widget operations: + +.. code-block:: text + + 2025-10-23 16:31:24,598 - my_automation - INFO - Opening URL: 'https://httpbin.org/forms/post' (wait_until=None) + 2025-10-23 16:31:39,549 - my_automation - INFO - [DemoFormView/custname]: fill('John Doe') -> True (elapsed 624 ms) + 2025-10-23 16:31:39,662 - my_automation - INFO - [DemoFormView/telephone]: fill('1234567890') -> True (elapsed 113 ms) diff --git a/docs/getting-started/installation.rst b/docs/getting-started/installation.rst new file mode 100644 index 00000000..bc2fba54 --- /dev/null +++ b/docs/getting-started/installation.rst @@ -0,0 +1,108 @@ +============ +Installation +============ + +Requirements +============ + +**Python Version** + Widgetastic.core requires Python 3.10 or higher. We recommend using the latest stable version of Python. + +**System Requirements** + * Windows, macOS, or Linux + * Internet connection for browser downloads (first-time setup) + +Installation Methods +==================== + +PyPI Installation (Recommended) +-------------------------------- + +The easiest way to install widgetastic.core is using pip: + +.. code-block:: bash + + pip install widgetastic.core + +This will install the core library with all required dependencies. + +Development Installation +------------------------ + +If you want to contribute to the project or need the latest development features: + +.. code-block:: bash + + # Clone the repository + git clone https://github.com/RedHatQE/widgetastic.core.git + cd widgetastic.core + + # Create virtual environment and activate it + python -m venv .venv_wt + source .venv_wt/bin/activate + + # Install in editable mode + pip install -e . + + # Or with development dependencies + pip install -e ".[dev]" + + + +Playwright Setup +================ + +Widgetastic.core uses Playwright as its browser automation engine. After installing widgetastic.core, +you need to install browser binaries: + +.. code-block:: bash + + # Install browser binaries (required for first-time setup) + playwright install + + # Or install specific browsers only like chromium / firefox + playwright install chromium + +.. note:: + Browser installation may take several minutes and requires internet connectivity. + +Verifying Installation +====================== + +Create a simple test script to verify everything is working: + +.. literalinclude:: ../examples/getting-started/test_installation.py + :language: python + :linenos: + +Run the script: + +.. code-block:: bash + + python test_installation.py + +If you see "✅ Widgetastic is working correctly!" along with the page title, your installation is successful. + + +Optional Dependencies +===================== + +For additional functionality, you can install optional dependencies: + +**Development Tools** + +.. code-block:: bash + + pip install "widgetastic.core[dev]" + +**Testing Dependencies** + +.. code-block:: bash + + pip install "widgetastic.core[test]" + +**Documentation Building** + +.. code-block:: bash + + pip install "widgetastic.core[docs]" diff --git a/docs/guidelines.rst b/docs/guidelines.rst deleted file mode 100644 index 3334510a..00000000 --- a/docs/guidelines.rst +++ /dev/null @@ -1,113 +0,0 @@ -.. _widgetastic-usage-guidelines: - -Widgetastic usage guidelines ----------------------------- - -Anyone using this library should consult these guidelines whether one is not violating any of them. - -- While writing new widgets: - - - They must have the standard read/fill interface - - - ``read()`` -> ``object`` - - - Whatever is returned from ``read()`` must be compatible with ``fill()``. Eg. ``obj.fill(obj.read())`` must work at any time. - - - ``read()`` may throw a ``DoNotReadThisWidget`` exception if reading the widget is pointless (eg. in current form state it is hidden). That is achieved by invoking the ``do_not_read_this_widget()`` function. - - - ``fill(value)`` -> ``True|False`` - - - ``fill(value)`` must be able to ingest whatever was returned by ``read()``. Eg. ``obj.fill(obj.read())`` must work at any time. - - - An exception to this rule is only acceptable in the case where this 1:1 direct mapping would cause severe inconvenience. - - - ``fill`` MUST return ``True`` if it changed anything during filling - - - ``fill`` MUST return ``False`` if it has not changed anything during filling - - - Any of these methods may be omitted if it is appropriate based on the UI widget interactions. - - - It is recommended that all widgets have at least ``read()`` but in cases like buttons where you don't read or fill, it is understandable that there is neither of those. - - ``__init__`` must be in accordance to the concept - - - If you want your widget to accept parameters ``a`` and ``b``, you have to create signature like this: - - - ``__init__(self, parent, a, b, logger=None)`` - - - The first line of the widget must call out to the root class in order to set things up properly: - - - ``Widget.__init__(self, parent, logger=logger)`` - - - Widgets MUST define ``__locator__`` in some way. Views do not have to, but can do it to fence the element lookup in its child widgets. - - - You can write ``__locator__`` method yourself. It should return anything that can be turned into a locator by ``smartloc.Locator`` - - - ``'#foo'`` - - - ``'//div[@id="foo"]'`` - - - ``smartloc.Locator(xpath='...')`` - - - et cetera - - - ``__locator__`` MUST NOT return ``ElementHandle`` instances to prevent stale element issues - - - If you use a ``ROOT`` class attribute, especially in combination with ``ParametrizedLocator``, a ``__locator__`` is generated automatically for you. - - - Widgets should keep its internal state in reasonable size. Ideally none, but eg. caching header names of tables is perfectly acceptable. Saving ``ElementHandle`` instances in the widget instance is not recommended. - - - Think about what to cache and when to invalidate - - - Never store ``ElementHandle`` objects. - - - Try to shorten the lifetime of any single ``ElementHandle`` as much as possible - - - This will help against stale element issues - - - Widgets shall log using ``self.logger``. That ensures the log message is prefixed with the widget name and location and gives more insight about what is happening. - -- When using Widgets (and Views) - - - Bear in mind that when you do ``MySuperWidget('foo', 'bar')`` in ipython, you are not getting an actual widget object, but rather an instance of WidgetDescriptor - - - In order to create a real widget object, you have to have widgetastic ``Browser`` instance around and prepend it to the arguments, so the call to create a real widget instance would look like: - - - ``MySuperWidget(wt_browser, 'foo', 'bar')`` - - - This browser prepending is done automatically by ``WidgetDescriptor`` when you access it on a ``View`` or another ``Widget`` - - - All of these means that the widget objects are created lazily. - - - Views can be nested - - - Filling and reading nested views is simple, each view is read/filled as a dictionary, so the required dictionary structure is exactly the same as the nested class structure - - - Views remember the order in which the Widgets were placed on it. Each ``WidgetDescriptor`` has a sequential number on it. This is used when filling or reading widgets, ensuring proper filling order. - - - This would normally also apply to Views since they are also descendants of ``Widget``, but since you are not instantiating the view when creating nested views, this mechanism does not work. - - - You can ensure the ``View`` gets wrapped in a ``WidgetDescriptor`` and therefore in correct order by placing a ``@View.nested`` decorator on the nested view. - - - Views can optionally define ``before_fill(values)`` and ``after_fill(was_change)`` - - - ``before_fill`` is invoked right before filling gets started. You receive the filling dictionary in the values parameter and you can act appropriately. - - - ``after_fill`` is invoked right after the fill ended, ``was_change`` tells you whether there was any change or not. - -- When using ``Browser`` (also applies when writing Widgets) - - - Ensure you use the widgetastic Browser methods rather than direct Playwright Locator methods where possible - - - Eg. instead of ``locator.text_content()`` use ``browser.text(locator)`` (applies for all such circumstances). These calls usually do not invoke more than their original counterparts. They only invoke some workarounds if some known issue arises. Check what the ``Browser`` (sub)class offers and if you miss something, create a PR - - - You don't necessarily have to specify ``self.browser.element(..., parent=self)`` when you are writing a query inside a widget implementation as widgetastic figures this out and does it automatically. - - - Most of the methods that implement the getters, that would normally be on the element object, take an argument or two for themselves and the rest of ``*args`` and ``**kwargs`` is shoved inside ``element()`` method for resolution, so constructs like ``self.browser.get_attribute('id', self.browser.element('locator', parent=foo))`` are not needed. Just write ``self.browser.get_attribute('id', 'locator', parent=foo)``. Check the method definitions on the ``Browser`` class to see that. - - - ``element()`` method tries to apply a rudimentary intelligence on the element it resolves. If a locator resolves to a single element, it returns it. If the locator resolves to multiple elements, it tries to filter out the invisible elements and return the first visible one. If none of them is visible, it just returns the first one. Under normal circumstances, standard Playwright resolution returns all matching elements. - - - DO NOT use nested locator calls, use ``self.browser.element('locator', parent=element)``. This approach is safer and more consistent with the framework architecture. - - - Eventually I might wrap the elements as well but I decided to not complicate things for now. - -*No current exceptions are to be taken as a precedent.* diff --git a/docs/index.rst b/docs/index.rst index f3cc6ef8..35c66400 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,19 +1,189 @@ -Welcome to widgetastic.core's documentation! -============================================ +============================== +Widgetastic.Core Documentation +============================== + +.. image:: https://img.shields.io/pypi/pyversions/widgetastic.core.svg?style=flat + :target: https://pypi.org/project/widgetastic.core + :alt: Python supported versions + +.. image:: https://badge.fury.io/py/widgetastic.core.svg + :target: https://pypi.org/project/widgetastic.core + +.. image:: https://github.com/RedHatQE/widgetastic.core/workflows/%F0%9F%95%B5%EF%B8%8F%20Test%20suite/badge.svg?branch=master + :target: https://github.com/RedHatQE/widgetastic.core/actions?query=workflow%3A%22%F0%9F%95%B5%EF%B8%8F+Test+suite%22 + +**Making testing of UIs fantastic.** + +Widgetastic is a Python library designed to abstract web UI widgets into a nice object-oriented layer. +This library includes the core classes and some basic widgets that are universal enough to exist in this +core repository. + +Built on top of Microsoft's **Playwright**, Widgetastic provides a robust, modern approach to web UI automation +that handles the complexities of modern web applications while maintaining clean, readable test code. + +.. note:: + This documentation covers widgetastic.core, the foundation library. For framework-specific widgets + (like those for PatternFly, Bootstrap, etc.), check out the ecosystem of widgetastic extensions. + +Features +======== + +**Widget-Focused Approach** + Individual interactive and non-interactive elements on web pages are represented as widgets; + classes with defined behaviour that model your UI components as reusable objects with consistent + read/fill interfaces. A good candidate for a widget might be something like a custom HTML button. + +**View Organization** + Widgets are grouped on Views. A View descends from the Widget class but is specifically designed + to hold other widgets. Views can be nested, and they can define their root locators that are + automatically honoured in element lookup for child widgets. This provides structure and context + for organizing complex pages. + +**Read/Fill Interface** + All Widgets (including Views because they descend from them) have a read/fill interface useful + for filling in forms etc. This interface works recursively. Widgets defined on Views are read/filled + in the exact order that they were defined, making form automation straightforward and predictable. + +**Built on Playwright** + Includes a wrapper around Playwright functionality that tries to make the experience as hassle-free + as possible. Leverage the speed, reliability, and modern web support of Microsoft Playwright with + customizable hooks and built-in network activity monitoring. + +**Intelligent Element Selection** + Automatically finds visible, interactable elements when multiple matches exist. Smart locator + detection supports CSS, XPath, and other locator types with Playwright compatibility, reducing + the need for complex selector strategies. + +**Robust Text Handling** + Reliable text extraction from any element, regardless of CSS styling or positioning. Handles + complex DOM structures and edge cases that traditional automation approaches struggle with. + +**Advanced View Patterns** + Supports :ref:`parametrized-views` for dynamic content and :ref:`switchable-conditional-views` for + adaptive UIs. Handle complex UI structures with elegant, maintainable code. + +**Version Picking** + Supports :ref:`version-picking` to handle UI changes across product versions with intelligent + widget selection. Write version-agnostic tests that adapt automatically to different UI versions. + +**Object Support** + Supports automatic :ref:`constructor-object-collapsing` for objects passed into widget constructors, + enabling flexible, dynamic widget creation and form filling. + +**Modern Python Support** + Modern Python versions (specified in pyproject.toml) are officially supported and unit-tested in CI. + Built with modern Python features and best practices in mind. + +What This Project Does NOT Do +============================== + +**Complete Testing Solution** + In the spirit of modularity, we have intentionally designed our testing system to be modular, so if a + different team likes one library, but wants to do other things a different way, the system does not + stand in their way. + +**UI Navigation** + As per the previous point, it is up to you what you use for navigation. In CFME QE, we use a library + called `navmazing `_, which is an evolution of the system + we used before. You can devise your own system, use ours, or adapt something else. + +**UI Models Representation** + Doing nontrivial testing usually requires some sort of representation of the stuff in the product in + the testing system. Usually, people use classes and instances of these with their methods corresponding + to the real actions you can do with the entities in the UI. Widgetastic offers integration for such + functionality, but does not provide any framework to use. + +**Test Execution** + We use pytest to drive our testing system. If you put the two previous points together and have a + system of representing, navigating and interacting, then writing simple boilerplate code to make the + system's usage from pytest straightforward is the last and possibly simplest thing to do. + +Quick Example +============= + +.. code-block:: python + + from playwright.sync_api import sync_playwright + from widgetastic.browser import Browser + from widgetastic.widget import View, Text, TextInput + + class LoginView(View): + username = TextInput(name='username') + password = TextInput(name='password') + submit = Text(locator='.//div[text()="Log In"]') + message = Text(locator='.//div[contains(@class, "flash-message")]') + + # Initialize with Playwright + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + wt_browser = Browser(page) + + # Use the view + login = LoginView(wt_browser) + login.fill({ + 'username': 'admin', + 'password': 'secret' + }) + login.submit.click() # Text widget inherits ClickableMixin so able to click. + + if login.message.is_displayed: + print(f"Login result: {login.message.text}") + +Documentation Contents +====================== .. toctree:: :maxdepth: 2 - :caption: Contents: + :caption: Getting Started + + getting-started/installation + getting-started/concepts + getting-started/first-steps + +.. toctree:: + :maxdepth: 2 + :caption: Tutorials + + tutorials/index + tutorials/basic-widgets + tutorials/views + tutorials/fill-strategies + tutorials/table-widget + tutorials/iframe-handling + tutorials/window-management + tutorials/version-picking + tutorials/ouia + tutorials/guidelines + +.. Uncomment when API reference is ready +.. .. toctree:: +.. :maxdepth: 3 +.. :caption: API Reference +.. +.. api-reference/index + +Community and Support +====================== + +* **GitHub Repository**: `RedHatQE/widgetastic.core `_ +* **Issue Tracker**: `Report bugs and request features `_ +* **PyPI Package**: `widgetastic.core `_ + +Projects using Widgetastic +=========================== + +* ManageIQ `integration_tests `_ +* Satellite `airgun `_ +* console.redhat.com (insights-qe) +* Windup `integration_test `_ - intro - basic_usage - advanced_usage - internals - guidelines - ouia +License +======= +Licensed under Apache License, Version 2.0 -Indices and tables +Indices and Tables ================== * :ref:`genindex` diff --git a/docs/internals.rst b/docs/internals.rst deleted file mode 100644 index ee33fc2b..00000000 --- a/docs/internals.rst +++ /dev/null @@ -1,118 +0,0 @@ -Internal structure of Widgetastic -================================= - -Widgetastic consists of 2 main parts: - -* `Playwright browser wrapper`_ -* `Widget system`_ - -.. `Playwright browser wrapper`: - -Playwright browser wrapper -=========================== - -This part of the framework serves the purpose of simplifying the interactions with Playwright and also handling some of the quirks we have discovered during development of our testing framework. It also supports "nesting" of the browsers in relation to specific widgets, so it is then easier in the widget layer to implement the lookup fencing. Majority of this functionality is implemented in :py:class:`widgetastic.browser.Browser`. - -Lookup fencing is a technique that enables the programmer to write locators that are relative to its hosting object. When such locator gets resolved, the parent element is resolved first (and it continues recursively until you hit an "unwrapped" browser that is just a browser). This behaviour is not visible to the outside under normal circumstances and it is achieved by :py:class:`widgetastic.browser.BrowserParentWrapper`. - -The :py:class:`widgetastic.browser.Browser` class has some convenience features like `Automatic detection of simple CSS locators`_ and `Automatic visibility precedence selection`_. - - -.. `Automatic detection of simple CSS locators`: - -Automatic detection of simple CSS locators ------------------------------------------- - -By default, all string locators are considered XPath, but in each place where a locator gets passed -into Widgetastic you can leverage automatic simple CSS locator detection. If a string corresponds to -the pattern of `tagname#id.class1.class2` where the tag is optional and at least one `id` or `class` -is present, it considers it a CSS locator. - -If you want to use a complex CSS locator or a different lookup type, you can use -the built-in ``SmartLocator`` functionality that processes all the locators. You can pass instances of -``SmartLocator`` or raw locator strings, and the system will automatically handle the conversion. - -.. `Automatic visibility precedence selection`: - -Automatic visibility precedence selection ------------------------------------------ - -Under normal circumstances, Playwright's ``locator.all()`` returns all elements from the query result. But what if there are multiple elements matching the query, the first one being for some reason invisible, and the second one displayed? - -Widgetastic's :py:meth:`widgetastic.browser.Browser.element`, :py:meth:`widgetastic.browser.Browser.elements`, and :py:meth:`widgetastic.browser.Browser.wait_for_element` browser methods allow for visibility checking in this case. If the keyword argument ``check_visiblity=True`` is passed to ``elements``, if ``visible=True`` is passed to ``wait_for_element``, or if the locator object (e.g., a :py:class:`widgetastic.widget.Widget` object) passed to ``element`` contains a ``__locator__`` method as well as a ``CHECK_VISIBILITY=True`` attribute, then the method will return only the visible matching element(s). - -.. `Widget system`: - -Widget system -============= - -The widget system consists of number of supporting classes and finally the :py:class:`widgetastic.widget.Widget` class itself. - -Let's first talk about how Widgetastic makes sure that although the user "instantiates" the widgets without any additional context, the widgets themselves receive everything they need in a consistent manner. - -The important thing is in :py:meth:`widgetastic.widget.Widget.__new__`. ``__new__`` is the dunder method responsible for creating the object from the class and it is called before ``__init__`` gets called. Widgetastic exploits this functionality. The ``Widget`` class needs to know the instance of another ``Widget`` or :py:class:`widgetastic.browser.Browser` to be instantiated. Since we do not know it at the moment of class definition, we need to **defer** it. And that is where :py:class:`widgetastic.widget.WidgetDescriptor` comes into play. - - -How the WidgetDescriptor works? -------------------------------- - -The beforementioned ``__new__`` method checks if the first argument or the ``parent`` kwargument is specified. If yes, it then lets python create the object as usual. If it is not passed, an instance of :py:class:`widgetastic.widget.WidgetDescriptor` is returned instead. The descriptor class contains these three most important informations: - -* The class object (*yes, class, not an instance*) -* args -* kwargs - -The ``WidgetDescriptor`` is named a descriptor for a reason. Because it implements the ``__get__`` method, it is a Python descriptor. Descriptors allow you to be in the access loop when you access an attribute on an object. This brings us to the deferring and how it is done. - -Simply said, once you access the widget (``view.widget``), the descriptor implementation in the ``WidgetDescriptor`` just instantiates the class with the args and kwargs that were stored on definition and returns it instead of returning itself. - -In real implementation, caching and other things make this process more complex, but under the hood this is what happens. - -:py:class:`widgetastic.widget.WidgetDescriptor` is also ordinal. Each one has a unique ``_seq_id`` attribute which increments for each new :py:class:`widgetastic.widget.WidgetDescriptor` created. Therefore although it is not possible with pure Python facilities, Widgetastic can order the widgets in the order as they were defined. - -All this also means that if you are playing with single widgets in eg. IPython, you always need to stick a browser obejct or another widget as the first parameter. You also need to make sure ``parent`` and ``logger`` are passed to ``super()`` so the widget object can be properly initialized. - -.. code-block:: python - - class MyNewWidget(Widget): - def __init__(self, parent, myarg1, logger=None): - Widget.__init__(self, parent, logger=logger) - self.myarg1 = myarg1 - - -The magic of metaclasses ------------------------- - -:py:class:`widgetastic.widget.Widget` class has a custom metaclass - :py:class:`widgetastic.widget.WidgetMetaclass`. Metaclasses create classes the same way classes create instances. :py:class:`widgetastic.widget.WidgetMetaclass` processes the class definition and builds a couple of helper attributes to facilitate eg. name resolution, since the widget definition cannot know by itself what was the name you assigned it on the class. It also wraps fill/read with logging, generates a :py:meth:`widgetastic.widget.Widget.__locator__` if ``ROOT`` is present, ... - - -Caching of widgets ------------------- - -Widget instances are cached on the hosting widget. Only plain widgets get cached, because the caching system is too simple so far to support parametrized views and such advanced functionality. The descriptor object is used as the cache key, the widget instance is the value. - - -``__locator__()`` and ``__element__()`` protocol ------------------------------------------------- - -To ensure good structure, a protocol of two methods was introduced. Let's talk a bit about them. - -``__locator__()`` method is not implemented by default on ``Widget`` class. Its sole purpose is to -serve a locator of the object itself, so when the object is thrown in element lookup, it returns the -result for the locator returned by this method. This method must return a locator, be it a valid -locator string, tuple or another locatable object. If a webelement is returned by ``__locator__()``, -a warning will be produced into the log. - -``__locator__()`` is auto-generated when ``ROOT`` attribute is present on the class with a valid -locator. - -``__element__()`` method has a default implementation on every widget. Its purpose is to look up the -root element from ``__locator__()``. It is present because the machinery that digests the objects -for element lookup will try it first. ``__element__()``'s default implementation looks up the -``__locator__()`` in the *parent browser*. That is important, because that allows simpler structure -for the browser wrapper. - -Combination of these methods ensures, that while the widget's root element is looked up in parent -browser, which fences the lookup into the parent widget, all lookups inside the widget, like child -widgets or other browser operations operate within the widget's root element, eliminating the need -of passing the parent element. diff --git a/docs/intro.rst b/docs/intro.rst deleted file mode 100644 index b838a3a6..00000000 --- a/docs/intro.rst +++ /dev/null @@ -1,51 +0,0 @@ -Introduction -============ - -Widgetastic is a Python library designed to abstract out web UI widgets into a nice object-oriented -layer. This library includes the core classes and some basic widgets that are universal enough to -exist in this core repository. - -Features --------- - -- Individual interactive and non-interactive elements on the web pages are represented as widgets; - that is, classes with defined behaviour. A good candidate for a widget might be something - a like custom HTML button. -- Widgets are grouped on Views. A View descends from the Widget class but it is specifically designed - to hold other widgets. -- All Widgets (including Views because they descend from them) have a read/fill interface useful for - filling in forms etc. This interface works recursively. -- Views can be nested. -- Widgets defined on Views are read/filled in exact order that they were defined. The only exception - to this default behaviour is for nested Views as there is limitation in the language. However, this - can be worked around by using ``View.nested`` decorator on the nested View. -- Includes a wrapper around Playwright functionality that tries to make the experience as hassle-free - as possible including customizable hooks and built-in network activity monitoring. -- Views can define their root locators and those are automatically honoured in the element lookup - in the child Widgets. -- Supports :ref:`parametrized-views`. -- Supports :ref:`switchable-conditional-views`. -- Supports :ref:`widget-including`. -- Supports :ref:`version-picking`. -- Supports automatic :ref:`constructor-object-collapsing` for objects passed into the widget constructors. -- Supports :ref:`fillable-objects` that can coerce themselves into an appropriate filling value. -- Supports modern Python versions (specify in pyproject.toml) are officially supported and unit-tested in CI. - -What this project does NOT do ------------------------------ - -- A complete testing solution. In spirit of modularity, we have intentionally designed our testing - system modular, so if a different team likes one library, but wants to do other things different - way, the system does not stand in its way. -- UI navigation. As per previous bullet, it is up to you what you use. In CFME QE, we use a library - called `navmazing `_, which is an evolution of the system - we used before. You can devise your own system, use ours, or adapt something else. -- UI models representation. Doing nontrivial testing usually requires some sort of representation - of the stuff in the product in the testing system. Usually, people use classes and instances of - these with their methods corresponding to the real actions you can do with the entities in the UI. - Widgetastic offers integration for such functionality (:ref:`fillable-objects`), but does not provide - any framework to use. -- Test execution. We use pytest to drive our testing system. If you put the two previous bullets - together and have a system of representing, navigating and interacting, then writing a simple - boilerplate code to make the system's usage from pytest straightforward is the last and possibly - simplest thing to do. diff --git a/docs/ouia.rst b/docs/ouia.rst deleted file mode 100644 index 6fe674fc..00000000 --- a/docs/ouia.rst +++ /dev/null @@ -1,45 +0,0 @@ -Open UI Automation -================== - -Widgetastic provides a support of `OUIA `_ compatible -components. There are three base classes: :py:class:`widgetastic.ouia.OUIAMixin`, -:py:class:`widgetastic.ouia.OUIAGenericView` and :py:class:`widgetastic.ouia.OUIAGenericWidget`. - -In order to start creating an OUIA compatible widget or view just inherit a respectful class. -Children classes must have the same name as the value of ``data-ouia-component-type`` attribute of -the root HTML element. Besides children classes should define ``OUIA_NAMESPACE`` class attribute if -it's appicable. - -Examples: - -Consider this html excerpt: - -.. code-block:: html - - - -According to OUIA this is ``Button`` component in ``PF`` namespace with id ``This is a button``. -Basing on that information we can create the following widget: - -.. code-block:: python - - from widgetastic.ouia import OUIAGenericWidget - - class Button(OUIAGenericWidget): - OUIA_NAMESPACE = "PF" - pass - -As you can see you don't need to specify any locator. If a component complies with OUIA spec the -locator can be generated. The only argument you may provide is ``component_id``. After that you can -add this class to some view and use in automation: - -.. code-block:: python - - class Details(View): - ROOT = ".//div[@id='some_id']" - button = Button("This is a button") - - view = Details(browser) - view.button.click() diff --git a/docs/source/modules.rst b/docs/source/modules.rst deleted file mode 100644 index 30d7d204..00000000 --- a/docs/source/modules.rst +++ /dev/null @@ -1,9 +0,0 @@ -:orphan: - -widgetastic -=========== - -.. toctree:: - :maxdepth: 4 - - widgetastic diff --git a/docs/source/widgetastic.rst b/docs/source/widgetastic.rst deleted file mode 100644 index b6b79847..00000000 --- a/docs/source/widgetastic.rst +++ /dev/null @@ -1,69 +0,0 @@ -widgetastic package -=================== - -Subpackages ------------ - -.. toctree:: - :maxdepth: 4 - - widgetastic.widget - -Submodules ----------- - -widgetastic.browser module --------------------------- - -.. automodule:: widgetastic.browser - :members: - :undoc-members: - :show-inheritance: - -widgetastic.exceptions module ------------------------------ - -.. automodule:: widgetastic.exceptions - :members: - :undoc-members: - :show-inheritance: - -widgetastic.log module ----------------------- - -.. automodule:: widgetastic.log - :members: - :undoc-members: - :show-inheritance: - -widgetastic.ouia module ------------------------ - -.. automodule:: widgetastic.ouia - :members: - :undoc-members: - :show-inheritance: - -widgetastic.utils module ------------------------- - -.. automodule:: widgetastic.utils - :members: - :undoc-members: - :show-inheritance: - -widgetastic.xpath module ------------------------- - -.. automodule:: widgetastic.xpath - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: widgetastic - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/widgetastic.widget.rst b/docs/source/widgetastic.widget.rst deleted file mode 100644 index 438d94bb..00000000 --- a/docs/source/widgetastic.widget.rst +++ /dev/null @@ -1,69 +0,0 @@ -widgetastic.widget package -========================== - -Submodules ----------- - -widgetastic.widget.base module ------------------------------- - -.. automodule:: widgetastic.widget.base - :members: - :undoc-members: - :show-inheritance: - -widgetastic.widget.checkbox module ----------------------------------- - -.. automodule:: widgetastic.widget.checkbox - :members: - :undoc-members: - :show-inheritance: - -widgetastic.widget.image module -------------------------------- - -.. automodule:: widgetastic.widget.image - :members: - :undoc-members: - :show-inheritance: - -widgetastic.widget.input module -------------------------------- - -.. automodule:: widgetastic.widget.input - :members: - :undoc-members: - :show-inheritance: - -widgetastic.widget.select module --------------------------------- - -.. automodule:: widgetastic.widget.select - :members: - :undoc-members: - :show-inheritance: - -widgetastic.widget.table module -------------------------------- - -.. automodule:: widgetastic.widget.table - :members: - :undoc-members: - :show-inheritance: - -widgetastic.widget.text module ------------------------------- - -.. automodule:: widgetastic.widget.text - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: widgetastic.widget - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/tutorials/basic-widgets.rst b/docs/tutorials/basic-widgets.rst new file mode 100644 index 00000000..8d1ef9f4 --- /dev/null +++ b/docs/tutorials/basic-widgets.rst @@ -0,0 +1,123 @@ +============= +Basic Widgets +============= + +This comprehensive tutorial demonstrates all the fundamental widgets in Widgetastic.core using the framework's real testing pages. +You'll learn to interact with web elements through practical examples using ``testing/html/testing_page.html`` - the same file used to test the framework itself. + +In widgetastic, a widget represents any interactive or non-interactive element on a web page. +Unlike traditional automation approaches that work directly with raw elements, widgets provide a higher-level, object-oriented abstraction. + +Learning Objectives +=================== + +By completing this tutorial, you will: + +* ✅ Basic understanding of core widget +* ✅ Understand the widget read/fill interface +* ✅ Handle widget state and validation + + +Text Widget +============ + +The :py:class:`~widgetastic.widget.Text` widget extracts text content from a web element. + +**Basic Text Widget Examples** + +.. literalinclude:: ../examples/basic-widgets/text_widget_basic.py + :language: python + :linenos: + + +.. note:: + + While inline widget initialization (as shown above) works for learning and debugging, production code should use View classes to organize widgets. + Views provide better structure, reusability, and maintainability for real automation projects. + + +Input Widgets +============== + +Widgetastic provides specialized widgets for some types of HTML input elements. Each input widget is optimized for its specific use case while maintaining a consistent interface. + +**TextInput Widget** + +The :py:class:`~widgetastic.widget.TextInput` widget handles standard text input elements like text, email, number, textarea, etc. + +Basic TextInput Operations: + +.. literalinclude:: ../examples/basic-widgets/textinput_basic.py + :language: python + :linenos: + + +TextInput with Different Element Types + +.. literalinclude:: ../examples/basic-widgets/textinput_different_types.py + :language: python + :linenos: + +TextInput State Management + +.. literalinclude:: ../examples/basic-widgets/textinput_state_management.py + :language: python + :linenos: + +.. note:: + **Read/Fill Interface Guidelines:** + + * The ``fill()`` method MUST return ``True`` if it changed anything, ``False`` if no change occurred + * Whatever is returned from ``read()`` must be compatible with ``fill()`` + * Round-trip requirement: ``widget.fill(widget.read())`` must work at any time + * This ensures widgets can be read and restored to their previous state reliably + + +**FileInput Widget** + +The :py:class:`~widgetastic.widget.FileInput` widget handles file upload inputs. + +.. literalinclude:: ../examples/basic-widgets/fileinput.py + :language: python + :linenos: + + +**ColourInput Widget** + +The :py:class:`~widgetastic.widget.ColourInput` widget handles HTML5 color picker inputs. + +.. literalinclude:: ../examples/basic-widgets/colourinput.py + :language: python + :linenos: + + +Checkbox Widget +================ + +The :py:class:`~widgetastic.widget.Checkbox` widget handles checkbox elements. + +.. literalinclude:: ../examples/basic-widgets/checkbox.py + :language: python + :linenos: + + +Select Widget +============= + +The :py:class:`~widgetastic.widget.Select` widget handles HTML select elements. + +.. literalinclude:: ../examples/basic-widgets/select_widget.py + :language: python + :linenos: + + +Image Widget +============ + +The :py:class:`~widgetastic.widget.Image` widget provides access to HTML image elements. + +**Image Examples from Testing Page** + +.. literalinclude:: ../examples/basic-widgets/image.py + :language: python + :linenos: diff --git a/docs/tutorials/fill-strategies.rst b/docs/tutorials/fill-strategies.rst new file mode 100644 index 00000000..d912cdc8 --- /dev/null +++ b/docs/tutorials/fill-strategies.rst @@ -0,0 +1,164 @@ +================ +Fill Strategies +================ + +This tutorial explains Widgetastic's built-in fill strategies for handling form filling operations. You'll learn about ``DefaultFillViewStrategy`` and ``WaitFillViewStrategy``, how they work, and when to use each one. + +Learning Objectives +=================== + +By completing this tutorial, you will: + +* ✅ Understand how fill strategies work in Widgetastic +* ✅ Learn when to use ``DefaultFillViewStrategy`` vs ``WaitFillViewStrategy`` +* ✅ Use ``respect_parent`` for strategy inheritance in nested views + +Understanding Fill Strategies +============================== + +Fill strategies control how widgets are filled in a View. When you call ``view.fill(values)``, the view uses its configured fill strategy to determine: + +* The order widgets should be filled +* Whether to wait for widgets to appear before filling +* How to handle errors during filling +* What to log during fill operations + +Widgetastic provides two built-in fill strategies: + +**Built-in Fill Strategies** + +* **DefaultFillViewStrategy**: Fills widgets sequentially without waiting +* **WaitFillViewStrategy**: Waits for each widget to be displayed before filling + +DefaultFillViewStrategy +======================== + +``DefaultFillViewStrategy`` is the default strategy used by all Views. It fills widgets in the order they appear in the view's ``widget_names`` attribute. + +**Key Features:** + +* Fills widgets sequentially in order +* No waiting for widgets to appear +* Skips widgets with ``None`` values +* Warns about extra keys that don't match widgets +* Handles widgets without fill methods gracefully +* Returns ``True`` if any widget value changed + +**Basic Usage:** + +.. literalinclude:: ../examples/fill-strategies/default_fill_strategy_examples.py + :language: python + :start-after: # Example: Basic Usage + :end-before: # End Example: Basic Usage + + +**Filtering None Values:** + +Values set to ``None`` are automatically filtered out: + +.. literalinclude:: ../examples/fill-strategies/default_fill_strategy_examples.py + :language: python + :start-after: # Example: Filtering None Values + :end-before: # End Example: Filtering None Values + + +**Handling Extra Keys:** + +The strategy warns about keys in your fill data that don't correspond to widgets: + +.. literalinclude:: ../examples/fill-strategies/default_fill_strategy_examples.py + :language: python + :start-after: # Example: Handling Extra Keys + :end-before: # End Example: Handling Extra Keys + +**Handling Widgets Without Fill Methods:** + +Widgets that don't have a ``fill()`` method are skipped with a warning: + +.. literalinclude:: ../examples/fill-strategies/default_fill_strategy_examples.py + :language: python + :start-after: # Example: Handling Widgets Without Fill + :end-before: # End Example: Handling Widgets Without Fill + +**Change Detection:** + +The strategy returns ``True`` only if at least one widget value actually changed: + +.. literalinclude:: ../examples/fill-strategies/default_fill_strategy_examples.py + :language: python + :start-after: # Example: Change Detection + :end-before: # End Example: Change Detection + + +WaitFillViewStrategy +==================== + +``WaitFillViewStrategy`` extends ``DefaultFillViewStrategy`` by adding wait functionality. Before filling each widget, it waits for the widget to be displayed. + +**Key Features:** +* Inherits all features from ``DefaultFillViewStrategy`` +* Waits for each widget to be displayed before filling +* Configurable wait timeout per widget +* Ideal for dynamic content that appears after user interactions +* Handles Single Page Applications (SPAs) with async loading + +**When to Use:** + +Use ``WaitFillViewStrategy`` when: +* Widgets may appear dynamically after page load +* Forms have conditional fields that appear based on selections +* Widgets are inside iframes that load slowly +* Forms have progressive revelation of fields + +**Basic Usage:** + +.. literalinclude:: ../examples/fill-strategies/wait_fill_strategy_examples.py + :language: python + :start-after: # Example: Basic Usage + :end-before: # End Example: Basic Usage + +**Custom Wait Timeout:** + +Configure how long to wait for each widget: + +.. literalinclude:: ../examples/fill-strategies/wait_fill_strategy_examples.py + :language: python + :start-after: # Example: Custom Wait Timeout + :end-before: # End Example: Custom Wait Timeout + + +Strategy Inheritance with respect_parent +======================================== + +Both fill strategies support the ``respect_parent`` parameter, which controls whether child views inherit the parent's fill strategy. + +**Understanding respect_parent:** + +* ``respect_parent=False`` (default): Child views get their own default strategy +* ``respect_parent=True``: Child views inherit parent's strategy + +**Example Without Inheritance:** + +.. literalinclude:: ../examples/fill-strategies/strategy_inheritance_examples.py + :language: python + :start-after: # Example: Without Inheritance + :end-before: # End Example: Without Inheritance + +**Example With Inheritance:** + +.. literalinclude:: ../examples/fill-strategies/strategy_inheritance_examples.py + :language: python + :start-after: # Example: With Inheritance + :end-before: # End Example: With Inheritance + + + +Key takeaways: + +* Views automatically use ``DefaultFillViewStrategy`` if none specified +* Use ``WaitFillViewStrategy`` for dynamic content that may not be immediately available +* Fill strategies handle error cases gracefully (skipping widgets without fill methods) +* Strategies respect widget order and filter None values automatically + + +This completes the fill strategies tutorial. You now understand how to use Widgetastic's built-in fill strategies effectively in your automation tests. diff --git a/docs/tutorials/guidelines.rst b/docs/tutorials/guidelines.rst new file mode 100644 index 00000000..87c370c7 --- /dev/null +++ b/docs/tutorials/guidelines.rst @@ -0,0 +1,430 @@ +.. _guidelines: + +=========== +Guidelines +=========== + +This document outlines essential guidelines for using Widgetastic.core effectively. Anyone using this library should consult these guidelines to ensure they are following best practices and not violating any framework conventions. + +.. note:: + These guidelines are based on the framework's architecture and design principles. Following them ensures your code is maintainable, reliable, and consistent with the framework's expectations. + +While Writing New Widgets +========================== + +Read/Fill Interface +------------------- + +All widgets should implement the standard read/fill interface: + +**read() Method** + +- **Return Type**: ``object`` +- **Compatibility**: Whatever is returned from ``read()`` must be compatible with ``fill()`` +- **Round-trip Requirement**: ``obj.fill(obj.read())`` must work at any time +- **Exception Handling**: ``read()`` may throw a ``DoNotReadThisWidget`` exception if reading the widget is pointless (e.g., in current form state it is hidden). This is achieved by invoking the ``do_not_read_this_widget()`` function. + +**fill() Method** + +- **Return Type**: ``True|False`` +- **Input Compatibility**: ``fill(value)`` must be able to ingest whatever was returned by ``read()`` +- **Round-trip Requirement**: ``obj.fill(obj.read())`` must work at any time +- **Exception**: An exception to this rule is only acceptable in the case where this 1:1 direct mapping would cause severe inconvenience +- **Return Value Rules**: + - ``fill`` MUST return ``True`` if it changed anything during filling + - ``fill`` MUST return ``False`` if it has not changed anything during filling + +**Optional Methods** + +- Any of these methods may be omitted if it is appropriate based on the UI widget interactions +- It is recommended that all widgets have at least ``read()`` but in cases like buttons where you don't read or fill, it is understandable that there is neither of those + +**Example** + +.. code-block:: python + + class MyWidget(Widget): + def read(self): + """Read current widget value.""" + return self.browser.text(self) + + def fill(self, value): + """Fill widget with value.""" + current = self.read() + if value == current: + return False # No change + self.browser.fill(self, str(value)) + return True # Changed + + # Verify round-trip works + widget = MyWidget(browser, locator="#my-widget") + value = widget.read() + widget.fill(value) # Should work without errors + +Widget Initialization +--------------------- + +**Signature Pattern** + +The ``__init__`` must follow the standard pattern: + +- If you want your widget to accept parameters ``a`` and ``b``, you must create signature like this: + - ``__init__(self, parent, a, b, logger=None)`` + +**Parent Class Initialization** + +- The first line of the widget must call out to the root class in order to set things up properly: + - ``Widget.__init__(self, parent, logger=logger)`` + +**Example** + +.. code-block:: python + + class CustomInput(Widget): + def __init__(self, parent, input_id, placeholder=None, logger=None): + """Initialize custom input widget.""" + Widget.__init__(self, parent, logger=logger) + self.input_id = input_id + self.placeholder = placeholder + + def __locator__(self): + return f"#input-{self.input_id}" + +Locator Definition +------------------ + +**Requirement** + +- Widgets MUST define ``__locator__`` in some way +- Views do not have to, but can do it to fence the element lookup in its child widgets + +**Locator Return Types** + +You can write ``__locator__`` method yourself. It should return anything that can be turned into a locator by :py:class:`~widgetastic.locator.SmartLocator`: + +- ``'#foo'`` (CSS selector) +- ``'//div[@id="foo"]'`` (XPath) +- ``SmartLocator(xpath='...')`` (Locator object instance) +- etc. + +**Important Restrictions** + +- ``__locator__`` MUST NOT return ``ElementHandle`` instances to prevent stale element issues + +**Automatic Generation** + +- If you use a ``ROOT`` class attribute, especially in combination with ``ParametrizedLocator``, a ``__locator__`` is generated automatically for you + +**Example** + +.. code-block:: python + + class MyWidget(Widget): + # Option 1: Using ROOT attribute (automatic __locator__) + ROOT = "#my-widget" + + # Option 2: Custom __locator__ method + def __locator__(self): + return f"#widget-{self.widget_id}" + + # Option 3: Using ParametrizedLocator + ROOT = ParametrizedLocator(".//div[@id={@widget_id|quote}]") + +State Management +---------------- + +**General Principle** + +- Widgets should keep its internal state in reasonable size Ideally none, but e.g., caching header names of tables is perfectly acceptable +- Saving ``ElementHandle`` instances in the widget instance is not recommended + +**Caching Guidelines** + +- Think about what to cache and when to invalidate +- Never store ``ElementHandle`` objects +- Try to shorten the lifetime of any single ``ElementHandle`` as much as possible +- This will help against stale element issues + + +Logging +------- + +**Standard Practice** + +- Widgets shall log using ``self.logger`` +- That ensures the log message is prefixed with the widget name and location +- This gives more insight about what is happening + +**Example** + +.. code-block:: python + + class MyWidget(Widget): + def fill(self, value): + self.logger.info(f"Filling widget with value: {value}") + # ... fill logic ... + self.logger.debug(f"Fill completed, new value: {self.read()}") + +When Using Widgets (and Views) +================================ + +WidgetDescriptor and Lazy Creation +----------------------------------- + +**Understanding WidgetDescriptor** + +- Bear in mind that when you do ``MySuperWidget('foo', 'bar')`` in python interpreter, you are not getting an actual widget object, but rather an instance of ``WidgetDescriptor`` + +**Creating Real Widget Instances** + +- In order to create a real widget object, you have to have widgetastic ``Browser`` instance around and prepend it to the arguments +- The call to create a real widget instance would look like: + - ``MySuperWidget(wt_browser, 'foo', 'bar')`` + +**Automatic Browser Prepending** + +- This browser prepending is done automatically by ``WidgetDescriptor`` when you access it on a ``View`` or another ``Widget`` +- All of these means that the widget objects are created lazily + +**Example** + +.. code-block:: python + + class MyView(View): + my_widget = MySuperWidget('foo', 'bar') + + view = MyView(browser) + # When you access view.my_widget, WidgetDescriptor automatically: + # 1. Prepends browser to arguments + # 2. Creates the actual widget instance + # 3. Returns the real widget object + widget = view.my_widget # Now it's a real widget instance + +Nested Views +------------ + +**Filling and Reading** + +- Views can be nested +- Filling and reading nested views is simple +- Each view is read/filled as a dictionary +- The required dictionary structure is exactly the same as the nested class structure + +**Example** + +.. code-block:: python + + class InnerView(View): + field1 = TextInput("#field1") + field2 = TextInput("#field2") + + class OuterView(View): + inner = View.nested(InnerView) + other_field = TextInput("#other") + + view = OuterView(browser) + # Fill nested view + view.fill({ + 'inner': { + 'field1': 'value1', + 'field2': 'value2' + }, + 'other_field': 'value3' + }) + +Widget Order and View.nested Decorator +-------------------------------------- + +**Order Preservation** + +- Views remember the order in which the Widgets were placed on it +- Each ``WidgetDescriptor`` has a sequential number on it +- This is used when filling or reading widgets, ensuring proper filling order + +**Nested Views Exception** + +- This would normally also apply to Views since they are also descendants of ``Widget`` +- But since you are not instantiating the view when creating nested views, this mechanism does not work + +**Solution: @View.nested Decorator** + +- You can ensure the ``View`` gets wrapped in a ``WidgetDescriptor`` and therefore in correct order by placing a ``@View.nested`` decorator on the nested view + +**Example** + +.. code-block:: python + + class InnerView(View): + field1 = TextInput(id="field1") + field2 = TextInput(id="field2") + + class OuterView(View): + @View.nested + class inner(View): + field1 = TextInput(id="field1") + field2 = TextInput(id="field2") + + other_field = TextInput(id="other") + +View Lifecycle Hooks +-------------------- + +**Optional Methods** + +- Views can optionally define ``before_fill(values)`` and ``after_fill(was_change)`` + +**before_fill** + +- Invoked right before filling gets started +- You receive the filling dictionary in the values parameter +- You can act appropriately (e.g., validation, preparation) + +**after_fill** + +- Invoked right after the fill ended +- ``was_change`` tells you whether there was any change or not +- Useful for post-fill actions (e.g., waiting for updates, logging) + +**Example** + +.. code-block:: python + + class MyView(View): + field1 = TextInput(id="field1") + field2 = TextInput(id="field2") + + def before_fill(self, values): + """Called before filling starts.""" + self.logger.info(f"About to fill with: {values}") + # Could validate values here + + def after_fill(self, was_change): + """Called after filling completes.""" + if was_change: + self.logger.info("View was successfully filled") + else: + self.logger.debug("No changes were made") + +When Using Browser (also applies when writing Widgets) +======================================================= + +Use Widgetastic Browser Methods +-------------------------------- + +**General Rule** + +- Ensure you use the widgetastic Browser methods rather than direct Playwright Locator methods where possible + +**Example** + +- Instead of ``locator.text_content()`` use ``browser.text(locator)`` +- This applies for all such circumstances +- These calls usually do not invoke more than their original counterparts +- They only invoke some workarounds if some known issue arises +- Check what the ``Browser`` (sub)class offers and if you miss something, create a PR + +**Example** + +.. code-block:: python + + # BAD: Direct Playwright method + element = browser.element("#my-element") + text = element.text_content() + + # GOOD: Widgetastic Browser method + text = browser.text("#my-element") + +Automatic Parent Resolution +---------------------------- + +**Simplified Syntax** + +- You don't necessarily have to specify ``self.browser.element(..., parent=self)`` when you are writing a query inside a widget implementation +- Widgetastic figures this out and does it automatically + +**Example** + +.. code-block:: python + + class MyWidget(Widget): + def get_child_text(self): + # Widgetastic automatically uses self as parent + return self.browser.text(".//span", parent=self) + # Can also be written as: + # return self.browser.text(".//span") # parent=self is automatic + +Method Arguments and Element Resolution +---------------------------------------- + +**Simplified Method Calls** + +- Most of the methods that implement the getters, that would normally be on the element object, take an argument or two for themselves +- The rest of ``*args`` and ``**kwargs`` is shoved inside ``element()`` method for resolution +- So constructs like ``self.browser.get_attribute('id', self.browser.element('locator', parent=foo))`` are not needed +- Just write ``self.browser.get_attribute('id', 'locator', parent=foo)`` +- Check the method definitions on the ``Browser`` class to see that + +**Example** + +.. code-block:: python + + # BAD: Nested element() call + element = self.browser.element('locator', parent=foo) + attr = self.browser.get_attribute('id', element) + + # GOOD: Direct method call + attr = self.browser.get_attribute('id', 'locator', parent=foo) + +Intelligent Element Selection +------------------------------ + +**Automatic Filtering** + +- ``element()`` method tries to apply a rudimentary intelligence on the element it resolves +- If a locator resolves to a single element, it returns it +- If the locator resolves to multiple elements, it tries to filter out the invisible elements and return the first visible one +- If none of them is visible, it just returns the first one +- Under normal circumstances, standard Playwright resolution returns all matching elements + +**Example** + +.. code-block:: python + + # If multiple elements match, widgetastic automatically: + # 1. Filters out invisible elements + # 2. Returns the first visible one + # 3. Falls back to first element if none are visible + element = browser.element(".//button") # Intelligent selection + +Avoid Nested Locator Calls +--------------------------- + +**Important Rule** + +- DO NOT use nested locator calls +- Use ``self.browser.element('locator', parent=element)`` instead +- This approach is safer and more consistent with the framework architecture + +**Example** + +.. code-block:: python + + # BAD: Nested locator calls + parent = browser.element("#parent") + child = parent.locator(".//child") # Don't do this! + + # GOOD: Use browser.element with parent parameter + parent = browser.element("#parent") + child = browser.element(".//child", parent=parent) + +Summary +======= + +These guidelines ensure that your Widgetastic code is: + +* **Consistent**: Follows framework conventions and patterns +* **Maintainable**: Easy to understand and modify +* **Reliable**: Avoids common pitfalls like stale element issues +* **Efficient**: Uses framework features optimally + +Remember: *No current exceptions are to be taken as a precedent.* Always follow these guidelines unless there's a compelling reason documented in the framework itself. diff --git a/docs/tutorials/iframe-handling.rst b/docs/tutorials/iframe-handling.rst new file mode 100644 index 00000000..eade4eb1 --- /dev/null +++ b/docs/tutorials/iframe-handling.rst @@ -0,0 +1,62 @@ +================ +IFrame Handling +================ + +This tutorial demonstrates how to work with iframes in Widgetastic.core using the framework's test pages. +You'll learn to navigate iframe hierarchies, switch contexts, and handle nested frame structures using ``iframe_page.html`` and ``iframe_page2.html``. + +.. note:: + **Prerequisites**: Basic widgets tutorial + **Test Pages Used**: ``testing/html/testing_page.html``, ``iframe_page.html``, ``iframe_page2.html`` + +Learning Objectives +=================== + +* ✅ Understand iframe context switching +* ✅ Navigate nested iframe hierarchies +* ✅ Handle iframe isolation and cross-context access + +Understanding IFrames in Web Automation +======================================= + +IFrames (inline frames) embed another HTML document within the current page. +They create isolated contexts that require special handling in web automation: + +* **Context Isolation**: Elements inside iframes aren't accessible from the main page context +* **Frame Switching**: You must explicitly switch context to interact with iframe content +* **Nested Frames**: IFrames can contain other iframes, creating complex hierarchies +* **Security Boundaries**: Cross-origin iframes may have additional restrictions + + +Basic IFrame Access +=================== + +The testing page contains an iframe that loads ``iframe_page.html``. Here's how to access it: + +**Simple IFrame View** + +.. literalinclude:: ../examples/iframe-handling/basic_iframe.py + :language: python + :linenos: + +Nested IFrame Navigation +======================== + +The iframe testing setup includes nested iframes. Here's how to handle complex hierarchies: + +**Nested IFrame Structure** + +.. literalinclude:: ../examples/iframe-handling/nested_iframe.py + :language: python + :linenos: + +IFrame Context Isolation +======================== + +IFrame contexts are completely isolated. Elements in different frames cannot directly interact: + +**Demonstrating Context Isolation** + +.. literalinclude:: ../examples/iframe-handling/context_isolation.py + :language: python + :linenos: diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst new file mode 100644 index 00000000..2725f0bd --- /dev/null +++ b/docs/tutorials/index.rst @@ -0,0 +1,57 @@ +========= +Tutorials +========= + +Welcome to the Widgetastic.Core tutorials! These step-by-step guides will take you from basic concepts to advanced +automation patterns. Each tutorial builds on the previous ones, so we recommend following them in order. + +.. note:: + **Prerequisites**: Basic Python knowledge and familiarity with HTML/web concepts. + +Complete Learning Path +====================== + +Follow these tutorials in order to learn widgetastic from foundation to advanced patterns. Each topic builds essential knowledge needed for the next level. + +These comprehensive tutorials cover everything from basic widget usage to advanced automation patterns. Each tutorial includes working examples using the widgetastic test page. + +**Ready to begin?** Start with Basic Widgets and follow the sequence. + +**Support**: Most examples use elements from ``testing/html/testing_page.html``, with some tutorials using specialized pages (``iframe_page.html``, ``popup_test_page.html``) for specific features - you can test everything yourself! + +Setting Up Your Environment +============================ + +**Browser Setup Using Testing Page** + +All examples in these tutorials use a common browser setup defined as: + +.. literalinclude:: ../examples/browser_setup.py + :language: python + :linenos: + +This setup: + +- Initializes Playwright with Chrome browser +- Navigates to the widgetastic testing page (``testing/html/testing_page.html``) +- Returns a ``Browser`` instance ready for use + + + +Understanding the Testing Page Structure +========================================= + +The ``testing_page.html`` contains comprehensive examples: + +* **Element Visibility & State Testing**: Hidden/visible elements, interactive buttons with click tracking +* **Input Widgets & Controls**: Text inputs, file uploads, color pickers, editable content, textareas +* **Form Elements & Input States**: Mixed input types, radio button groups, enabled/disabled states +* **Table Widget Examples**: Standard tables with headers, tables without proper headers, embedded widgets +* **Image Widget Testing**: Images with src, alt, and title attributes +* **Locator & Element Finding**: Ambiguous vs specific locators, batch operations, element dimensions +* **Drag and Drop Testing**: Interactive drag/drop elements, sortable lists, position tracking +* **Advanced Table Operations**: Tables with embedded widgets, switchable content, dynamic content +* **Multiple TBody Table Structure**: Complex table structures with multiple tbody sections +* **View Testing**: Normal views, parametrized views, conditional switchable views +* **IFrame & Nested Content**: Embedded iframe testing +* **OUIA Integration**: Open UI Automation components with standardized attributes diff --git a/docs/tutorials/ouia.rst b/docs/tutorials/ouia.rst new file mode 100644 index 00000000..25778aef --- /dev/null +++ b/docs/tutorials/ouia.rst @@ -0,0 +1,90 @@ +========================== +Open UI Automation (OUIA) +========================== + +Widgetastic provides comprehensive support for `OUIA (Open UI Automation) `_ +compatible components. OUIA is a specification that standardizes component identification through +HTML data attributes, making automation more reliable and maintainable. + +In order to start creating an OUIA compatible widget or view, just inherit from the appropriate base class. +Children classes should define the ``OUIA_COMPONENT_TYPE`` class attribute to match the value of +``data-ouia-component-type`` attribute of the root HTML element. + +The key advantage is that **you don't need to specify any locator**. If a component complies with OUIA +spec, the locator can be automatically generated from the OUIA attributes. + +OUIA Base Classes +================= + +Widgetastic provides three base classes for OUIA support: + +* :py:class:`widgetastic.ouia.OUIABase`: Core OUIA functionality +* :py:class:`widgetastic.ouia.OUIAGenericWidget`: Base for OUIA-compatible widgets +* :py:class:`widgetastic.ouia.OUIAGenericView`: Base for OUIA-compatible views + +Creating OUIA Widgets +====================== + +Consider this HTML excerpt: + +.. code-block:: html + + + +According to OUIA, this is a ``Button`` component in the ``PF`` namespace with id ``This is a button``. +Based on that information, we can create the following widget: + +.. literalinclude:: ../examples/ouia/creating_ouia_widgets.py + :language: python + :linenos: + +Creating OUIA Views +=================== + +OUIA views are containers that use OUIA attributes for identification. They're perfect for +organizing multiple OUIA widgets together. + +.. literalinclude:: ../examples/ouia/creating_ouia_views.py + :language: python + :linenos: + +Existing OUIA Widgets +===================== + +Widgetastic provides some basic pre-built OUIA widgets that you can use directly: + +* :py:class:`widgetastic.ouia.input.TextInput` - OUIA version of TextInput widget +* :py:class:`widgetastic.ouia.checkbox.Checkbox` - OUIA version of Checkbox widget +* :py:class:`widgetastic.ouia.text.Text` - OUIA version of Text widget + +OUIA Safety Attribute +===================== + +The ``data-ouia-safe`` attribute indicates whether a component is in a static state (no animations). +This is useful for waiting until components are ready for interaction. + +.. literalinclude:: ../examples/ouia/ouia_safety_attribute.py + :language: python + :linenos: + +Complete Example +================ + +Here's a complete example using OUIA widgets from the testing page: + +.. literalinclude:: ../examples/ouia/ouia_complete_example.py + :language: python + :linenos: + + +Key Points +========== + +* Use ``OUIAGenericWidget`` for individual components (buttons, inputs, etc.) +* Use ``OUIAGenericView`` for container components (forms, sections, selects) +* Define ``OUIA_COMPONENT_TYPE`` in widget classes to match ``data-ouia-component-type`` +* Use ``component_id`` parameter to match ``data-ouia-component-id`` attribute +* No manual locators needed - they're automatically generated +* Component types and IDs are case-sensitive - match exactly diff --git a/docs/tutorials/table-widget.rst b/docs/tutorials/table-widget.rst new file mode 100644 index 00000000..182c5b7a --- /dev/null +++ b/docs/tutorials/table-widget.rst @@ -0,0 +1,84 @@ +============= +Table Widget +============= + +This tutorial demonstrates the Table widget in Widgetastic.core using comprehensive examples from ``testing_page.html``. You'll learn to work with HTML tables, read data from rows and cells, handle embedded widgets, and manage complex table structures with merged cells. + +.. note:: + **Prerequisites**: Basic widgets tutorial + **Test Pages Used**: ``testing/html/testing_page.html`` + + +Table Widget Fundamentals +========================= + +The :py:class:`~widgetastic.widget.Table` widget lets you work with HTML tables easily. Think of it as a way to read and interact with table data on web pages. + +**What is a Table Widget?** + +A table widget represents an HTML ```` element. It helps you: +* Read data from table rows and cells +* Fill in form inputs that are inside table cells +* Find specific rows based on their content +* Handle complex tables with merged cells + +Let's learn with three simple examples from the testing page. + +Basic Table - Reading Data +========================== + +The simplest use case: reading data from a standard table with headers. + +.. literalinclude:: ../examples/table-widget/basic_table_reading.py + :language: python + :linenos: + +**Accessing Cells in Different Ways** + +When you have a row, you can access cells in multiple ways: + +.. literalinclude:: ../examples/table-widget/accessing_cells.py + :language: python + :linenos: + +**Finding Rows by Content** + +You can search for rows that contain specific values using several methods: + +.. literalinclude:: ../examples/table-widget/finding_rows.py + :language: python + :linenos: + +Table with Embedded Widgets +============================ + +Some tables have input fields (like text inputs) inside their cells. You need to tell the Table widget which columns contain widgets. + +.. literalinclude:: ../examples/table-widget/table_with_widgets.py + :language: python + :linenos: + + +Complex Table with Merged Cells +=============================== + +Some tables have cells that span multiple rows or columns (rowspan/colspan). Widgetastic handles these automatically. + +.. literalinclude:: ../examples/table-widget/complex_table_merged_cells.py + :language: python + :linenos: + +**What Happens with Merged Cells?** + +When a cell spans multiple rows or columns, Widgetastic creates a ``TableReference`` that points to the original cell. This means: +* You can still access all cells normally +* Merged cells are handled transparently +* Widgets in merged cells work just like regular cells + +**Associative Column Filling** + +When you have a table where one column uniquely identifies each row, you can use ``assoc_column`` to fill rows by their key value: + +.. literalinclude:: ../examples/table-widget/associative_column_filling.py + :language: python + :linenos: diff --git a/docs/tutorials/version-picking.rst b/docs/tutorials/version-picking.rst new file mode 100644 index 00000000..8adfb9d3 --- /dev/null +++ b/docs/tutorials/version-picking.rst @@ -0,0 +1,48 @@ +.. _version-picking: + +=============== +Version picking +=============== + +This tutorial demonstrates version picking in Widgetastic.core, a powerful feature for handling application evolution and multiple product versions. +You'll learn to create version-aware widgets/views that adapt to different application versions automatically. + + + +Understanding Version Picking +============================= + +Version picking allows widgets and views to adapt their behavior based on the application version being tested: + +**Why Version Picking is Important** + +* **Application Evolution**: UI changes between software versions +* **Backward Compatibility**: Support testing multiple versions simultaneously +* **Maintenance Efficiency**: Single test suite for multiple product versions +* **Gradual Migration**: Smooth transitions between widget implementations + +Setting Up Version Picking Environment +====================================== + +.. literalinclude:: ../examples/version-picking/version_picking.py + :language: python + :start-after: # Example: Setting Up Version Picking Environment + :end-before: # End of Example: Setting Up Version Picking Environment + + +Basic Version Picking +===================== + +Start with simple version-dependent widget definitions: + +**Simple Version Pick Example** + +In this example, we want to select input and click button for different versions. + +* Default/fallback (v1.x): TextInput (name=fill_with_1) and Button (id=#fill_with_button_1) +* Version 2.0.0+ (v2.x): TextInput (name=fill_with_2) and Button (id=#fill_with_button_2) + +.. literalinclude:: ../examples/version-picking/version_picking.py + :language: python + :start-after: # Example: Basic Version Picking + :end-before: # End of Example: Basic Version Picking diff --git a/docs/tutorials/views.rst b/docs/tutorials/views.rst new file mode 100644 index 00000000..3d67f373 --- /dev/null +++ b/docs/tutorials/views.rst @@ -0,0 +1,323 @@ +===== +Views +===== + +Views are the cornerstone of widgetastic's architecture. They organize widgets into logical groups that represent pages, sections, or components of your application. This tutorial covers different types of views. + +.. note:: + **Prerequisites**: Complete :doc:`basic-widgets` tutorial first. + +Understanding Views +=================== + +A **View** is a container that groups related widgets together. Think of it as representing a page, dialog, or section of your web application. +A View descends from the Widget class but it is specifically designed to hold other widgets. + + +**Basic View Example** + +.. literalinclude:: ../examples/views/basic_view.py + :language: python + :linenos: + +.. note:: + **WidgetDescriptor and Lazy Creation:** + + When you define widgets on a view (e.g., ``main_title = Text(locator= ".//h1[@id='wt-core-title']")``), you're not creating + an actual widget object immediately. Instead, a ``WidgetDescriptor`` is created. The actual widget + instance is created lazily when you access it (e.g., ``view.main_title``), at which point the browser + is automatically prepended to the widget's arguments. This lazy creation mechanism ensures widgets + are only instantiated when needed and have access to the correct browser context. + + +View Hierarchy and Nesting +=========================== + +Views can contain other views, creating hierarchical structures that mirror your application's layout. This allows you to organize complex pages into manageable, reusable components. + +There are two approaches to create nested views in widgetastic: + +.. note:: + In our testing page, we have a `View Testing` section. Under this section, we have normal view, parametrized view and conditional switchable view. + Let's see how to create nested views for these sections. + +**1. Attribute Assignment** + +This approach creates standalone view classes and assigns them as attributes using ``View.nested()``: + +.. literalinclude:: ../examples/views/nested_view_attribute_assignment.py + :language: python + :linenos: + + +**2. Inner Classes** + +This approach defines view classes as inner classes with the ``@View.nested`` decorator: + +.. literalinclude:: ../examples/views/nested_view_inner_classes.py + :language: python + :linenos: + +.. note:: + **Understanding @View.nested Decorator:** + + The ``@View.nested`` decorator is **not strictly necessary** for basic functionality, but it provides + important benefits that become critical in certain scenarios: + + * **Widget Ordering**: Views remember the order in which widgets are placed on them, which is + important for fill/read operations. When you use ``View.nested()`` as an attribute assignment + (Method 1), the nested view doesn't get wrapped in a ``WidgetDescriptor``, so it won't participate + in the ordering mechanism. Using the ``@View.nested`` decorator on an inner class ensures the view + is properly wrapped in a ``WidgetDescriptor`` and maintains correct order for fill/read operations. + + * **Proper Initialization**: Guarantees correct parent-child relationships and browser context propagation + + * **Cleaner Organization**: Keeps related views grouped within the parent class, improving code readability + + **When to use @View.nested**: Use it when widget ordering matters for your fill/read operations, or + when you want to ensure proper WidgetDescriptor wrapping for consistency with the framework's design. + +**ROOT Locator Scoping** + +The ``ROOT`` attribute defines the container for a view. All widgets in that view are searched within this container, providing proper scoping: + +.. literalinclude:: ../examples/views/root_locator_scoping.py + :language: python + :linenos: + +.. _parametrized-views: + +Parametrized Views +================== +:py:class:`widgetastic.widget.ParametrizedView` are useful when you need to create a view for a repeated pattern on a page that differs only by eg. a title or an id. +For example, if you have a page with a list of items, you can use a parametrized view to create a view for each item. +You can then use the parameters eg. in locators to create a view for each item. + +**ParametrizedView Example** + +Look at our testing page, Under `Parametrized view testing` section, you can see three similar containers that follow the same pattern but with different identifiers. + +* Thing "foo": Container ``
``, input ``name="asdf_foo"``, description ``name="desc_foo"``, checkbox ``name="active_foo"`` +* Thing "bar": Container ``
``, input ``name="asdf_bar"``, description ``name="desc_bar"``, checkbox ``name="active_bar"`` +* Thing "baz": Container ``
``, input ``name="asdf_baz"``, description ``name="desc_baz"``, checkbox ``name="active_baz"`` + +Without ParametrizedView, you'd need to create separate view classes for each "thing" or write repetitive code with hardcoded locators for each variation. This becomes unmaintainable when testing multiple similar components. + +ParametrizedView solves this by letting you define a single template view that can be reused with different parameters. The ``thing_id`` parameter gets injected into locators and widget definitions at runtime, allowing one view class to handle all "thing" variations on the testing page. + +.. literalinclude:: ../examples/views/parametrized_view.py + :language: python + :linenos: + +**Nested Parametrized View Example** + +It is also possible to nest the parametrized view inside another view, parametrized or otherwise. +In this case the invocation of a nested view looks like a method call, instead of looking like a property. +The invocation supports passing the arguments both ways, positional and keyword based. + +.. literalinclude:: ../examples/views/nested_parametrized_view.py + :language: python + :linenos: + + +The parametrized views also support list-like access using square braces. +For that to work, you need the `all` classmethod defined on the view so Widgetastic would be aware of all the items. +You can access the parametrized views by member index [i] and slice [i:j]. + +It is also possible to iterate through all the occurrences of the parametrized view. +Let's assume the previous code sample is still loaded and the `thing_container_view` class has the all() defined. +In that case, the code would like like this: + +.. literalinclude:: ../examples/views/parametrized_view_iteration.py + :language: python + :linenos: + +.. note:: + This sample code would go through all the occurrences of the parametrization. Remember that the all classmethod **IS REQUIRED** in this case. + +You can also pass the :py:class:`widgetastic.utils.ParametrizedString` instance as a constructor parameter into widget instantiation on the view class. +Because it utilizes :ref:`constructor-object-collapsing`, it will resolve itself automatically. + +.. _constructor-object-collapsing: + + +Constructor Object Collapsing +============================= + +Constructor object collapsing is a powerful mechanism that allows objects to lazily resolve themselves into different objects during widget instantiation. This is used internally by several widgetastic utilities like :py:class:`widgetastic.utils.VersionPick` for :doc:`version-picking` and :py:class:`widgetastic.utils.ParametrizedString` for parametrized views. + +**How It Works** + +By using :py:class:`widgetastic.utils.ConstructorResolvable`, you can create an object that can lazily resolve itself into a different object upon widget instantiation. The key is to subclass this class and implement ``.resolve(self, parent_object)`` where ``parent_object`` is the to-be parent of the widget. + +**Why It's Useful** + +This mechanism enables: + +* **Lazy Evaluation**: Objects can decide their final form only when they have full context +* **Dynamic Resolution**: The same constructor parameter can resolve to different values based on runtime conditions +* **Version Picking**: :py:class:`widgetastic.utils.VersionPick` uses this to select appropriate widgets based on browser version +* **Parametrized Strings**: :py:class:`widgetastic.utils.ParametrizedString` uses this to inject parameters during widget construction + +.. note:: + Most users won't need to implement their own ``ConstructorResolvable`` classes, as the built-in ones (``VersionPick``, ``ParametrizedString``, ``ParametrizedLocator``) cover most use cases. + + +.. _switchable-conditional-views: + +Conditional Views +================= + +Handle dynamic UI sections that change based on application state using conditional views. + +If you have forms in your product whose parts change depending on previous selections, you might like to use the :py:class:`widgetastic.widget.ConditionalSwitchableView`. +It will allow you to represent different kinds of views under one widget name. + + +**ConditionalSwitchableView Example** + +Look at our testing page, Under `Conditional view testing` section, you can see a form with a dropdown and three different views. + +* Action type 1: Container ``
``, input ``name="action1_widget"``, select ``name="action1_options"``, checkbox ``name="action1_enabled"`` +* Action type 2: Container ``
``, input ``name="action2_widget"``, select ``name="action2_priority"``, input ``name="action2_notes"`` +* Action type 3: Container ``
``, input ``name="action3_widget"``, input ``name="action3_config"``, select ``name="action3_mode"`` + +.. literalinclude:: ../examples/views/conditional_switchable_view.py + :language: python + :linenos: + +You can see it gives you the flexibility of decision based on the values in the view. + + +**Simple Conditional Widget Registration** + +.. literalinclude:: ../examples/views/simple_conditional_widget.py + :language: python + :linenos: + + +View-Level Operations +===================== + +**Batch Operations** + +Views support batch operations like fill and read on all their widgets. + +.. literalinclude:: ../examples/views/batch_operations.py + :language: python + :linenos: + +**View Lifecycle Hooks** + +Views can optionally define ``before_fill(values)`` and ``after_fill(was_change)`` methods to +intercept the fill process: + +.. literalinclude:: ../examples/views/view_lifecycle_hooks.py + :language: python + :linenos: + + +**View State Checking** + +If we don't specify a ``ROOT`` locator, it will be considered as displayed every time. +but if we specify a ``ROOT`` locator, it will be considered as displayed only when the root locator is present on web page. + +.. literalinclude:: ../examples/views/view_state_checking.py + :language: python + :linenos: + +.. note:: + View ``is_displayed`` property is important to know when you are using views to navigate between pages. + So it recommended to specify a ``ROOT`` locator for all views. If you don't want to specify a ``ROOT`` locator, + then tried to add custom ``is_displayed`` property to the view. + + +Best Practices for Views +========================= + +When designing views in widgetastic, following best practices will help you create maintainable, readable, and robust automation code. Here are some key guidelines: + +- **Use Descriptive Names**: Name your view classes according to their purpose or the section of the application they represent. This makes your code self-explanatory and easier to navigate. + + .. code-block:: python + + # Good: Clear purpose + class LoginFormView(View): + pass + + class UserProfileSettingsView(View): + pass + + # Avoid: Generic names + class View1(View): + pass + +- **Group Related Widgets**: Organize widgets within a view so that each view contains only widgets relevant to a specific page, dialog, or component. Avoid mixing unrelated widgets in a single view. + + .. code-block:: python + + # Group related functionality + class SearchView(View): + search_input = TextInput(id="search") + search_button = Button(id="search-btn") + results_table = Table(id="results") + + # Don't mix unrelated widgets + class BadView(View): + login_field = TextInput(id="login") # Login functionality + checkout_btn = Button(id="checkout") # Shopping functionality + settings_link = Text("a#settings") # Settings functionality + +- **Leverage ROOT Locators**: Always define a ``ROOT`` locator for your views to scope widget searches to the correct section of the page. This prevents accidental matches and improves reliability. + + .. code-block:: python + + # Scope widgets to specific sections + class SidebarView(View): + ROOT = "#sidebar" + + menu_item1 = Text("a[href='/dashboard']") + menu_item2 = Text("a[href='/profile']") + +- **Prefer Nested Views for Complex Pages**: For pages with multiple sections or repeated patterns, use nested views or parametrized views to mirror the application's structure. This keeps your code modular and reusable. + + .. code-block:: python + + # Nested views + class UserProfilePage(View): + ROOT = "#user-profile" + @View.nested + class personal_info(View): + ROOT = "#personal-section" + first_name = TextInput("#first_name") + last_name = TextInput("#last_name") + + @View.nested + class preferences(View): + ROOT = "#preferences-section" + theme = Select("#theme") + language = Select("#language") + +- **Customize is_displayed When Needed**: If a view cannot be reliably detected by a single locator, override the ``is_displayed`` property to implement custom logic using one or more widgets. + + .. code-block:: python + + class CustomView(View): + ROOT = "#custom-view" + custom_widget = TextInput("#custom-widget") + + @property + def is_displayed(self): + return self.custom_widget.is_displayed + + +Summary +======= + +Views are essential for organizing and structuring your automation code: + +* **Basic Views**: Container for related widgets +* **Nested Views**: Hierarchical page structures +* **Parametrized Views**: Handle repeated UI patterns +* **Conditional Views**: Adapt to dynamic content +* **View Operations**: Batch read/fill operations diff --git a/docs/tutorials/window-management.rst b/docs/tutorials/window-management.rst new file mode 100644 index 00000000..993297c3 --- /dev/null +++ b/docs/tutorials/window-management.rst @@ -0,0 +1,77 @@ +================== +Window Management +================== + +This tutorial demonstrates window and popup management in Widgetastic.core using the framework's test pages. You'll learn to handle multiple browser windows, tabs, and popups using ``popup_test_page.html`` and ``external_test_page.html``. + +.. note:: + **Prerequisites**: Basic widgets tutorial + **Test Pages Used**: ``popup_test_page.html``, ``external_test_page.html``, ``testing_page.html`` + +Learning Objectives +=================== + +By completing this tutorial, you will: + +* ✅ Understand the WindowManager system +* ✅ Handle popup windows and new tabs +* ✅ Switch between multiple browser contexts +* ✅ Manage browser lifecycle and cleanup +* ✅ Handle cross-page automation workflows + +Setting Up Window Management +============================ + +.. literalinclude:: ../examples/window-management/basic_windows_management.py + :language: python + :start-after: # Setup: Basic Windows Management + :end-before: # End of Setup + +Basic Window Operations +======================= + +The WindowManager provides methods for creating and managing multiple browser windows: + +**Creating New Windows** + +.. literalinclude:: ../examples/window-management/basic_windows_management.py + :language: python + :start-after: # Example: Creating New Windows + :end-before: # End of Example: Creating New Windows + +**Switching Between Windows** + +.. literalinclude:: ../examples/window-management/basic_windows_management.py + :language: python + :start-after: # Example: Switching Between Windows + :end-before: # End of Example: Switching Between Windows + + +Handling Popups and New Tabs +============================ + +Manage popup windows and new tabs created by JavaScript. Widgetastic provides two approaches: +reliable detection using ``expect_new_page()`` context manager, and automatic detection via +``all_browsers`` property. + +**Reliable Popup Detection with `expect_new_page()`** + +The recommended approach for handling popups and new tabs is using the ``expect_new_page()`` +context manager. This method uses Playwright's native ``expect_page()`` to reliably wait for +and capture new pages opened by JavaScript or links. + +.. literalinclude:: ../examples/window-management/handling_popups.py + :language: python + :start-after: # Example: Handling Popups and New Tabs + :end-before: # End of Example: Handling Popups and New Tabs + + +**Working with `all_browsers` Property** + +The ``all_browsers`` property provides automatic cleanup and best-effort detection of new pages. +It's useful for listing all active browsers, but for reliable popup detection, use ``expect_new_page()``. + +.. literalinclude:: ../examples/window-management/handling_popups.py + :language: python + :start-after: # Example: Working with all_browsers Property + :end-before: # End of Example: Working with all_browsers Property diff --git a/pyproject.toml b/pyproject.toml index d39d5902..a79d06c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,11 @@ dev = [ "pytest-xdist", "pytest-cov", ] -docs = ["sphinx"] +docs = [ + "sphinx", + "sphinx-autobuild", + "sphinx_rtd_theme", +] test = [ "pytest", "pytest-xdist", diff --git a/pytest.ini b/pytest.ini index 64c673f4..b0d206be 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,5 @@ [pytest] +testpaths = testing addopts = -v --cov widgetastic diff --git a/testing/html/testing_page.html b/testing/html/testing_page.html index 4252b503..03599d0a 100644 --- a/testing/html/testing_page.html +++ b/testing/html/testing_page.html @@ -279,7 +279,7 @@

Widgetastic.Core - Testing Page

Interactive demonstrations for widget automation and testing

-
+
Element Visibility & State Testing
@@ -335,7 +335,7 @@
-
+
Input Widgets & Controls
@@ -363,7 +363,7 @@
- +
@@ -1494,6 +1494,276 @@

bartest

+ +
+
View Testing
+
+
+
Normal View Testing
+
Basic view structure with standard form elements for normal view testing
+
+

Normal View Form

+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ class NormalView(View):
+     name = TextInput(id="normal_name")
+     email = TextInput(id="normal_email")
+     terms = Checkbox(id="normal_terms")
+     submit = Text(id="normal_submit") +
+
+ +
+
Parametrized View Testing
+
Elements with parametrized IDs and names for ParametrizedView testing
+ + +
+
+

Thing: foo

+
+ + +
+
+ + +
+
+ +
+
+ +
+

Thing: bar

+
+ + +
+
+ + +
+
+ +
+
+ +
+

Thing: baz

+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ class MyParametrizedView(ParametrizedView):
+     PARAMETERS = ('thing_id', )
+     ROOT = ParametrizedLocator('.//div[@id={thing_id|quote}]')
+     widget = TextInput(name=ParametrizedString('asdf_{thing_id}'))
+     description = TextInput(name=ParametrizedString('desc_{thing_id}'))
+     active = Checkbox(name=ParametrizedString('active_{thing_id}'))

+ + # Usage:
+ view = MyParametrizedView(browser, additional_context={'thing_id': 'foo'})
+ # or nested: view.this_is_parametrized('foo').widget.fill('test') +
+
+ +
+
Conditional Switchable View Testing
+
Forms that switch based on select dropdown value for ConditionalSwitchableView testing
+ +
+
+ + +
+ +
+ + +
+ + + + + + + + + + + +
+
Simple Conditional Widget
+
+ + +
+ +
+
+ +
+ class SomeForm(View):
+     foo = TextInput(name='foo_value')
+     action_type = Select(name='action_type')
+     action_form = ConditionalSwitchableView(reference='action_type')

+ +     @action_form.register('Action type 1', default=True)
+     class ActionType1Form(View):
+         widget = TextInput(name='action1_widget')
+         options = Select(name='action1_options')

+ +     @action_form.register(lambda action_type: action_type == 'Action type 2')
+     class ActionType2Form(View):
+         widget = TextInput(name='action2_widget')
+         priority = Select(name='action2_priority')

+ +     @action_form.register(
+         lambda action_type, foo: action_type == 'Action type 2' and foo == '2')
+     class ActionType2Form(View):
+         widget = TextInput(name='action2_widget')

+ + # Simple widget registration:
+ switched_widget = ConditionalSwitchableView(reference='bar')
+ switched_widget.register('Action type 1', default=True, widget=TextInput(name='simple_widget')) +
+
+
+
+ + +
IFrame & Nested Content