Source code for univention.testing.browser.lib

#!/usr/bin/python3
# SPDX-FileCopyrightText: 2023-2025 Univention GmbH
# SPDX-License-Identifier: AGPL-3.0-only


import locale
import logging
import re
import subprocess
import time
import urllib.parse
from enum import Enum

from playwright.sync_api import Page, expect

from univention.config_registry import ucr
from univention.lib.i18n import Translation


logger = logging.getLogger(__name__)

SEC = 1000
MIN = 60 * 1000

translator = Translation('ucs-test-framework')
_ = translator.translate


[docs] class UCSLanguage(Enum): EN_US = 1 DE_DE = 2 def __str__(self) -> str: if self == UCSLanguage.EN_US: return 'en-US' elif self == UCSLanguage.DE_DE: return 'de-DE' return ''
[docs] def get_name(self) -> str: if self == UCSLanguage.EN_US: return 'English' elif self == UCSLanguage.DE_DE: return 'Deutsch' return ''
[docs] class Interactions: def __init__(self, tester: 'UMCBrowserTest') -> None: self.tester: UMCBrowserTest = tester self.page: Page = tester.page
[docs] def check_second_checkbox_in_grid(self): """ This function checks the second checkbox in a grid Note: Prefer to use `check_checkbox_in_grid_by_name` when possible """ checkbox = self.page.get_by_role('checkbox') expect(checkbox).to_have_count(2) checkbox.last.check()
[docs] def check_checkbox_in_grid_by_name(self, name: str, nth: int | None = None): """ This function checks a checkbox in a <tr> where the given `name` appears :param name: the name to search for :param nth: controls what to do when there are multiple entries with `name` found. If none the function will throw an exception if `int` the function will act on the nth occurrence of the text """ row = self.page.locator(f"tr:has-text('{name}')") if nth is not None: row = row.nth(nth) expect(row).to_be_visible(timeout=30 * 1000) checkbox = row.get_by_role('checkbox') expect(checkbox).to_be_visible(timeout=30 * 1000) checkbox.click()
[docs] def open_modules(self, modules: list[str], limit: int | None = None, start_at: int | None = None): """ This method will open all modules given by `modules`. It does this by searching for the module in the UMC, clicking on it and then clicking the close button :param limit: optionally only open the first `limit` modules :param start_at: starts opening """ for module in modules[start_at:limit]: logger.info('Opening module %s', module) self.open_and_close_module(module) logger.info('Closed module %s', module) time.sleep(1)
[docs] def open_all_modules(self, limit: int | None = None, start_at: int | None = None): """ This method opens all modules that can be found in the UMC when searching for '*' :param limit: optionally only open the first `limit` modules :param start_at: starts opening """ modules = self.get_available_modules() logger.info('Found %d modules', len(modules)) self.open_modules(modules, limit=limit, start_at=start_at)
[docs] def open_and_close_module(self, module_name: str, wait_for_network_idle: bool = False): self.open_module(_(module_name), wait_for_network_idle=wait_for_network_idle) # give it a bit more time, e.g. system diagnostic runs longer self.page.get_by_role('button', name=_('Close')).click(timeout=60000)
[docs] def get_available_modules(self) -> list[str]: self.page.locator('.umcModuleSearchToggleButton').click() logger.info('Clicked the search button') self.page.locator('.umcModuleSearch input.dijitInputInner').press_sequentially('*') modules = self.page.locator('.umcGalleryName').all() result = [module.get_attribute('title') or module.inner_text() for module in modules] self.page.locator('.umcModuleSearchToggleButton').click() return result
[docs] def open_module(self, module_name: str, expect_response: re.Pattern[str] | str | None = None, wait_for_network_idle: bool = False): """ This method opens a module from anywhere where the module search bar in the UCM is visible :param module_name: the name of the module to be opened :param expect_response: wait for a specific response to be completed before returning :param wait_for_network_idle: wait for no network connections for at least 500ms. Mututally exclusive with expect_response. """ self.page.locator('.umcModuleSearchToggleButton').click() logger.info('Clicked the search button') self.page.locator('.umcModuleSearch input.dijitInputInner').press_sequentially(module_name) module_by_title_attrib_locator = self.page.locator(f".umcGalleryName[title='{module_name}']") exact_module_name = re.compile(f'^{re.escape(module_name)}$') logger.info('Trying to find button to open module %s', module_name) module_locator = self.page.locator('.umcGalleryName', has_text=exact_module_name) expect(module_locator.or_(module_by_title_attrib_locator)).to_be_visible() if module_by_title_attrib_locator.is_visible(): clickable_module_locator = module_by_title_attrib_locator else: clickable_module_locator = module_locator if expect_response is not None: with self.page.expect_response(expect_response): clickable_module_locator.click() else: clickable_module_locator.click() logger.info('Clicked the module button') if expect_response is None and wait_for_network_idle: logger.info('Waiting for network to be idle...') self.page.wait_for_load_state("networkidle", timeout=30 * 3 * 1000) if module_name == 'App Center': from univention.testing.browser.appcenter import AppCenter, wait_for_final_query app_center = AppCenter(self.tester) with self.page.expect_response(lambda request: wait_for_final_query(request), timeout=2 * MIN): # noqa: PLW0108 app_center.handle_first_open_dialog()
[docs] def fill_combobox(self, name: str, option: str): # combobox_filter = self.page.locator(f"input[name='{name}'][type='hidden']") combobox_filter = self.page.get_by_label(name) self.page.get_by_role('combobox').filter(has=combobox_filter).locator('.ucsSimpleIconButton').click() self.page.get_by_role('option', name=option).click()
[docs] class UMCBrowserTest(Interactions): """ This is the base class for all Playwright browser tests. It defines common operations and methods that are useful to all other library modules. Note: As a general rule of this library, unless otherwise noted, the caller is responsible for the translation of a string :param page: The Playwright Page object :param lang: The language to use for UCS """ lang: UCSLanguage def __init__(self, page: Page, lang: UCSLanguage = UCSLanguage.EN_US): self.page: Page = page self.set_language(lang) Interactions.__init__(self, self)
[docs] def set_language(self, lang: UCSLanguage): logger.info('Setting language to %s', lang) self.lang = lang self.__set_lang(str(lang)) translator.set_language(str(lang).replace('-', '_')) locale.setlocale(locale.LC_ALL, f'{str(lang).replace("-", "_")}.UTF-8')
def __set_lang(self, lang: str): self.page.context.clear_cookies() cookies = [ { 'name': 'UMCLang', 'value': lang, 'url': f'{self.base_url}/univention', }, ] role = ucr.get('server/role') # if we are not on the master we also need to set the language cookie for the master if role != 'domaincontroller_master': cookies.append( { 'name': 'UMCLang', 'value': lang, 'url': f"https://{ucr.get('ldap/master')}/univention", }, ) self.page.context.add_cookies(cookies) @property def base_url(self) -> str: """:return: the base url in the form of https://{hostname}.{domainname}""" return f"https://{ucr.get('hostname')}.{self.domainname}" @property def ldap_base(self) -> str: return ucr['ldap/base'] @property def domainname(self) -> str: return ucr['domainname']
[docs] def check_for_no_module_available_popup(self): popup = self.page.get_by_role('dialog').get_by_text('There is no module available for the authenticated user') expect(popup).to_be_visible(timeout=30 * SEC) button = self.page.get_by_role('button', name=_('Ok')) expect(button).to_be_visible() button.click()
[docs] def login( self, username: str = 'Administrator', password: str = ucr.get('tests/domainadmin/pwd', 'univention'), location: str = '/univention/management', check_for_no_module_available_popup: bool = False, login_should_fail: bool = False, do_navigation: bool = True, expect_password_change_prompt: bool = False, wait_until: str = 'networkidle', ): """ Navigates to {base_url}/univention/login?location={location} and logs in with the given credentials :param username: The username of the user to be logged in :param password: The password of the user to be logged in :param location: the location to navigate to after a successful login. This value is being URL encoded :param check_for_no_module_available_popup: If set to true check for a "There is no module available for the..." popup after login :param login_should_fail: Returns after failure to log in with wrong credentials :param do_navigation: Wether to navigate to the login page :param expect_password_change_prompt: Expect a password change prompt to be visible after clicking the Login button """ logger.info("Starting login to '%s' ", location) page = self.page if do_navigation: location = urllib.parse.quote(location) query_parameter = f'?location={location}' if location else '' page.goto(f'{self.base_url}/univention/login{query_parameter}') page.get_by_label(_('Username'), exact=True).fill(username) page.get_by_label(_('Password'), exact=True).fill(password) login_button = page.get_by_role('button', name=_('Login')) if expect_password_change_prompt: logger.info('Expecting the password change prompt. Only clicking button') login_button.click() return if login_should_fail: login_button.click() expect(page.get_by_text(_('The authentication has failed, please login again.'))).to_be_visible(timeout=1 * MIN) logger.info('Login failed as expected') return if check_for_no_module_available_popup: login_button.click() logger.info("Checking for the 'No module for user available popup'") self.check_for_no_module_available_popup() else: logger.info('Logging in without waiting for requests to finish') login_button.click() # TODO: wait_until networkidle is discouraged by Playwright, replace at some point self.page.wait_for_url(re.compile(r'.*univention/(management|portal|selfservice).*'), wait_until=wait_until) logger.info('Login Done')
[docs] def end_umc_session(self): """Logs the current logged in user out by navigating to /univention/login""" self.page.goto(f'{self.base_url}/univention/logout')
[docs] def logout(self): """ Logout using the Side Menu This method is merely a shortcut for `SideMenu.logout()` """ from univention.testing.browser.sidemenu import SideMenu side_menu = SideMenu(self) side_menu.navigate(do_login=False) side_menu.logout()
[docs] def systemd_restart_service(self, service: str): logger.info('restarting service %s', service) subprocess.run(['deb-systemd-invoke', 'restart', service], check=True)
[docs] def restart_umc(self): self.systemd_restart_service('univention-management-console-server') time.sleep(3)