diff --git a/Makefile b/Makefile index a8e6b5f..96557a7 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,6 @@ export GECKODRIVER_LOG = $(shell pwd)/results/geckodriver.log BLACK_CHECK = black -l 90 --check --diff . BLACK_FIX = black -l 90 . -MINIMUM_COVERAGE = 95 -FOXPUPPET_TESTS = pytest -vvv --driver Firefox --cov --cov-fail-under=$(MINIMUM_COVERAGE) --html results/report.html check: install_poetry lint test @@ -18,7 +16,7 @@ install_poetry: curl -sSL https://install.python-poetry.org | python3 - test: install_dependencies - poetry run $(FOXPUPPET_TESTS) + poetry run pytest lint: install_dependencies poetry run $(BLACK_CHECK) diff --git a/foxpuppet/windows/browser/navbar.py b/foxpuppet/windows/browser/navbar.py index 09fcb93..50dfeb2 100644 --- a/foxpuppet/windows/browser/navbar.py +++ b/foxpuppet/windows/browser/navbar.py @@ -5,7 +5,7 @@ from selenium.webdriver.common.by import By from foxpuppet.region import Region -from foxpuppet.windows.base import BaseWindow +from foxpuppet.windows.browser.urlbar import UrlBar class NavBar(Region): @@ -39,3 +39,8 @@ def is_tracking_shield_displayed(self) -> bool: return el.get_attribute("active") is not None el = self.root.find_element(By.ID, "tracking-protection-icon") return bool(el.get_attribute("state")) + + @property + def url_bar(self) -> UrlBar: + """Returns an instance of the UrlBar class.""" + return UrlBar(self.window, self.root) diff --git a/foxpuppet/windows/browser/panel_ui/__init__.py b/foxpuppet/windows/browser/panel_ui/__init__.py new file mode 100644 index 0000000..499934f --- /dev/null +++ b/foxpuppet/windows/browser/panel_ui/__init__.py @@ -0,0 +1,4 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +"""Contains the Panel UI API and supporting files.""" diff --git a/foxpuppet/windows/browser/panel_ui/panel_ui.py b/foxpuppet/windows/browser/panel_ui/panel_ui.py new file mode 100644 index 0000000..061b963 --- /dev/null +++ b/foxpuppet/windows/browser/panel_ui/panel_ui.py @@ -0,0 +1,201 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +"""Contains classes for handling Firefox Panel UI (Hamburger menu).""" + +from selenium.webdriver.common.by import By + +from foxpuppet.windows.browser.navbar import NavBar +from selenium.webdriver.remote.webelement import WebElement +from typing import Type, Any, TYPE_CHECKING, Optional +from selenium.webdriver.support import expected_conditions as EC + + +class PanelUI(NavBar): + """Handles interaction with Panel UI.""" + + if TYPE_CHECKING: + from foxpuppet.windows import BrowserWindow + + @staticmethod + def create( + window: Optional["BrowserWindow"], root: WebElement + ) -> Type["PanelUI"] | Any: + """Create a Panel UI object. + + Args: + window (:py:class:`BrowserWindow`): Window object this region + appears in. + root + (:py:class:`~selenium.webdriver.remote.webelement.WebElement`): + WebDriver element object that serves as the root for the + Panel UI. + + Returns: + :py:class:`PanelUI`: Firefox Panel UI. + + """ + panel_items: dict = {} + _id: str | bool | WebElement | dict = root.get_property("id") + + panel_items.update(PANEL_ITEMS) + return panel_items.get(_id, PanelUI)(window, root) + + @property + def is_update_available(self) -> bool: + """ + Checks if the Panel UI button indicates a pending Firefox update. + + Returns: + bool: True if an update notification (barge) is present, False otherwise. + """ + with self.selenium.context(self.selenium.CONTEXT_CHROME): + update_status = self.selenium.find_element( + *PanelUILocators.PANEL_UI_BUTTON + ).get_attribute("barged") + return update_status == "true" + + def open_panel_menu(self) -> None: + """ + Opens the Panel UI menu. + """ + with self.selenium.context(self.selenium.CONTEXT_CHROME): + self.selenium.find_element(*PanelUILocators.PANEL_UI_BUTTON).click() + self.wait.until( + EC.presence_of_element_located(*PanelUILocators.PANEL_POPUP), + message="Panel UI menu did not open", + ) + + def open_new_tab(self) -> None: + """ + Opens a new tab using the Panel UI menu. + """ + initial_handles = set(self.selenium.window_handles) + self.open_panel_menu() + with self.selenium.context(self.selenium.CONTEXT_CHROME): + self.selenium.find_element(*PanelUILocators.NEW_TAB).click() + self.wait.until( + lambda _: set(self.selenium.window_handles) - initial_handles, + message="New Tab did not open", + ) + new_tab = (set(self.selenium.window_handles) - initial_handles).pop() + self.selenium.switch_to.window(new_tab) + + def open_new_window(self) -> None: + """ + Opens a new window using the Panel UI menu. + """ + initial_handles = set(self.selenium.window_handles) + self.open_panel_menu() + with self.selenium.context(self.selenium.CONTEXT_CHROME): + self.selenium.find_element(*PanelUILocators.NEW_WINDOW).click() + self.wait.until( + lambda _: set(self.selenium.window_handles) - initial_handles, + message="New window did not open", + ) + new_window = (set(self.selenium.window_handles) - initial_handles).pop() + self.selenium.switch_to.window(new_window) + + def open_private_window(self) -> None: + """ + Opens a new window in private browsing mode using the Panel UI menu. + """ + initial_handles = set(self.selenium.window_handles) + self.open_panel_menu() + with self.selenium.context(self.selenium.CONTEXT_CHROME): + self.selenium.find_element(*PanelUILocators.PRIVATE_WINDOW).click() + self.wait.until( + lambda _: set(self.selenium.window_handles) - initial_handles, + message="Private window did not open", + ) + from foxpuppet.windows.browser.window import BrowserWindow + + new_private_window = self.selenium.window_handles[-1] + try: + private_window = BrowserWindow( + self.selenium, new_private_window + ).is_private + if private_window: + self.selenium.switch_to.window(new_private_window) + except Exception as e: + raise Exception(f"The new window is not private: {str(e)}") + + def open_history_menu(self) -> None: + """ + Opens the History in Panel UI Menu + """ + with self.selenium.context(self.selenium.CONTEXT_CHROME): + self.selenium.find_element(*PanelUILocators.HISTORY).click() + self.wait.until( + lambda _: self.selenium.find_element( + *PanelUILocators.PANEL_HISTORY + ).is_displayed(), + message="History menu did not open", + ) + + +class History(PanelUI): + def history_items(self) -> list[WebElement]: + """ + Retrieves all history items from the Panel UI history menu. + + Returns: + list[WebElement]: List of WebElement objects representing history items. + Returns an empty list if no history items are found. + """ + with self.selenium.context(self.selenium.CONTEXT_CHROME): + history_items = self.selenium.find_elements( + *PanelUILocators.RECENT_HISTORY_ITEMS + ) + return history_items + + def clear_history(self): + """ + Clears the browsing history. + """ + with self.selenium.context(self.selenium.CONTEXT_CHROME): + self.selenium.find_element(*PanelUILocators.CLEAR_RECENT_HISTORY).click() + self.selenium.switch_to.frame( + self.selenium.find_element(*PanelUILocators.HISTORY_IFRAME) + ) + with self.selenium.context(self.selenium.CONTEXT_CONTENT): + self.selenium.find_element(*PanelUILocators.DROPDOWN_HISTORY).click() + self.selenium.find_element( + *PanelUILocators.CLEAR_HISTORY_EVERYTHING + ).click() + self.selenium.execute_script( + """ + const shadowHost = arguments[0]; + const shadowRoot = shadowHost.shadowRoot; + const clearRecentHistoryButton = shadowRoot.querySelector('button[dlgtype="accept"]'); + clearRecentHistoryButton.click(); + """, + self.selenium.find_element(*PanelUILocators.HISTORY_DIALOG_BUTTON), + ) + self.selenium.switch_to.default_content() + + +class PanelUILocators: + CLEAR_HISTORY_EVERYTHING = (By.CSS_SELECTOR, "menuitem[value='0']") + CLEAR_RECENT_HISTORY = (By.ID, "appMenuClearRecentHistory") + CLEAR_RECENT_HISTORY_BUTTON = (By.CSS_SELECTOR, "button[dlgtype='accept']") + DROPDOWN_HISTORY = (By.ID, "sanitizeDurationChoice") + HISTORY = (By.ID, "appMenu-history-button") + HISTORY_DIALOG_BUTTON = (By.CSS_SELECTOR, "dialog[defaultButton='accept']") + HISTORY_IFRAME = (By.CSS_SELECTOR, "browser.dialogFrame") + NEW_TAB = (By.ID, "appMenu-new-tab-button2") + NEW_WINDOW = (By.ID, "appMenu-new-window-button2") + PANEL_HISTORY = (By.ID, "PanelUI-history") + PANEL_POPUP = ((By.ID, "appMenu-popup"),) + PANEL_UI_BUTTON = (By.ID, "PanelUI-menu-button") + PRIVATE_WINDOW = (By.ID, "appMenu-new-private-window-button2") + RECENT_HISTORY_ITEMS = ( + By.CSS_SELECTOR, + "#appMenu_historyMenu toolbarbutton.subviewbutton", + ) + + +PANEL_ITEMS = { + "PanelUI-menu-button": PanelUI, + "appMenu-history-button": History, +} diff --git a/foxpuppet/windows/browser/urlbar.py b/foxpuppet/windows/browser/urlbar.py new file mode 100644 index 0000000..0a4a331 --- /dev/null +++ b/foxpuppet/windows/browser/urlbar.py @@ -0,0 +1,49 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +"""Creates Navbar object to interact with Firefox URL Bar.""" + +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.support.wait import WebDriverWait +from foxpuppet.region import Region + + +class UrlBar(Region): + def suggestions(self, url: str) -> list[str]: + """ + Get all URL suggestions shown in the URL bar. + + Args: + url (str): The URL to type into the URL bar + + Returns: + list[str]: List of suggested URLs that appear in the URL bar + """ + with self.selenium.context(self.selenium.CONTEXT_CHROME): + url_bar = self.selenium.find_element(*URLBarLocators.INPUT_FIELD) + url_bar.clear() + url_bar.send_keys(url) + + self.wait.until( + lambda _: self.selenium.find_elements(*URLBarLocators.SEARCH_RESULTS) + ) + + search_results = self.selenium.find_elements( + *URLBarLocators.SEARCH_RESULT_ITEMS + ) + + suggested_urls = [ + result.find_element(*URLBarLocators.SEARCH_RESULT_ITEM).text + for result in search_results + if result.find_element(*URLBarLocators.SEARCH_RESULT_ITEM).text + ] + + return suggested_urls + + +class URLBarLocators: + INPUT_FIELD = (By.ID, "urlbar-input") + SEARCH_RESULTS = (By.ID, "urlbar-results") + SEARCH_RESULT_ITEM = (By.CSS_SELECTOR, "span.urlbarView-url") + SEARCH_RESULT_ITEMS = (By.CSS_SELECTOR, "div.urlbarView-row[role='presentation']") diff --git a/foxpuppet/windows/browser/window.py b/foxpuppet/windows/browser/window.py index 34cbc62..0e5e20f 100644 --- a/foxpuppet/windows/browser/window.py +++ b/foxpuppet/windows/browser/window.py @@ -11,25 +11,32 @@ from foxpuppet.windows.browser.navbar import NavBar from foxpuppet.windows.browser.notifications import BaseNotification from foxpuppet.windows.browser.bookmarks.bookmark import Bookmark +from foxpuppet.windows.browser.panel_ui.panel_ui import PanelUI from selenium.webdriver.remote.webelement import WebElement from typing import Any, Optional, Union, TypeVar, Type T = TypeVar("T", bound="BaseNotification") +P = TypeVar("P", bound="PanelUI") class BrowserWindow(BaseWindow): """Representation of a browser window.""" - _bookmark_locator = (By.ID, "main-window") # editBookmarkPanelTemplate + _bookmark_locator = (By.ID, "main-window") _file_menu_button_locator = (By.ID, "file-menu") _file_menu_private_window_locator = (By.ID, "menu_newPrivateWindow") _file_menu_new_window_button_locator = (By.ID, "menu_newNavigator") _nav_bar_locator = (By.ID, "nav-bar") _notification_locator = (By.CSS_SELECTOR, "#notification-popup popupnotification") + _panel_ui_locator = (By.ID, "PanelUI-menu-button") _app_menu_notification_locator = ( By.CSS_SELECTOR, "#appMenu-notification-popup popupnotification", ) + _app_menu_panel_ui_locator = ( + By.CSS_SELECTOR, + "#appMenu-mainView .panel-subview-body toolbarbutton", + ) _tab_browser_locator = (By.ID, "tabbrowser-tabs") @property @@ -81,6 +88,23 @@ def bookmark(self) -> Bookmark: root = self.selenium.find_element(*self._bookmark_locator) return Bookmark.create(self, root) + @property + def panel(self) -> PanelUI | Any: + panel_root = None + with self.selenium.context(self.selenium.CONTEXT_CHROME): + root = self.selenium.find_element(*self._panel_ui_locator) + panel_root = PanelUI.create(self, root) + + panel_items = self.selenium.find_elements(*self._app_menu_panel_ui_locator) + for item in panel_items: + _id = item.get_property("id") + from foxpuppet.windows.browser.panel_ui.panel_ui import PANEL_ITEMS + + if _id in PANEL_ITEMS and item.is_displayed(): + panel_root = PANEL_ITEMS[_id].create(self, item) # type: ignore + + return panel_root + def wait_for_notification( self, notification_class: Optional[Type[T]] = BaseNotification, # type: ignore @@ -129,6 +153,36 @@ def wait_for_bookmark(self) -> Bookmark: ) return self.bookmark + def wait_for_panel( + self, panel_ui_class: Optional[Type[P]] = PanelUI # type: ignore + ) -> Optional[P]: + """Wait for the specified PanelUI item to be displayed. + + Args: + panel_ui_class (:py:class:`PanelUI`, optional): + The PanelUI subclass to wait for. If `None` is specified, it + will wait for any panel UI to be displayed. Defaults to `PanelUI`. + + Returns: + Optional[:py:class:`PanelUI`]: The displayed PanelUI or `None` if not found. + """ + if panel_ui_class: + if panel_ui_class is PanelUI: + message = "No panel UI was shown." + else: + message = f"{panel_ui_class.__name__} was not shown." + self.wait.until( + lambda _: isinstance(self.panel, panel_ui_class), + message=message, + ) + return self.panel # type: ignore + else: + self.wait.until( + lambda _: self.panel is None, + message="Unexpected panel UI was shown.", + ) + return None + @property def is_private(self) -> bool | Any: """Property that checks if the specified window is private or not. diff --git a/pyproject.toml b/pyproject.toml index b9047e3..791bfd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ flake8-isort = "^6.1.1" pytest = "^8.3.3" pytest-cov = "^5.0.0" pytest-selenium = "^4.1.0" +pytest-repeat = "^0.9.3" black = "^24.10.0" mypy = "^1.15.0" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..aa8baf4 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = -vvv --driver Firefox --cov --cov-fail-under=95 --html=results/report.html --self-contained-html +testpaths = tests diff --git a/tests/test_notifications.py b/tests/test_notifications.py index a38ac9d..5e84c10 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -249,6 +249,7 @@ def test_close_failed_notification( browser.wait_for_notification(None) +@pytest.mark.repeat(100) @pytest.mark.parametrize( "firefox_options", [{"page_load_strategy_none": True}], indirect=True ) diff --git a/tests/test_panel_ui.py b/tests/test_panel_ui.py new file mode 100644 index 0000000..63d1e57 --- /dev/null +++ b/tests/test_panel_ui.py @@ -0,0 +1,161 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +"""Tests for Panel UI.""" + +import pytest +import time +from selenium.webdriver.remote.webdriver import WebDriver +from foxpuppet.windows import BrowserWindow +from foxpuppet.windows.browser.panel_ui.panel_ui import PanelUI, History + + +@pytest.fixture +def links() -> list: + links = [ + "https://www.mozilla.org/en-US/?v=a", + "https://www.youtube.com", + "https://www.facebook.com/", + ] + return links + + +@pytest.fixture +def panel_ui(browser: BrowserWindow) -> PanelUI | None: + """Create Panel UI object. + + Args: + browser: BrowserWindow instance + + Returns: + :py:class:`PanelUI`: FoxPuppet Panel UI object + """ + return browser.wait_for_panel(PanelUI) + + +@pytest.fixture +def browser_history(panel_ui: PanelUI, browser: BrowserWindow) -> History | None: + """Create Panel UI object. + + Args: + browser: BrowserWindow instance + + Returns: + :py:class:`PanelUI`: FoxPuppet Panel UI object + """ + panel_ui.open_panel_menu() + return browser.wait_for_panel(History) + + +def test_open_new_tab(panel_ui: PanelUI, selenium: WebDriver) -> None: + """Test opening a new tab using the Panel UI.""" + panel_ui.open_new_tab() + assert len(selenium.window_handles) == 2 + + +def test_open_new_window(panel_ui: PanelUI, selenium: WebDriver) -> None: + """Test opening a new window using the Panel UI.""" + panel_ui.open_new_window() + assert len(selenium.window_handles) == 2 + + +def test_open_new_private_window(panel_ui: PanelUI, selenium: WebDriver) -> None: + """Test opening a new window using the Panel UI.""" + panel_ui.open_private_window() + assert len(selenium.window_handles) == 2 + + +def test_url_is_present_in_history(browser_history: History, selenium: WebDriver) -> None: + """Test that visited URL appears in browser history.""" + url = "https://www.mozilla.org/en-US/?v=a" + selenium.get(url) + browser_history.open_history_menu() + history_items = browser_history.history_items() + with selenium.context(selenium.CONTEXT_CHROME): + is_present = False + for item in history_items: + image_attr = item.get_attribute("image") + if image_attr is not None: + is_present = url in image_attr + if is_present: + break + assert is_present + + +def test_verify_url_bar_suggestions(panel_ui: PanelUI, selenium: WebDriver) -> None: + """Test that a link appears in url bar suggestions.""" + test_url = "https://www.mozilla.org/en-US/?v=a" + selenium.get(test_url) + all_suggestions = panel_ui.url_bar.suggestions(test_url) + matching_suggestions = [ + suggestion + for suggestion in all_suggestions + if suggestion in test_url and len(suggestion) != 0 + ] + assert len(matching_suggestions) == 1 + + +def test_verify_links_open_in_new_tab_from_history( + panel_ui: PanelUI, browser_history: History, selenium: WebDriver, links: list +) -> None: + """Test that links opened in new tab are present in browser history.""" + panel_ui.open_new_tab() + for link in links: + selenium.get(link) + panel_ui.open_panel_menu() + panel_ui.open_history_menu() + history_items = browser_history.history_items() + with selenium.context(selenium.CONTEXT_CHROME): + found_urls = [] + for link in links: + for item in history_items: + image_attr = item.get_attribute("image") + if image_attr is not None and link in image_attr: + found_urls.append(link) + break + assert len(found_urls) == 3 + + +def test_verify_links_open_in_new_window_from_history( + panel_ui: PanelUI, browser_history: History, selenium: WebDriver, links: list +) -> None: + """Test that links opened in new window are present in browser history.""" + panel_ui.open_new_window() + time.sleep(3) + for link in links: + selenium.get(link) + panel_ui.open_panel_menu() + panel_ui.open_history_menu() + history_items = browser_history.history_items() + with selenium.context(selenium.CONTEXT_CHROME): + found_urls = [] + for link in links: + for item in history_items: + image_attr = item.get_attribute("image") + if image_attr is not None and link in image_attr: + found_urls.append(link) + break + assert len(found_urls) == 3 + + +def test_clear_recent_history( + panel_ui: PanelUI, browser_history: History, selenium: WebDriver +) -> None: + """Test clearing browser history removes visited URLs.""" + url = "https://www.mozilla.org/en-US/?v=a" + selenium.get(url) + panel_ui.open_history_menu() + browser_history.clear_history() + time.sleep(1) + panel_ui.open_panel_menu() + panel_ui.open_history_menu() + history_items = browser_history.history_items() + with selenium.context(selenium.CONTEXT_CHROME): + is_present = False + for item in history_items: + image_attr = item.get_attribute("image") + if image_attr is not None: + is_present = url in image_attr + if is_present: + break + assert not is_present