#!/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 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)