diff --git a/.gitignore b/.gitignore index 51e1ff753a..d3a3a78bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,5 @@ nimbus-build-system.paths # ui-tests /test/ui-pytest/configs/_local.py *.pyc + +test/ui-pytest/squish_server.ini diff --git a/test/ui-pytest/README.md b/test/ui-pytest/README.md index 20a6939953..76d5a586fc 100644 --- a/test/ui-pytest/README.md +++ b/test/ui-pytest/README.md @@ -1,153 +1,4 @@ -# Status desktop ui-tests - -# Setup: -Skip any of the steps, if sure that you have the correct version of the required tool. -## All Platforms -### 1. Install Qt 5.15 -https://doc.qt.io/qt-6/get-and-install-qt.html -### 2. Setup Squish License Server -https://hackmd.io/@status-desktop/HkbWpk2e5 -### 3. Install PyCharm -Download and install: -https://www.jetbrains.com/pycharm/download/other.html -Please, select any build depending on OS, but NOT an Apple Silicon (dmg) - -How to: https://www.jetbrains.com/help/pycharm/installation-guide.html - -## Windows -### 4. Install Squish -https://status-misc.ams3.digitaloceanspaces.com/squish/squish-7.1-20230301-1424-qt515x-win64-msvc142.exe -### 5. Install Python -Download and install for all users: https://www.python.org/ftp/python/3.10.11/python-3.10.11-amd64.exe -### 6. Install Requirements -``` -YOUR_PYTHON_PATH/pip3.exe install -r ./requirements.txt -``` -### 7. Setup Environment Variables -Add in system environment variables: -``` -SQUISH_DIR=PATH_TO_THE_SQUISH_ROOT_FOLDER -PYTHONPATH=%SQUISH_DIR%/lib;%SQUISH_DIR%/lib/python;%PYTHONPATH% -``` -RESTART PC -### 8. Verify environment variables -``` -echo %SQUISH_DIR% -echo %PYTHONPATH% -``` -### 9. Setup Python for Squish -Download 'PythonChanger.py' in %SQUISH_DIR%: -https://kb.froglogic.com/squish/howto/changing-python-installation-used-squish-binary-packages/PythonChanger.py -``` -YOUR_PYTHON_PATH/python3.10 SQUISH_DIR/PythonChanger.py --revert -YOUR_PYTHON_PATH/python3.10 SQUISH_DIR/PythonChanger.py -``` -- Replace "YOUR PYTHON PATH" on to Python3.10 file location path -- Replace "SQUISH DIR" on to the Squish root folder path -### 10 Test: -Executing tests located in 'test_self.py' file -``` -pytest ./tests/test_self.py -``` -Executing test 'test_import_squish' from 'test_self.py' file -``` -pytest ./tests/test_self.py::test_import_squish -``` -Executing all tests with 'import_squish' in test name -``` -pytest -k import_squish -``` -Executing all tests with tag 'self' -``` -pytest -m self -``` - -## Linux -### 4. Install Squish -https://status-misc.ams3.digitaloceanspaces.com/squish/squish-7.1-20230222-1555-qt515x-linux64.run -### 5. Install Python -```bash -sudo apt-get install software-properties-common -``` -```bash -sudo add-apt-repository ppa:deadsnakes/ppa -``` -```bash -sudo apt-get update -``` -```bash -sudo apt-get install python3.10 -``` -```bash -sudo apt install python3-pip -``` -### 6. Install Requirements -```bash -sudo pip3 install -r ./requirements.txt -``` -### 7. Setup Environment Variables -```bash -gedit ~/.profile -``` -``` -export SQUISH_DIR=PATH_TO_THE_SQUISH_ROOT_FOLDER -export PYTHONPATH=$SQUISH_DIR/lib:$SQUISH_DIR/lib/python:$PYTHONPATH -export LD_LIBRARY_PATH=$SQUISH_DIR/lib:$SQUISH_DIR/python3/lib:$LD_LIBRARY_PATH -``` -RESTART PC - -## Mac -### 4. Install Squish -https://status-misc.ams3.digitaloceanspaces.com/squish/squish-7.1-20230328-1608-qt515x-macaarch64.dmg -### 5. Install Python -```bash -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -``` -```bash -brew update --auto-update -brew install wget -brew install python@3.10 -``` -### 6. Install Requirements -```bash -sudo pip3 install -r ./requirements.txt -``` -### 7. Setup Environment Variables -```bash -touch ~/.zprofile -open ~/.zprofile -``` -``` -export SQUISH_DIR=PATH_TO_THE_SQUISH_ROOT_FOLDER -export PYTHONPATH=$SQUISH_DIR/lib:$SQUISH_DIR/lib/python:$PYTHONPATH -export LD_LIBRARY_PATH=$SQUISH_DIR/lib:$LD_LIBRARY_PATH -``` -RESTART PC - -## Linux or MAC: -### 8. Verify environment variables -```bash -echo $USERNAME -echo $PYTHONPATH -echo $LD_LIBRARY_PATH -``` -### 9. Setup Python for Squish -https://kb.froglogic.com/squish/howto/changing-python-installation-used-squish-binary-packages/ -```bash -brew install wget -wget -O $SQUISH_DIR/PythonChanger.py https://kb.froglogic.com/squish/howto/changing-python-installation-used-squish-binary-packages/PythonChanger.py -python3.10 $SQUISH_DIR/PythonChanger.py --revert -python3.10 $SQUISH_DIR/PythonChanger.py -``` -### 10 Test: -```bash -echo "Executing tests located in 'test_self.py' file" -pytest ./tests/test_self.py -echo "Executing test 'test_import_squish' from 'test_self.py' file" -pytest ./tests/test_self.py::test_import_squish -echo "Executing all tests with 'import_squish' in test name" -pytest -k import_squish -echo "Executing all tests with tag 'self'" -pytest -m self -``` -For more info, read: https://docs.pytest.org/en/latest/getting-started.html +# Status desktop ui-tests + +Setup: +https://www.notion.so/Setup-Environment-e5d88399027042a0992e85fd9b0e5167?pvs=4 diff --git a/test/ui-pytest/configs/__init__.py b/test/ui-pytest/configs/__init__.py index 36bf648d5c..f68fc55042 100644 --- a/test/ui-pytest/configs/__init__.py +++ b/test/ui-pytest/configs/__init__.py @@ -1,14 +1,21 @@ -import logging - -from . import testpath, timeouts - -_logger = logging.getLogger(__name__) - -try: - from ._local import * -except ImportError: - exit( - 'Config file: "_local.py" not found in "./configs".\n' - 'Please use template "_.local.py.default" to create file or execute command: \n' - rf'cp {testpath.ROOT}/configs/_local.py.default {testpath.ROOT}/configs/_local.py' - ) +import logging + +from scripts.utils.system_path import SystemPath +from . import testpath, timeouts, testrail, system + +_logger = logging.getLogger(__name__) + +try: + from ._local import * +except ImportError: + exit( + 'Config file: "_local.py" not found in "./configs".\n' + 'Please use template "_.local.py.default" to create file or execute command: \n' + rf'cp {testpath.ROOT}/configs/_local.py.default {testpath.ROOT}/configs/_local.py' + ) + +if APP_DIR is None: + exit('Please add "APP_DIR" in ./configs/_local.py') +if system.IS_WIN and 'bin' not in APP_DIR: + exit('Please use launcher from "bin" folder in "APP_DIR"') +APP_DIR = SystemPath(APP_DIR) diff --git a/test/ui-pytest/configs/_local.py.ci b/test/ui-pytest/configs/_local.py.ci index 46c599464a..d20174297d 100644 --- a/test/ui-pytest/configs/_local.py.ci +++ b/test/ui-pytest/configs/_local.py.ci @@ -1,7 +1,5 @@ import os -from scripts.utils.system_path import SystemPath - LOCAL_RUN = False - -APP_DIR = SystemPath(os.getenv('APP_DIR') +ATTACH_MODE = False +APP_DIR = os.getenv('APP_DIR') diff --git a/test/ui-pytest/configs/_local.py.default b/test/ui-pytest/configs/_local.py.default index 0eae750871..04950e900f 100644 --- a/test/ui-pytest/configs/_local.py.default +++ b/test/ui-pytest/configs/_local.py.default @@ -1,3 +1,3 @@ LOCAL_RUN = True - +ATTACH_MODE = True APP_DIR = None diff --git a/test/ui-pytest/configs/system.py b/test/ui-pytest/configs/system.py new file mode 100644 index 0000000000..03eef64e95 --- /dev/null +++ b/test/ui-pytest/configs/system.py @@ -0,0 +1,7 @@ +import platform + +IS_LIN = True if platform.system() == 'Linux' else False +IS_MAC = True if platform.system() == 'Darwin' else False +IS_WIN = True if platform.system() == 'Windows' else False + +OS_ID = 'lin' if IS_LIN else 'mac' if IS_MAC else 'win' diff --git a/test/ui-pytest/configs/testpath.py b/test/ui-pytest/configs/testpath.py index d0a8e46997..bbcabbb896 100644 --- a/test/ui-pytest/configs/testpath.py +++ b/test/ui-pytest/configs/testpath.py @@ -1,15 +1,27 @@ import os from datetime import datetime +import typing + from scripts.utils.system_path import SystemPath ROOT: SystemPath = SystemPath(__file__).resolve().parent.parent +# Runtime initialisation +TEST: typing.Optional[SystemPath] = None +TEST_VP: typing.Optional[SystemPath] = None +TEST_ARTIFACTS: typing.Optional[SystemPath] = None + # Test Directories RUN_ID = os.getenv('RUN_DIR', f'run_{datetime.now():%d%m%Y_%H%M%S}') TEMP: SystemPath = ROOT / 'tmp' RESULTS: SystemPath = TEMP / 'results' RUN: SystemPath = RESULTS / RUN_ID +VP: SystemPath = ROOT / 'ext' / 'vp' +TEST_FILES: SystemPath = ROOT / 'ext' / 'test_files' # Driver Directories -SQUISH_DIR = os.getenv('RUN_DIR') +SQUISH_DIR = SystemPath(os.getenv('SQUISH_DIR')) + +# Status Application +STATUS_DATA: SystemPath = RUN / 'status' diff --git a/test/ui-pytest/configs/testrail.py b/test/ui-pytest/configs/testrail.py new file mode 100644 index 0000000000..cd3a43755f --- /dev/null +++ b/test/ui-pytest/configs/testrail.py @@ -0,0 +1,6 @@ +import os + +TESTRAIL_RUN_ID = os.getenv('TESTRAIL_URL', '').strip() +TESTRAIL_URL = os.getenv('TESTRAIL_URL', None) +TESTRAIL_USER = os.getenv('TESTRAIL_USER', None) +TESTRAIL_PWD = os.getenv('TESTRAIL_PWD', None) diff --git a/test/ui-pytest/configs/timeouts.py b/test/ui-pytest/configs/timeouts.py index 58d4cef638..5f9016f202 100644 --- a/test/ui-pytest/configs/timeouts.py +++ b/test/ui-pytest/configs/timeouts.py @@ -1,3 +1,6 @@ # Timoeuts before raising errors -UI_LOAD_TIMEOUT_MSEC = 5000 +UI_LOAD_TIMEOUT_SEC = 5 +UI_LOAD_TIMEOUT_MSEC = UI_LOAD_TIMEOUT_SEC * 1000 +PROCESS_TIMEOUT_SEC = 10 +APP_LOAD_TIMEOUT_MSEC = 60000 diff --git a/test/ui-pytest/conftest.py b/test/ui-pytest/conftest.py index d393c73ade..17bb526800 100644 --- a/test/ui-pytest/conftest.py +++ b/test/ui-pytest/conftest.py @@ -1,17 +1,63 @@ +import logging +from datetime import datetime + +import allure import pytest +from PIL import ImageGrab + +import configs +from driver.aut import AUT +from scripts.utils.system_path import SystemPath +from tests.fixtures.path import generate_test_info + +_logger = logging.getLogger(__name__) pytest_plugins = [ + 'tests.fixtures.aut', 'tests.fixtures.path', + 'tests.fixtures.squish', + 'tests.fixtures.testrail', ] @pytest.fixture(scope='session', autouse=True) def setup_session_scope( - run_dir, + init_testrail_api, + prepare_test_directory, + start_squish_server, ): yield +@pytest.fixture(autouse=True) +def setup_function_scope( + generate_test_data, + check_result +): + yield + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + rep = outcome.get_result() + setattr(item, 'rep_' + rep.when, rep) + + def pytest_exception_interact(node): - """Handles test on fail.""" - pass + try: + test_path, test_name, test_params = generate_test_info(node) + node_dir: SystemPath = configs.testpath.RUN / test_path / test_name / test_params + node_dir.mkdir(parents=True, exist_ok=True) + + screenshot = node_dir / 'screenshot.png' + if screenshot.exists(): + screenshot = node_dir / f'screenshot_{datetime.now():%H%M%S}.png' + ImageGrab.grab().save(screenshot) + allure.attach( + name='Screenshot on fail', + body=screenshot.read_bytes(), + attachment_type=allure.attachment_type.PNG) + AUT().stop() + except Exception as ex: + _logger.debug(ex) diff --git a/test/ui-pytest/constants/__init__.py b/test/ui-pytest/constants/__init__.py index e69de29bb2..7c9bbe54c3 100644 --- a/test/ui-pytest/constants/__init__.py +++ b/test/ui-pytest/constants/__init__.py @@ -0,0 +1,4 @@ +from . import commands +from .colors import * +from .tesseract import * +from .user import * diff --git a/test/ui-pytest/constants/colors.py b/test/ui-pytest/constants/colors.py new file mode 100644 index 0000000000..96e3f1fe4a --- /dev/null +++ b/test/ui-pytest/constants/colors.py @@ -0,0 +1,49 @@ +from enum import Enum + + +class Color(Enum): + WHITE = 1 + BLACK = 2 + RED = 3 + BLUE = 4 + GREEN = 5 + YELLOW = 6 + ORANGE = 7 + + +boundaries = { + Color.WHITE: [ + [0, 0, 0], + [0, 0, 255] + ], + Color.BLACK: [ + [0, 0, 0], + [179, 100, 130] + ], + Color.RED: [ + [ + [0, 100, 20], + [10, 255, 255] + ], + [ + [160, 100, 20], + [179, 255, 255] + ] + ], + Color.BLUE: [ + [110, 50, 50], + [130, 255, 255] + ], + Color.GREEN: [ + [36, 25, 25], + [70, 255, 255] + ], + Color.YELLOW: [ + [20, 100, 0], + [45, 255, 255] + ], + Color.ORANGE: [ + [10, 100, 20], + [25, 255, 255] + ] +} \ No newline at end of file diff --git a/test/ui-pytest/constants/commands.py b/test/ui-pytest/constants/commands.py new file mode 100644 index 0000000000..05d64b6bdc --- /dev/null +++ b/test/ui-pytest/constants/commands.py @@ -0,0 +1,13 @@ +import configs.system + +# Buttons +BACKSPACE = 'Backspace' +COMMAND = 'Command' +CTRL = 'Ctrl' +ESCAPE = 'Escape' +RETURN = 'Return' +SHIFT = 'Shift' + +# Combinations +SELECT_ALL = f'{CTRL if configs.system.IS_WIN else COMMAND}+A' +OPEN_GOTO = f'{COMMAND}+{SHIFT}+G' diff --git a/test/ui-pytest/constants/tesseract.py b/test/ui-pytest/constants/tesseract.py new file mode 100644 index 0000000000..a33c56c5cd --- /dev/null +++ b/test/ui-pytest/constants/tesseract.py @@ -0,0 +1,30 @@ +""" +Tesseract provides various configuration parameters that can be used to customize the OCR process. These parameters are passed as command-line arguments to Tesseract through the --oem and --psm options or through the config parameter in pytesseract. Here are some commonly used Tesseract configuration parameters: + +--oem (OCR Engine Mode): This parameter specifies the OCR engine mode to use. The available options are: + +0: Original Tesseract only. +1: Neural nets LSTM only. +2: Tesseract + LSTM. +3: Default, based on what is available. +--psm (Page Segmentation Mode): This parameter defines the page layout analysis mode to use. The available options are: + +0: Orientation and script detection (OSD) only. +1: Automatic page segmentation with OSD. +2: Automatic page segmentation, but no OSD or OCR. +3: Fully automatic page segmentation, but no OSD. (Default) +4: Assume a single column of text of variable sizes. +5: Assume a single uniform block of vertically aligned text. +6: Assume a single uniform block of text. +7: Treat the image as a single text line. +8: Treat the image as a single word. +9: Treat the image as a single word in a circle. +10: Treat the image as a single character. +--lang (Language): This parameter specifies the language(s) to use for OCR. Multiple languages can be specified separated by plus (+) signs. For example, --lang eng+fra for English and French. + +--tessdata-dir (Tessdata Directory): This parameter sets the path to the directory containing Tesseract's language data files. + +These are just a few examples of the commonly used configuration parameters in Tesseract. There are many more options available for advanced customization and fine-tuning of OCR results. You can refer to the official Tesseract documentation for a comprehensive list of configuration parameters and their descriptions: https://tesseract-ocr.github.io/tessdoc/Command-Line-Usage.html +""" + +text_on_profile_image = r'--oem 3 --psm 10' diff --git a/test/ui-pytest/constants/user.py b/test/ui-pytest/constants/user.py new file mode 100644 index 0000000000..fc6d8c5ee1 --- /dev/null +++ b/test/ui-pytest/constants/user.py @@ -0,0 +1,7 @@ +from collections import namedtuple + +UserAccount = namedtuple('User', ['name', 'password']) +user_account = UserAccount('squisher', '*P@ssw0rd*') +user_account_one = UserAccount('tester123', 'TesTEr16843/!@00') +user_account_two = UserAccount('Athletic', 'TesTEr16843/!@00') +user_account_three = UserAccount('Nervous', 'TesTEr16843/!@00') diff --git a/test/ui-pytest/driver/__init__.py b/test/ui-pytest/driver/__init__.py index e712a27ead..00f88059d9 100755 --- a/test/ui-pytest/driver/__init__.py +++ b/test/ui-pytest/driver/__init__.py @@ -1,16 +1,27 @@ import squishtest # noqa import configs +from . import server, context, objects_access, toplevel_window, aut, atomacos, mouse imports = {module.__name__: module for module in [ - # import any modules from driver folder + atomacos, + aut, + context, + objects_access, + mouse, + server, + toplevel_window + ]} def __getattr__(name): if name in imports: return imports[name] - return getattr(squishtest, name) + try: + return getattr(squishtest, name) + except AttributeError: + raise ImportError(f'Module "driver" has no attribute "{name}"') squishtest.testSettings.waitForObjectTimeout = configs.timeouts.UI_LOAD_TIMEOUT_MSEC diff --git a/test/ui-pytest/driver/atomacos.py b/test/ui-pytest/driver/atomacos.py new file mode 100644 index 0000000000..73b53d7fce --- /dev/null +++ b/test/ui-pytest/driver/atomacos.py @@ -0,0 +1,45 @@ +import time +from copy import deepcopy + +import configs.timeouts + +if configs.system.IS_MAC: + import atomacos + +BUNDLE_ID = 'im.Status.NimStatusClient' + + +# https://pypi.org/project/atomacos/ + + +def attach_atomac(timeout_sec: int = configs.timeouts.UI_LOAD_TIMEOUT_SEC): + atomator = atomacos.getAppRefByBundleId(BUNDLE_ID) + started_at = time.monotonic() + while not hasattr(atomator, 'AXMainWindow'): + time.sleep(1) + assert time.monotonic() - started_at < timeout_sec, f'Attach error: {BUNDLE_ID}' + return atomator + + +def find_object(object_name: dict): + _object_name = deepcopy(object_name) + if 'container' in _object_name: + parent = find_object(_object_name['container']) + del _object_name['container'] + else: + return attach_atomac().windows()[0] + + assert parent is not None, f'Object not found: {object_name["container"]}' + _object = parent.findFirst(**_object_name) + assert _object is not None, f'Object not found: {_object_name}' + return _object + + +def wait_for_object(object_name: dict, timeout_sec: int = configs.timeouts.UI_LOAD_TIMEOUT_SEC): + started_at = time.monotonic() + while True: + try: + return find_object(object_name) + except AssertionError as err: + if time.monotonic() - started_at > timeout_sec: + raise LookupError(f'Object: {object_name} not found. Error: {err}') diff --git a/test/ui-pytest/driver/aut.py b/test/ui-pytest/driver/aut.py new file mode 100644 index 0000000000..96c19d329d --- /dev/null +++ b/test/ui-pytest/driver/aut.py @@ -0,0 +1,78 @@ +import allure +import squish + +import configs +import driver +from configs.system import IS_WIN, IS_LIN +from driver import context +from driver.server import SquishServer +from scripts.utils import system_path, local_system + + +class AUT: + def __init__( + self, + app_path: system_path.SystemPath = configs.APP_DIR, + host: str = '127.0.0.1', + port: int = 61500 + ): + super(AUT, self).__init__() + self.path = app_path + self.host = host + self.port = int(port) + self.ctx = None + self.aut_id = self.path.name if IS_LIN else self.path.stem + self.process_name = 'Status' if IS_WIN else 'nim_status_client' + driver.testSettings.setWrappersForApplication(self.aut_id, ['Qt']) + + def __str__(self): + return type(self).__qualname__ + + @allure.step('Attach Squish to Test Application') + def attach(self, timeout_sec: int = configs.timeouts.PROCESS_TIMEOUT_SEC, attempt: int = 2): + if self.ctx is None: + self.ctx = context.attach('AUT', timeout_sec) + try: + squish.setApplicationContext(self.ctx) + except TypeError as err: + if attempt: + return self.attach(timeout_sec, attempt - 1) + else: + raise err + + @allure.step('Detach Squish and Application') + def detach(self): + if self.ctx is not None: + squish.currentApplicationContext().detach() + assert squish.waitFor(lambda: not self.ctx.isRunning, configs.timeouts.PROCESS_TIMEOUT_SEC) + self.ctx = None + return self + + @allure.step('Close application by process name') + def stop(self, verify: bool = True): + local_system.kill_process_by_name(self.process_name, verify=verify) + + @allure.step("Start application") + def launch(self, *args) -> 'AUT': + SquishServer().set_aut_timeout() + if configs.ATTACH_MODE: + SquishServer().add_attachable_aut('AUT', self.port) + command = [ + configs.testpath.SQUISH_DIR / 'bin' / 'startaut', + f'--port={self.port}', + f'"{self.path}"' + ] + list(args) + local_system.execute(command) + try: + local_system.wait_for_started(self.process_name) + except AssertionError: + local_system.execute(command, check=True) + else: + SquishServer().add_executable_aut(self.aut_id, self.path.parent) + command = [self.aut_id] + list(args) + self.ctx = squish.startApplication( + ' '.join(command), configs.timeouts.PROCESS_TIMEOUT_SEC) + + self.attach() + assert squish.waitFor(lambda: self.ctx.isRunning, configs.timeouts.PROCESS_TIMEOUT_SEC) + return self diff --git a/test/ui-pytest/driver/context.py b/test/ui-pytest/driver/context.py new file mode 100644 index 0000000000..4577c2c863 --- /dev/null +++ b/test/ui-pytest/driver/context.py @@ -0,0 +1,31 @@ +import logging +import time + +import allure +import squish + +import configs + +_logger = logging.getLogger(__name__) + + +@allure.step('Attaching to "{0}"') +def attach(aut_name: str, timeout_sec: int = configs.timeouts.PROCESS_TIMEOUT_SEC): + started_at = time.monotonic() + while True: + try: + context = squish.attachToApplication(aut_name) + _logger.info(f'AUT: {aut_name} attached') + return context + except RuntimeError as err: + _logger.debug(err) + time.sleep(1) + assert time.monotonic() - started_at < timeout_sec, f'Attach error: {aut_name}' + + +@allure.step('Detaching') +def detach(): + for ctx in squish.applicationContextList(): + ctx.detach() + assert squish.waitFor(lambda: not ctx.isRunning, configs.timeouts.APP_LOAD_TIMEOUT_MSEC) + _logger.info(f'All AUTs detached') diff --git a/test/ui-pytest/driver/mouse.py b/test/ui-pytest/driver/mouse.py new file mode 100755 index 0000000000..9da7f7d0b3 --- /dev/null +++ b/test/ui-pytest/driver/mouse.py @@ -0,0 +1,44 @@ +import time + +import squish + + +def move(obj: object, x: int, y: int, dx: int, dy: int, step: int, sleep: float = 0): + while True: + if x > dx: + x -= step + if x < x: + x = dx + elif x < dx: + x += step + if x > dx: + x = dx + if y > dy: + y -= step + if y < dy: + y = dy + elif y < dy: + y += step + if y > dy: + y = dy + squish.mouseMove(obj, x, y) + time.sleep(sleep) + if x == dx and y == dy: + break + + +def press_and_move( + obj, + x: int, + y: int, + dx: int, + dy: int, + mouse: int = squish.MouseButton.LeftButton, + step: int = 1, + sleep: float = 0 +): + squish.mouseMove(obj, x, y) + squish.mousePress(obj, x, y, mouse) + move(obj, x, y, dx, dy, step, sleep) + squish.mouseRelease(mouse) + time.sleep(1) diff --git a/test/ui-pytest/driver/objects_access.py b/test/ui-pytest/driver/objects_access.py new file mode 100644 index 0000000000..ad35c81c40 --- /dev/null +++ b/test/ui-pytest/driver/objects_access.py @@ -0,0 +1,12 @@ +import logging + +import object + +_logger = logging.getLogger(__name__) + + +def walk_children(parent, depth: int = 1000): + for child in object.children(parent): + yield child + if depth: + yield from walk_children(child, depth - 1) diff --git a/test/ui-pytest/driver/server.py b/test/ui-pytest/driver/server.py new file mode 100644 index 0000000000..9d8dbc9daf --- /dev/null +++ b/test/ui-pytest/driver/server.py @@ -0,0 +1,51 @@ +import typing + +import configs.testpath +from scripts.utils import local_system + +_PROCESS_NAME = '_squishserver' + + +class SquishServer: + + def __init__( + self, + host: str = '127.0.0.1', + port: int = 4322 + ): + self.path = configs.testpath.SQUISH_DIR / 'bin' / 'squishserver' + self.config = configs.testpath.ROOT / 'squish_server.ini' + self.host = host + self.port = port + + def start(self): + cmd = [ + f'"{self.path}"', + '--configfile', str(self.config), + '--verbose', + f'--host={self.host}', + f'--port={self.port}', + ] + local_system.execute(cmd) + try: + local_system.wait_for_started(_PROCESS_NAME) + except AssertionError: + local_system.execute(cmd, check=True) + + @classmethod + def stop(cls, attempt: int = 2): + local_system.kill_process_by_name(_PROCESS_NAME, verify=False) + + # https://doc-snapshots.qt.io/squish/cli-squishserver.html + def configuring(self, action: str, options: typing.Union[int, str, list]): + local_system.run( + [f'"{self.path}"', '--configfile', str(self.config), '--config', action, ' '.join(options)]) + + def add_executable_aut(self, aut_id, app_dir): + self.configuring('addAUT', [aut_id, f'"{app_dir}"']) + + def add_attachable_aut(self, aut_id: str, port: int): + self.configuring('addAttachableAUT', [aut_id, f'localhost:{port}']) + + def set_aut_timeout(self, value: int = configs.timeouts.PROCESS_TIMEOUT_SEC): + self.configuring('setAUTTimeout', [str(value)]) diff --git a/test/ui-pytest/driver/toplevel_window.py b/test/ui-pytest/driver/toplevel_window.py new file mode 100644 index 0000000000..e39f6373f6 --- /dev/null +++ b/test/ui-pytest/driver/toplevel_window.py @@ -0,0 +1,52 @@ +import squish +import toplevelwindow + +import configs + + +def maximize(object_name): + def _maximize() -> bool: + try: + toplevelwindow.ToplevelWindow(squish.waitForObject(object_name)).maximize() + return True + except RuntimeError: + return False + + return squish.waitFor(lambda: _maximize(), configs.timeouts.UI_LOAD_TIMEOUT_MSEC) + + +def minimize(object_name): + def _minimize() -> bool: + try: + toplevelwindow.ToplevelWindow(squish.waitForObject(object_name)).minimize() + return True + except RuntimeError: + return False + + return squish.waitFor(lambda: _minimize(), configs.timeouts.UI_LOAD_TIMEOUT_MSEC) + + +def set_focus(object_name): + def _set_focus() -> bool: + try: + toplevelwindow.ToplevelWindow(squish.waitForObject(object_name)).setFocus() + return True + except RuntimeError: + return False + + return squish.waitFor(lambda: _set_focus(), configs.timeouts.UI_LOAD_TIMEOUT_MSEC) + + +def on_top_level(object_name): + def _on_top() -> bool: + try: + toplevelwindow.ToplevelWindow(squish.waitForObject(object_name)).setForeground() + return True + except RuntimeError: + return False + + return squish.waitFor(lambda: _on_top(), configs.timeouts.UI_LOAD_TIMEOUT_MSEC) + + +def close(object_name): + squish.sendEvent("QCloseEvent", squish.waitForObject(object_name)) diff --git a/test/ui-pytest/ext/test_files/tv_signal.jpeg b/test/ui-pytest/ext/test_files/tv_signal.jpeg new file mode 100644 index 0000000000..e824d72da5 Binary files /dev/null and b/test/ui-pytest/ext/test_files/tv_signal.jpeg differ diff --git a/test/ui-pytest/ext/test_files/tv_signal.png b/test/ui-pytest/ext/test_files/tv_signal.png new file mode 100644 index 0000000000..d492cae138 Binary files /dev/null and b/test/ui-pytest/ext/test_files/tv_signal.png differ diff --git a/test/ui-pytest/ext/vp/test_onboarding/test_generate_new_keys/mac/user_image_profile.png b/test/ui-pytest/ext/vp/test_onboarding/test_generate_new_keys/mac/user_image_profile.png new file mode 100644 index 0000000000..2532bfc23a Binary files /dev/null and b/test/ui-pytest/ext/vp/test_onboarding/test_generate_new_keys/mac/user_image_profile.png differ diff --git a/test/ui-pytest/ext/vp/test_onboarding/test_generate_new_keys/user_image_onboarding.png b/test/ui-pytest/ext/vp/test_onboarding/test_generate_new_keys/user_image_onboarding.png new file mode 100644 index 0000000000..987bb6c7e0 Binary files /dev/null and b/test/ui-pytest/ext/vp/test_onboarding/test_generate_new_keys/user_image_onboarding.png differ diff --git a/test/ui-pytest/ext/vp/test_onboarding/test_generate_new_keys/user_image_profile.png b/test/ui-pytest/ext/vp/test_onboarding/test_generate_new_keys/user_image_profile.png new file mode 100644 index 0000000000..7be1907cfd Binary files /dev/null and b/test/ui-pytest/ext/vp/test_onboarding/test_generate_new_keys/user_image_profile.png differ diff --git a/test/ui-pytest/gui/components/base_popup.py b/test/ui-pytest/gui/components/base_popup.py new file mode 100644 index 0000000000..e086ee300c --- /dev/null +++ b/test/ui-pytest/gui/components/base_popup.py @@ -0,0 +1,15 @@ +import allure + +import driver +from gui.elements.qt.object import QObject + + +class BasePopup(QObject): + + def __init__(self): + super(BasePopup, self).__init__('statusDesktop_mainWindow_overlay') + + @allure.step('Close') + def close(self): + driver.nativeType('') + self.wait_until_hidden() diff --git a/test/ui-pytest/gui/components/before_started_popup.py b/test/ui-pytest/gui/components/before_started_popup.py new file mode 100644 index 0000000000..c9177f8291 --- /dev/null +++ b/test/ui-pytest/gui/components/before_started_popup.py @@ -0,0 +1,26 @@ +import allure + +from gui.components.base_popup import BasePopup +from gui.elements.qt.button import Button +from gui.elements.qt.check_box import CheckBox + + +class BeforeStartedPopUp(BasePopup): + + def __init__(self): + super(BeforeStartedPopUp, self).__init__() + self._acknowledge_checkbox = CheckBox('acknowledge_checkbox') + self._terms_of_use_checkBox = CheckBox('termsOfUseCheckBox_StatusCheckBox') + self._get_started_button = Button('getStartedStatusButton_StatusButton') + + @property + @allure.step('Get visible attribute') + def is_visible(self) -> bool: + return self._get_started_button.is_visible + + @allure.step('Allow all and get started') + def get_started(self): + self._acknowledge_checkbox.set(True) + self._terms_of_use_checkBox.set(True, x=10) + self._get_started_button.click() + self.wait_until_hidden() diff --git a/test/ui-pytest/gui/components/os/__init__.py b/test/ui-pytest/gui/components/os/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/ui-pytest/gui/components/os/lin/__init__.py b/test/ui-pytest/gui/components/os/lin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/ui-pytest/gui/components/os/lin/open_file_dialog.py b/test/ui-pytest/gui/components/os/lin/open_file_dialog.py new file mode 100644 index 0000000000..06b56466b5 --- /dev/null +++ b/test/ui-pytest/gui/components/os/lin/open_file_dialog.py @@ -0,0 +1,22 @@ +import allure + +import constants.commands +import driver +from gui.elements.qt.button import Button +from gui.elements.qt.text_edit import TextEdit +from gui.elements.qt.window import Window +from scripts.utils.system_path import SystemPath + + +class OpenFileDialog(Window): + + def __init__(self): + super(OpenFileDialog, self).__init__('please_choose_an_image_QQuickWindow') + self._path_text_edit = TextEdit('titleBar_textInput_TextInputWithHandles') + self._open_button = Button('please_choose_an_image_Open_Button') + + @allure.step('Open file') + def open_file(self, fp: SystemPath): + self._path_text_edit.text = str(fp) + driver.type(self._path_text_edit.object, f'<{constants.commands.RETURN}>') + self.wait_until_hidden() diff --git a/test/ui-pytest/gui/components/os/mac/__init__.py b/test/ui-pytest/gui/components/os/mac/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/ui-pytest/gui/components/os/mac/open_file_dialogs.py b/test/ui-pytest/gui/components/os/mac/open_file_dialogs.py new file mode 100644 index 0000000000..8d0b545a3c --- /dev/null +++ b/test/ui-pytest/gui/components/os/mac/open_file_dialogs.py @@ -0,0 +1,59 @@ +import logging +import time + +import allure + +import constants +import driver +from gui.elements.os.mac.button import Button +from gui.elements.os.mac.object import NativeObject +from gui.elements.os.mac.text_edit import TextEdit +from scripts.utils.system_path import SystemPath + +_logger = logging.getLogger(__name__) + + +class OpenFileDialog(NativeObject): + + def __init__(self): + super(OpenFileDialog, self).__init__('openFileDialog') + self._open_button = Button('openButton') + + def _open_go_to_dialog(self, attempt: int = 2): + # Set focus + driver.nativeMouseClick(int(self.bounds.x + 10), int(self.bounds.y + 10), driver.Qt.LeftButton) + time.sleep(1) + driver.nativeType(f'<{constants.commands.OPEN_GOTO}>') + try: + return _GoToDialog().wait_until_appears() + except LookupError as err: + _logger.debug(err) + if attempt: + self._open_go_to_dialog(attempt - 1) + else: + raise err + + @allure.step('Open file') + def open_file(self, fp: SystemPath): + # Set focus + driver.nativeMouseClick(int(self.bounds.x + 10), int(self.bounds.y + 10), driver.Qt.LeftButton) + time.sleep(1) + driver.nativeType(f'<{constants.commands.OPEN_GOTO}>') + self._open_go_to_dialog().select_file(fp) + self._open_button.click() + self.wait_until_hidden() + + +class _GoToDialog(NativeObject): + + def __init__(self): + self.go_to_text_edit = TextEdit('pathTextField') + super(_GoToDialog, self).__init__('goToDialog') + + @allure.step('Select file') + def select_file(self, fp: SystemPath): + self.go_to_text_edit.text = str(fp) + driver.nativeMouseClick(int(self.bounds.x + 10), int(self.bounds.y + 10), driver.Qt.LeftButton) + time.sleep(1) + driver.nativeType(f'<{constants.commands.RETURN}>') + self.wait_until_hidden() diff --git a/test/ui-pytest/gui/components/os/open_file_dialogs.py b/test/ui-pytest/gui/components/os/open_file_dialogs.py new file mode 100644 index 0000000000..5a40ba9463 --- /dev/null +++ b/test/ui-pytest/gui/components/os/open_file_dialogs.py @@ -0,0 +1,12 @@ +import configs + +if configs.system.IS_WIN: + from .win.open_file_dialogs import OpenFileDialog as BaseOpenFileDialog +elif configs.system.IS_MAC: + from .mac.open_file_dialogs import OpenFileDialog as BaseOpenFileDialog +else: + from .lin.open_file_dialog import OpenFileDialog as BaseOpenFileDialog + + +class OpenFileDialog(BaseOpenFileDialog): + pass diff --git a/test/ui-pytest/gui/components/os/win/__init__.py b/test/ui-pytest/gui/components/os/win/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/ui-pytest/gui/components/os/win/open_file_dialogs.py b/test/ui-pytest/gui/components/os/win/open_file_dialogs.py new file mode 100644 index 0000000000..722ea0073c --- /dev/null +++ b/test/ui-pytest/gui/components/os/win/open_file_dialogs.py @@ -0,0 +1,24 @@ +import logging + +import allure + +from gui.elements.os.win.button import Button +from gui.elements.os.win.object import NativeObject +from gui.elements.os.win.text_edit import TextEdit +from scripts.utils.system_path import SystemPath + +_logger = logging.getLogger(__name__) + + +class OpenFileDialog(NativeObject): + + def __init__(self): + super().__init__('file_Dialog') + self._file_path_text_edit = TextEdit('choose_file_Edit') + self._select_button = Button('choose_Open_Button') + + @allure.step('Open file') + def open_file(self, fp: SystemPath): + self._file_path_text_edit.text = str(fp) + self._select_button.click() + self.wait_until_hidden() diff --git a/test/ui-pytest/gui/components/profile_picture_popup.py b/test/ui-pytest/gui/components/profile_picture_popup.py new file mode 100644 index 0000000000..54e07d7695 --- /dev/null +++ b/test/ui-pytest/gui/components/profile_picture_popup.py @@ -0,0 +1,52 @@ +import time +from collections import namedtuple + +import allure + +import driver.mouse +from gui.components.base_popup import BasePopup +from gui.elements.qt.button import Button +from gui.elements.qt.object import QObject +from gui.elements.qt.slider import Slider + +shift_image = namedtuple('Shift', ['left', 'right', 'top', 'bottom']) + + +class ProfilePicturePopup(BasePopup): + + def __init__(self): + super(ProfilePicturePopup, self).__init__() + self._zoom_slider = Slider('o_StatusSlider') + self._view = QObject('cropSpaceItem_Item') + self._make_profile_picture_button = Button('make_this_my_profile_picture_StatusButton') + self._slider_handler = QObject('o_DropShadow') + + @allure.step('Make profile image') + def make_profile_picture( + self, + zoom: int = None, + shift: shift_image = None + ): + if zoom is not None: + self._zoom_slider.value = zoom + # The slider changed value, but image updates only after click on slider + self._slider_handler.click() + time.sleep(1) + if shift is not None: + if shift.left: + driver.mouse.press_and_move(self._view.object, 1, 1, shift.left, 1) + time.sleep(1) + if shift.right: + driver.mouse.press_and_move( + self._view.object, self._view.width, 1, self._view.width - shift.right, 1) + time.sleep(1) + if shift.top: + driver.mouse.press_and_move(self._view.object, 1, 1, 1, shift.top, step=1) + time.sleep(1) + if shift.bottom: + driver.mouse.press_and_move( + self._view.object, 1, self._view.height, 1, self._view.height - shift.bottom, step=1) + time.sleep(1) + + self._make_profile_picture_button.click() + self.wait_until_hidden() diff --git a/test/ui-pytest/gui/components/profile_popup.py b/test/ui-pytest/gui/components/profile_popup.py new file mode 100644 index 0000000000..1abf3b48fb --- /dev/null +++ b/test/ui-pytest/gui/components/profile_popup.py @@ -0,0 +1,62 @@ +import allure + +import constants +import driver +from gui.components.base_popup import BasePopup +from gui.elements.qt.button import Button +from gui.elements.qt.object import QObject +from gui.elements.qt.text_label import TextLabel +from scripts.tools.image import Image + + +class ProfilePopup(BasePopup): + + def __init__(self): + super(ProfilePopup, self).__init__() + self._profile_image = QObject('ProfileHeader_userImage') + self._user_name_label = TextLabel('ProfilePopup_displayName') + self._edit_profile_button = Button('ProfilePopup_editButton') + self._chat_key_text_label = TextLabel('https_status_app_StatusBaseText') + self._emoji_hash = QObject('profileDialog_userEmojiHash_EmojiHash') + + @property + @allure.step('Get profile image') + def profile_image(self): + return self._profile_image.image + + @property + @allure.step('Get image without identicon_ring') + def cropped_profile_image(self): + # Profile image without identicon_ring + self._profile_image.image.update_view() + self._profile_image.image.crop( + driver.UiTypes.ScreenRectangle( + 15, 15, self._profile_image.image.width-30, self._profile_image.image.height-30 + )) + return self._profile_image.image + + @property + @allure.step('Get user name') + def user_name(self) -> str: + return self._user_name_label.text + + @property + @allure.step('Get chat key') + def chat_key(self) -> str: + chat_key = self._chat_key_text_label.text.split('https://status.app/u/')[1].strip() + if '#' in chat_key: + chat_key = chat_key.split('#')[1] + return chat_key + + @property + @allure.step('Get emoji hash image') + def emoji_hash(self) -> Image: + return self._emoji_hash.image + + @allure.step('Verify: user image contains text') + def is_user_image_contains(self, text: str): + # To remove all artifacts, the image cropped. + crop = driver.UiTypes.ScreenRectangle( + 15, 15, self._profile_image.image.width - 30, self._profile_image.image.height - 30 + ) + return self.profile_image.has_text(text, constants.tesseract.text_on_profile_image, crop=crop) diff --git a/test/ui-pytest/gui/components/splash_screen.py b/test/ui-pytest/gui/components/splash_screen.py new file mode 100644 index 0000000000..b9e7a7bde0 --- /dev/null +++ b/test/ui-pytest/gui/components/splash_screen.py @@ -0,0 +1,20 @@ +import allure + +import configs + +from gui.elements.qt.object import QObject + + +class SplashScreen(QObject): + + def __init__(self): + super(SplashScreen, self).__init__('splashScreen') + + @allure.step('Wait until appears {0}') + def wait_until_appears(self, timeout_msec: int = configs.timeouts.UI_LOAD_TIMEOUT_MSEC): + assert self.wait_for(lambda: self.exists, timeout_msec), f'Object {self} is not visible' + return self + + @allure.step('Wait until hidden {0}') + def wait_until_hidden(self, timeout_msec: int = configs.timeouts.APP_LOAD_TIMEOUT_MSEC): + super().wait_until_hidden(timeout_msec) diff --git a/test/ui-pytest/gui/components/user_canvas.py b/test/ui-pytest/gui/components/user_canvas.py new file mode 100644 index 0000000000..11b6648dc5 --- /dev/null +++ b/test/ui-pytest/gui/components/user_canvas.py @@ -0,0 +1,67 @@ +import time + +import allure + +import configs +import constants +import driver +from gui.components.profile_popup import ProfilePopup +from gui.elements.qt.button import Button +from gui.elements.qt.object import QObject +from gui.elements.qt.text_label import TextLabel + + +class UserCanvas(QObject): + + def __init__(self): + super(UserCanvas, self).__init__('o_StatusListView') + self._always_active_button = Button('userContextmenu_AlwaysActiveButton') + self._inactive_button = Button('userContextmenu_InActiveButton') + self._automatic_button = Button('userContextmenu_AutomaticButton') + self._view_my_profile_button = Button('userContextMenu_ViewMyProfileAction') + self._user_name_text_label = TextLabel('userLabel_StyledText') + self._profile_image = QObject('o_StatusIdenticonRing') + + @property + @allure.step('Get profile image') + def profile_image(self): + return self._profile_image.image + + @property + @allure.step('Get user name') + def user_name(self) -> str: + return self._user_name_text_label.text + + @allure.step('Wait until appears {0}') + def wait_until_appears(self, timeout_msec: int = configs.timeouts.UI_LOAD_TIMEOUT_MSEC): + super(UserCanvas, self).wait_until_appears(timeout_msec) + time.sleep(1) + return self + + @allure.step('Set user state online') + def set_user_state_online(self): + self._always_active_button.click() + self.wait_until_hidden() + + @allure.step('Set user state offline') + def set_user_state_offline(self): + self._inactive_button.click() + self.wait_until_hidden() + + @allure.step('Set user automatic state') + def set_user_automatic_state(self): + self._automatic_button.click() + self.wait_until_hidden() + + @allure.step('Open Profile popup') + def open_profile_popup(self) -> ProfilePopup: + self._view_my_profile_button.click() + return ProfilePopup().wait_until_appears() + + @allure.step('Verify: User image contains text') + def is_user_image_contains(self, text: str): + # To remove all artifacts, the image cropped. + crop = driver.UiTypes.ScreenRectangle( + 5, 5, self._profile_image.image.width-10, self._profile_image.image.height-10 + ) + return self._profile_image.image.has_text(text, constants.tesseract.text_on_profile_image, crop=crop) diff --git a/test/ui-pytest/gui/components/welcome_status_popup.py b/test/ui-pytest/gui/components/welcome_status_popup.py new file mode 100644 index 0000000000..ccee23556f --- /dev/null +++ b/test/ui-pytest/gui/components/welcome_status_popup.py @@ -0,0 +1,21 @@ +import allure + +from gui.components.base_popup import BasePopup +from gui.elements.qt.button import Button +from gui.elements.qt.check_box import CheckBox + + +class WelcomeStatusPopup(BasePopup): + + def __init__(self): + self._agree_to_use_checkbox = CheckBox('agreeToUse_StatusCheckBox') + self._ready_to_use_checkbox = CheckBox('readyToUse_StatusCheckBox') + self._ready_to_use_button = Button('i_m_ready_to_use_Status_Desktop_Beta_StatusButton') + super(WelcomeStatusPopup, self).__init__() + + @allure.step('Confirm all') + def confirm(self): + self._agree_to_use_checkbox.set(True) + self._ready_to_use_checkbox.set(True) + self._ready_to_use_button.click() + self.wait_until_hidden() diff --git a/test/ui-pytest/gui/elements/__init__.py b/test/ui-pytest/gui/elements/__init__.py index 8b13789179..e69de29bb2 100644 --- a/test/ui-pytest/gui/elements/__init__.py +++ b/test/ui-pytest/gui/elements/__init__.py @@ -1 +0,0 @@ - diff --git a/test/ui-pytest/gui/elements/base_object.py b/test/ui-pytest/gui/elements/base_object.py new file mode 100644 index 0000000000..cde0bc0934 --- /dev/null +++ b/test/ui-pytest/gui/elements/base_object.py @@ -0,0 +1,43 @@ +import logging + +import allure + +import configs +import driver +from gui import objects_map + +_logger = logging.getLogger(__name__) + + +class BaseObject: + + def __init__(self, name: str): + self.symbolic_name = name + self.real_name = getattr(objects_map, name) + + def __str__(self): + return f'{type(self).__qualname__}({self.symbolic_name})' + + def __repr__(self): + return f'{type(self).__qualname__}({self.symbolic_name})' + + @property + def object(self): + raise NotImplementedError + + @property + def is_visible(self) -> bool: + raise NotImplementedError + + @allure.step('Wait until appears {0}') + def wait_until_appears(self, timeout_msec: int = configs.timeouts.UI_LOAD_TIMEOUT_MSEC): + assert driver.waitFor(lambda: self.is_visible, timeout_msec), f'Object {self} is not visible' + return self + + @allure.step('Wait until hidden {0}') + def wait_until_hidden(self, timeout_msec: int = configs.timeouts.UI_LOAD_TIMEOUT_MSEC): + assert driver.waitFor(lambda: not self.is_visible, timeout_msec), f'Object {self} is not hidden' + + @classmethod + def wait_for(cls, condition, timeout_msec: int = configs.timeouts.UI_LOAD_TIMEOUT_MSEC) -> bool: + return driver.waitFor(lambda: condition, timeout_msec) diff --git a/test/ui-pytest/gui/elements/os/__init__.py b/test/ui-pytest/gui/elements/os/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/ui-pytest/gui/elements/os/mac/__init__.py b/test/ui-pytest/gui/elements/os/mac/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/ui-pytest/gui/elements/os/mac/button.py b/test/ui-pytest/gui/elements/os/mac/button.py new file mode 100644 index 0000000000..bc5838f215 --- /dev/null +++ b/test/ui-pytest/gui/elements/os/mac/button.py @@ -0,0 +1,10 @@ +import allure + +from .object import NativeObject + + +class Button(NativeObject): + + @allure.step('Click {0}') + def click(self): + self.object.Press() diff --git a/test/ui-pytest/gui/elements/os/mac/object.py b/test/ui-pytest/gui/elements/os/mac/object.py new file mode 100644 index 0000000000..f79a09abf4 --- /dev/null +++ b/test/ui-pytest/gui/elements/os/mac/object.py @@ -0,0 +1,48 @@ +import logging + +import allure + +import driver +from gui.elements.base_object import BaseObject + +_logger = logging.getLogger(__name__) + + +class NativeObject(BaseObject): + + def __init__(self, name: str): + super().__init__(name) + + @property + @allure.step('Get object {0}') + def object(self): + return driver.atomacos.wait_for_object(self.real_name) + + @property + @allure.step('Get visible {0}') + def is_visible(self): + try: + return self.object is not None + except LookupError as err: + _logger.debug(err) + return False + + @property + @allure.step('Get bounds {0}') + def bounds(self): + return self.object.AXFrame + + @property + @allure.step('Get width {0}') + def width(self) -> int: + return int(self.object.AXSize.width) + + @property + @allure.step('Get height {0}') + def height(self) -> int: + return int(self.object.AXSize.height) + + @property + @allure.step('Get central coordinate {0}') + def center(self): + return self.bounds.center() diff --git a/test/ui-pytest/gui/elements/os/mac/text_edit.py b/test/ui-pytest/gui/elements/os/mac/text_edit.py new file mode 100644 index 0000000000..dad05c17a4 --- /dev/null +++ b/test/ui-pytest/gui/elements/os/mac/text_edit.py @@ -0,0 +1,14 @@ +import driver +from .object import NativeObject + + +class TextEdit(NativeObject): + + @property + def text(self) -> str: + return str(self.object.AXValue) + + @text.setter + def text(self, value: str): + self.object.setString('AXValue', value) + driver.waitFor(lambda: self.text == value) diff --git a/test/ui-pytest/gui/elements/os/win/__init__.py b/test/ui-pytest/gui/elements/os/win/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/ui-pytest/gui/elements/os/win/button.py b/test/ui-pytest/gui/elements/os/win/button.py new file mode 100644 index 0000000000..47399c7b40 --- /dev/null +++ b/test/ui-pytest/gui/elements/os/win/button.py @@ -0,0 +1,10 @@ +import allure + +from .object import NativeObject + + +class Button(NativeObject): + + @allure.step('Click {0}') + def click(self): + super().click() diff --git a/test/ui-pytest/gui/elements/os/win/object.py b/test/ui-pytest/gui/elements/os/win/object.py new file mode 100644 index 0000000000..335a273aaa --- /dev/null +++ b/test/ui-pytest/gui/elements/os/win/object.py @@ -0,0 +1,42 @@ +import logging + +import allure + +import driver +from gui.elements.base_object import BaseObject + +_logger = logging.getLogger(__name__) + + +class NativeObject(BaseObject): + + def __init__(self, name: str): + super().__init__(name) + + @property + @allure.step('Get object {0}') + def object(self): + return driver.waitForObject(self.real_name) + + @property + @allure.step('Get visible {0}') + def is_visible(self): + try: + driver.waitForObject(self.real_name, 1) + return True + except (AttributeError, LookupError, RuntimeError): + return False + + @property + @allure.step('Get bounds {0}') + def bounds(self): + return driver.object.globalBounds(self.object) + + @property + @allure.step('Get central coordinate {0}') + def center(self): + return self.bounds.center() + + @allure.step('Click {0}') + def click(self): + driver.mouseClick(self.object) diff --git a/test/ui-pytest/gui/elements/os/win/text_edit.py b/test/ui-pytest/gui/elements/os/win/text_edit.py new file mode 100644 index 0000000000..7ccbf2a0b2 --- /dev/null +++ b/test/ui-pytest/gui/elements/os/win/text_edit.py @@ -0,0 +1,32 @@ +import allure + +import configs +import constants +import driver +from .object import NativeObject + + +class TextEdit(NativeObject): + + @property + @allure.step('Get current text {0}') + def text(self) -> str: + return str(self.object.text) + + @text.setter + @allure.step('Type: {1} {0}') + def text(self, value: str): + self.clear() + driver.nativeType(value) + assert driver.waitFor(lambda: self.text == value, configs.timeouts.UI_LOAD_TIMEOUT_MSEC), \ + f'Type text failed, value in field: "{self.text}", expected: {value}' + + @allure.step('Clear {0}') + def clear(self): + # Set focus + driver.nativeMouseClick(int(self.center.x), int(self.center.y), driver.Qt.LeftButton) + driver.type(self.object, f'<{constants.commands.SELECT_ALL}>') + driver.type(self.object, f'<{constants.commands.BACKSPACE}>') + assert driver.waitFor(lambda: not self.text), \ + f'Clear text field failed, value in field: "{self.text}"' + return self diff --git a/test/ui-pytest/gui/elements/qt/__init__.py b/test/ui-pytest/gui/elements/qt/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/ui-pytest/gui/elements/qt/button.py b/test/ui-pytest/gui/elements/qt/button.py new file mode 100644 index 0000000000..16d4b1f043 --- /dev/null +++ b/test/ui-pytest/gui/elements/qt/button.py @@ -0,0 +1,21 @@ +import typing + +import allure + +import driver +from gui.elements.qt.object import QObject + + +class Button(QObject): + + @allure.step('Click {0}') + def click( + self, + x: typing.Union[int, driver.UiTypes.ScreenPoint] = None, + y: typing.Union[int, driver.UiTypes.ScreenPoint] = None, + button: driver.MouseButton = None + ): + if None not in (x, y, button): + getattr(self.object, 'clicked')() + else: + super(Button, self).click(x, y, button) diff --git a/test/ui-pytest/gui/elements/qt/check_box.py b/test/ui-pytest/gui/elements/qt/check_box.py new file mode 100644 index 0000000000..b3a26b4124 --- /dev/null +++ b/test/ui-pytest/gui/elements/qt/check_box.py @@ -0,0 +1,15 @@ +import allure + +import configs +import driver +from gui.elements.qt.object import QObject + + +class CheckBox(QObject): + + @allure.step("Set {0} value: {1}") + def set(self, value: bool, x: int = None, y: int = None): + if self.is_checked is not value: + self.click(x, y) + assert driver.waitFor( + lambda: self.is_checked is value, configs.timeouts.UI_LOAD_TIMEOUT_MSEC), 'Value not changed' diff --git a/test/ui-pytest/gui/elements/qt/list.py b/test/ui-pytest/gui/elements/qt/list.py new file mode 100644 index 0000000000..577fb5dff1 --- /dev/null +++ b/test/ui-pytest/gui/elements/qt/list.py @@ -0,0 +1,43 @@ +import time +import typing + +import allure + +import configs +import driver +from gui.elements.qt.object import QObject + + +class List(QObject): + + @property + @allure.step('Get list items {0}') + def items(self): + return [self.object.itemAtIndex(index) for index in range(self.object.count)] + + @allure.step('Get values of list items {0}') + def get_values(self, attr_name: str) -> typing.List[str]: + values = [] + for index in range(self.object.count): + value = str(getattr(self.object.itemAtIndex(index), attr_name, '')) + if value: + values.append(value) + return values + + @allure.step('Select item {1} in {0}') + def select(self, value: str, attr_name: str): + driver.mouseClick(self.wait_for_item(value, attr_name)) + + @allure.step('Wait for item {1} in {0} with attribute {2}') + def wait_for_item(self, value: str, attr_name: str, timeout_sec: int = configs.timeouts.UI_LOAD_TIMEOUT_SEC): + started_at = time.monotonic() + values = [] + while True: + for index in range(self.object.count): + cur_value = str(getattr(self.object.itemAtIndex(index), attr_name, '')) + if cur_value == value: + return self.object.itemAtIndex(index) + values.append(cur_value) + time.sleep(1) + if time.monotonic() - started_at > timeout_sec: + raise RuntimeError(f'value not found in list: {values}') diff --git a/test/ui-pytest/gui/elements/qt/object.py b/test/ui-pytest/gui/elements/qt/object.py new file mode 100644 index 0000000000..cf9a1e59f0 --- /dev/null +++ b/test/ui-pytest/gui/elements/qt/object.py @@ -0,0 +1,118 @@ +import logging +import time + +import allure + +import configs +import driver +from gui.elements.base_object import BaseObject +from scripts.tools.image import Image + +_logger = logging.getLogger(__name__) + + +class QObject(BaseObject): + + def __init__(self, name: str): + super().__init__(name) + self._image = Image(self.real_name) + + def __str__(self): + return f'{type(self).__qualname__}({self.symbolic_name})' + + @property + @allure.step('Get object {0}') + def object(self): + return driver.waitForObject(self.real_name, configs.timeouts.UI_LOAD_TIMEOUT_MSEC) + + @property + @allure.step('Get object exists {0}') + def exists(self) -> bool: + return driver.object.exists(self.real_name) + + @property + @allure.step('Get bounds {0}') + def bounds(self): + return driver.object.globalBounds(self.object) + + @property + @allure.step('Get "x" coordinate {0}') + def x(self) -> int: + return self.bounds.x + + @property + @allure.step('Get "y" coordinate {0}') + def y(self) -> int: + return self.bounds.y + + @property + @allure.step('Get width {0}') + def width(self) -> int: + return int(self.bounds.width) + + @property + @allure.step('Get height {0}') + def height(self) -> int: + return int(self.bounds.height) + + @property + @allure.step('Get central coordinate {0}') + def center(self): + return self.bounds.center() + + @property + @allure.step('Get enabled {0}') + def is_enabled(self) -> bool: + return self.object.enabled + + @property + @allure.step('Get selected {0}') + def is_selected(self) -> bool: + return self.object.selected + + @property + @allure.step('Get checked {0}') + def is_checked(self) -> bool: + return self.object.checked + + @property + @allure.step('Get visible {0}') + def is_visible(self) -> bool: + try: + return driver.waitForObject(self.real_name, 0).visible + except (AttributeError, LookupError, RuntimeError): + return False + + @property + @allure.step('Get image {0}') + def image(self): + if self._image.view is None: + self._image.update_view() + return self._image + + @allure.step('Click {0}') + def click( + self, + x: int = None, + y: int = None, + button=None + ): + driver.mouseClick( + self.object, + x or self.width // 2, + y or self.height // 2, + button or driver.Qt.LeftButton + ) + + @allure.step('Hover {0}') + def hover(self, timeout_msec: int = configs.timeouts.UI_LOAD_TIMEOUT_MSEC): + def _hover(): + try: + driver.mouseMove(self.object) + return getattr(self.object, 'hovered', True) + except RuntimeError as err: + _logger.debug(err) + time.sleep(1) + return False + + assert driver.waitFor(lambda: _hover(), timeout_msec) diff --git a/test/ui-pytest/gui/elements/qt/slider.py b/test/ui-pytest/gui/elements/qt/slider.py new file mode 100644 index 0000000000..5e48a50410 --- /dev/null +++ b/test/ui-pytest/gui/elements/qt/slider.py @@ -0,0 +1,33 @@ +import allure + +from gui.elements.qt.object import QObject + + +class Slider(QObject): + + @property + @allure.step('Get minimal value {0}') + def min(self) -> int: + return int(getattr(self.object, 'from')) + + @property + @allure.step('Get maximal value {0}') + def max(self) -> max: + return int(getattr(self.object, 'to')) + + @property + @allure.step('Get value {0}') + def value(self) -> int: + return int(self.object.value) + + @value.setter + @allure.step('Set value {1} {0}') + def value(self, value: int): + if value != self.value: + if self.min <= value <= self.max: + if self.value < value: + while self.value < value: + self.object.increase() + if self.value > value: + while self.value > value: + self.object.decrease() diff --git a/test/ui-pytest/gui/elements/qt/text_edit.py b/test/ui-pytest/gui/elements/qt/text_edit.py new file mode 100644 index 0000000000..c453a57e7a --- /dev/null +++ b/test/ui-pytest/gui/elements/qt/text_edit.py @@ -0,0 +1,33 @@ +import allure + +import configs +import driver +from gui.elements.qt.object import QObject + + +class TextEdit(QObject): + + @property + @allure.step('Get current text {0}') + def text(self) -> str: + return str(self.object.text) + + @text.setter + @allure.step('Type text {1} {0}') + def text(self, value: str): + self.clear() + self.type_text(value) + assert driver.waitFor(lambda: self.text == value, configs.timeouts.UI_LOAD_TIMEOUT_MSEC), \ + f'Type text failed, value in field: "{self.text}", expected: {value}' + + @allure.step('Type: {1} in {0}') + def type_text(self, value: str): + driver.type(self.object, value) + return self + + @allure.step('Clear {0}') + def clear(self): + self.object.clear() + assert driver.waitFor(lambda: not self.text), \ + f'Clear text field failed, value in field: "{self.text}"' + return self diff --git a/test/ui-pytest/gui/elements/qt/text_label.py b/test/ui-pytest/gui/elements/qt/text_label.py new file mode 100644 index 0000000000..ba604fdaaf --- /dev/null +++ b/test/ui-pytest/gui/elements/qt/text_label.py @@ -0,0 +1,11 @@ +import allure + +from gui.elements.qt.object import QObject + + +class TextLabel(QObject): + + @property + @allure.step('Get text {0}') + def text(self) -> str: + return str(self.object.text) diff --git a/test/ui-pytest/gui/elements/qt/window.py b/test/ui-pytest/gui/elements/qt/window.py new file mode 100644 index 0000000000..554bada9c3 --- /dev/null +++ b/test/ui-pytest/gui/elements/qt/window.py @@ -0,0 +1,40 @@ +import logging + +import allure + +import driver +from gui.elements.qt.object import QObject + +_logger = logging.getLogger(__name__) + + +class Window(QObject): + + def prepare(self) -> 'Window': + self.maximize() + self.on_top_level() + return self + + @allure.step("Maximize {0}") + def maximize(self): + assert driver.toplevel_window.maximize(self.real_name), 'Maximize failed' + _logger.info(f'Window {getattr(self.object, "title", "")} is maximized') + + @allure.step("Minimize {0}") + def minimize(self): + assert driver.toplevel_window.minimize(self.real_name), 'Minimize failed' + _logger.info(f'Window {getattr(self.object, "title", "")} is minimized') + + @allure.step("Set focus on {0}") + def set_focus(self): + assert driver.toplevel_window.set_focus(self.real_name), 'Set focus failed' + _logger.info(f'Window {getattr(self.object, "title", "")} in focus') + + @allure.step("Move {0} on top") + def on_top_level(self): + assert driver.toplevel_window.on_top_level(self.real_name), 'Set on top failed' + _logger.info(f'Window {getattr(self.object, "title", "")} moved on top') + + @allure.step("Close {0}") + def close(self): + driver.toplevel_window.close(self.real_name) diff --git a/test/ui-pytest/gui/main_window.py b/test/ui-pytest/gui/main_window.py index ac2189fc44..af1fde0b55 100644 --- a/test/ui-pytest/gui/main_window.py +++ b/test/ui-pytest/gui/main_window.py @@ -1,9 +1,46 @@ import logging +import allure + +from gui.components.user_canvas import UserCanvas +from gui.elements.qt.button import Button +from gui.elements.qt.object import QObject +from gui.elements.qt.window import Window + _logger = logging.getLogger(__name__) -class MainWindow: +class LeftPanel(QObject): def __init__(self): - pass + super(LeftPanel, self).__init__('mainWindow_StatusAppNavBar') + self._profile_button = Button('mainWindow_ProfileNavBarButton') + + @property + @allure.step('Get user badge color') + def user_badge_color(self) -> str: + return str(self._profile_button.object.badge.color.name) + + @allure.step('Open user canvas') + def open_user_canvas(self) -> UserCanvas: + self._profile_button.click() + return UserCanvas().wait_until_appears() + + @allure.step('Verify: User is online') + def user_is_online(self) -> bool: + return self.user_badge_color == '#4ebc60' + + @allure.step('Verify: User is offline') + def user_is_offline(self): + return self.user_badge_color == '#7f8990' + + @allure.step('Verify: User is set to automatic') + def user_is_set_to_automatic(self): + return self.user_badge_color == '#4ebc60' + + +class MainWindow(Window): + + def __init__(self): + super(MainWindow, self).__init__('statusDesktop_mainWindow') + self.left_panel = LeftPanel() diff --git a/test/ui-pytest/gui/objects_map/__init__.py b/test/ui-pytest/gui/objects_map/__init__.py index 80965aa260..e3636bc4a3 100644 --- a/test/ui-pytest/gui/objects_map/__init__.py +++ b/test/ui-pytest/gui/objects_map/__init__.py @@ -1,5 +1,4 @@ from .component_names import * -from .main_window_names import * -from .messages_names import * +from .main_names import * from .onboarding_names import * -from .settings_names import * +from .os_names import * diff --git a/test/ui-pytest/gui/objects_map/component_names.py b/test/ui-pytest/gui/objects_map/component_names.py new file mode 100644 index 0000000000..80b2b3dc81 --- /dev/null +++ b/test/ui-pytest/gui/objects_map/component_names.py @@ -0,0 +1,63 @@ +from objectmaphelper import * +from . main_names import statusDesktop_mainWindow_overlay + +# Before you get started Popup +acknowledge_checkbox = {"checkable": True, "container": statusDesktop_mainWindow_overlay, "objectName": "acknowledgeCheckBox", "type": "StatusCheckBox", "visible": True} +termsOfUseCheckBox_StatusCheckBox = {"checkable": True, "container": statusDesktop_mainWindow_overlay, "objectName":"termsOfUseCheckBox", "type": "StatusCheckBox", "visible": True} +getStartedStatusButton_StatusButton = {"container": statusDesktop_mainWindow_overlay, "objectName": "getStartedStatusButton", "type": "StatusButton", "visible": True} + +# Back Up Your Seed Phrase Popup +o_PopupItem = {"container": statusDesktop_mainWindow_overlay, "type": "PopupItem", "unnamed": 1, "visible": True} +i_have_a_pen_and_paper_StatusCheckBox = {"checkable": True, "container": statusDesktop_mainWindow_overlay, "objectName": "Acknowledgements_havePen", "type": "StatusCheckBox", "visible": True} +i_know_where_I_ll_store_it_StatusCheckBox = {"checkable": True, "container": statusDesktop_mainWindow_overlay, "objectName": "Acknowledgements_storeIt", "type": "StatusCheckBox", "visible": True} +i_am_ready_to_write_down_StatusCheckBox = {"checkable": True, "container": statusDesktop_mainWindow_overlay, "objectName": "Acknowledgements_writeDown", "type": "StatusCheckBox", "visible": True} +not_Now_StatusButton = {"checkable": False, "container": statusDesktop_mainWindow_overlay, "type": "StatusButton", "unnamed": 1, "visible": True} +confirm_Seed_Phrase_StatusButton = {"checkable": False, "container": statusDesktop_mainWindow_overlay, "objectName": "BackupSeedModal_nextButton", "type": "StatusButton", "visible": True} +backup_seed_phrase_popup_ConfirmSeedPhrasePanel_StatusSeedPhraseInput_placeholder = {"container": statusDesktop_mainWindow_overlay, "objectName": "ConfirmSeedPhrasePanel_StatusSeedPhraseInput_%WORD_NO%", "type": "StatusSeedPhraseInput", "visible": True} +reveal_seed_phrase_StatusButton = {"checkable": False, "container": statusDesktop_mainWindow_overlay, "objectName": "ConfirmSeedPhrasePanel_RevealSeedPhraseButton", "type": "StatusButton", "visible": True} +blur_GaussianBlur = {"container": statusDesktop_mainWindow_overlay, "id": "blur", "type": "GaussianBlur", "unnamed": 1, "visible": True} +confirmSeedPhrasePanel_StatusSeedPhraseInput = {"container": statusDesktop_mainWindow_overlay, "type": "StatusSeedPhraseInput", "visible": True} +confirmFirstWord = {"container": statusDesktop_mainWindow_overlay, "objectName": "BackupSeedModal_BackupSeedStepBase_confirmFirstWord", "type": "BackupSeedStepBase", "visible": True} +confirmFirstWord_inputText = {"container": confirmFirstWord, "objectName": "BackupSeedStepBase_inputText", "type": "TextEdit", "visible": True} +continue_StatusButton = {"checkable": False, "container": statusDesktop_mainWindow_overlay, "objectName": "BackupSeedModal_nextButton", "type": "StatusButton", "visible": True} +confirmSecondWord = {"container": statusDesktop_mainWindow_overlay, "objectName": "BackupSeedModal_BackupSeedStepBase_confirmSecondWord", "type": "BackupSeedStepBase", "visible": True} +confirmSecondWord_inputText = {"container": confirmSecondWord, "objectName": "BackupSeedStepBase_inputText", "type": "TextEdit", "visible": True} +i_acknowledge_StatusCheckBox = {"checkable": True, "container": statusDesktop_mainWindow_overlay, "objectName": "ConfirmStoringSeedPhrasePanel_storeCheck", "type": "StatusCheckBox", "visible": True} +completeAndDeleteSeedPhraseButton = {"container": statusDesktop_mainWindow_overlay, "objectName": "BackupSeedModal_completeAndDeleteSeedPhraseButton", "type": "StatusButton", "visible": True} + +# Send Contact Request Popup +contactRequest_ChatKey_Input = {"container": statusDesktop_mainWindow_overlay, "objectName": "SendContactRequestModal_ChatKey_Input", "type": "TextEdit"} +contactRequest_SayWhoYouAre_Input = {"container": statusDesktop_mainWindow_overlay, "objectName": "SendContactRequestModal_SayWhoYouAre_Input", "type": "TextEdit"} +contactRequest_Send_Button = {"container": statusDesktop_mainWindow_overlay, "objectName": "SendContactRequestModal_Send_Button", "type": "StatusButton"} + +# Change Language Popup +close_the_app_now_StatusButton = {"checkable": False, "container": statusDesktop_mainWindow_overlay, "type": "StatusButton", "unnamed": 1, "visible": True} + +# User Status Profile Menu +o_StatusListView = {"container": statusDesktop_mainWindow_overlay, "type": "StatusListView", "unnamed": 1, "visible": True} +userContextmenu_AlwaysActiveButton= {"container": o_StatusListView, "objectName": "userStatusMenuAlwaysOnlineAction", "type": "StatusMenuItem", "visible": True} +userContextmenu_InActiveButton= {"container": o_StatusListView, "objectName": "userStatusMenuInactiveAction", "type": "StatusMenuItem", "visible": True} +userContextmenu_AutomaticButton= {"container": o_StatusListView, "objectName": "userStatusMenuAutomaticAction", "type": "StatusMenuItem", "visible": True} +userContextMenu_ViewMyProfileAction = {"container": o_StatusListView, "objectName": "userStatusViewMyProfileAction", "type": "StatusMenuItem", "visible": True} +userLabel_StyledText = {"container": o_StatusListView, "type": "StyledText", "unnamed": 1, "visible": True} +o_StatusIdenticonRing = {"container": o_StatusListView, "type": "StatusIdenticonRing", "unnamed": 1, "visible": True} + +# My Profile Popup +ProfileHeader_userImage = {"container": statusDesktop_mainWindow_overlay, "objectName": "ProfileDialog_userImage", "type": "UserImage", "visible": True} +ProfilePopup_displayName = {"container": statusDesktop_mainWindow_overlay, "objectName": "ProfileDialog_displayName", "type": "StatusBaseText", "visible": True} +ProfilePopup_editButton = {"container": statusDesktop_mainWindow_overlay, "objectName": "editProfileButton", "type": "StatusButton", "visible": True} +ProfilePopup_SendContactRequestButton = {"container": statusDesktop_mainWindow_overlay, "objectName": "profileDialog_sendContactRequestButton", "type": "StatusButton", "visible": True} +profileDialog_userEmojiHash_EmojiHash = {"container": statusDesktop_mainWindow_overlay, "objectName": "ProfileDialog_userEmojiHash", "type": "EmojiHash", "visible": True} +edit_TextEdit = {"container": statusDesktop_mainWindow_overlay, "id": "edit", "type": "TextEdit", "unnamed": 1, "visible": True} +https_status_app_StatusBaseText = {"container": edit_TextEdit, "type": "StatusBaseText", "unnamed": 1, "visible": True} + +# Welcome Status Popup +agreeToUse_StatusCheckBox = {"checkable": True, "container": statusDesktop_mainWindow_overlay, "id": "agreeToUse", "type": "StatusCheckBox", "unnamed": 1, "visible": True} +readyToUse_StatusCheckBox = {"checkable": True, "container": statusDesktop_mainWindow_overlay, "id": "readyToUse", "type": "StatusCheckBox", "unnamed": 1, "visible": True} +i_m_ready_to_use_Status_Desktop_Beta_StatusButton = {"checkable": False, "container": statusDesktop_mainWindow_overlay, "type": "StatusButton", "unnamed": 1, "visible": True} + +# Profile Picture Popup +o_StatusSlider = {"container": statusDesktop_mainWindow_overlay, "type": "StatusSlider", "unnamed": 1, "visible": True} +cropSpaceItem_Item = {"container": statusDesktop_mainWindow_overlay, "id": "cropSpaceItem", "type": "Item", "unnamed": 1, "visible": True} +make_this_my_profile_picture_StatusButton = {"checkable": False, "container": statusDesktop_mainWindow_overlay, "objectName": "imageCropperAcceptButton", "type": "StatusButton", "visible": True} +o_DropShadow = {"container": statusDesktop_mainWindow_overlay, "type": "DropShadow", "unnamed": 1, "visible": True} \ No newline at end of file diff --git a/test/ui-pytest/gui/objects_map/main_names.py b/test/ui-pytest/gui/objects_map/main_names.py new file mode 100644 index 0000000000..f618a5ada9 --- /dev/null +++ b/test/ui-pytest/gui/objects_map/main_names.py @@ -0,0 +1,14 @@ +statusDesktop_mainWindow = {"name": "mainWindow", "type": "StatusWindow", "visible": True} +statusDesktop_mainWindow_overlay = {"container": statusDesktop_mainWindow, "type": "Overlay", "unnamed": 1, "visible": True} +splashScreen = {"container": statusDesktop_mainWindow, "objectName": "splashScreen", "type": "DidYouKnowSplashScreen"} + +# Navigation Panel +mainWindow_StatusAppNavBar = {"container": statusDesktop_mainWindow, "type": "StatusAppNavBar", "unnamed": 1, "visible": True} +messages_navbar_StatusNavBarTabButton = {"checkable": True, "container": mainWindow_StatusAppNavBar, "objectName": "Messages-navbar", "type": "StatusNavBarTabButton", "visible": True} +communities_Portal_navbar_StatusNavBarTabButton = {"checkable": True, "container": mainWindow_StatusAppNavBar, "objectName": "Communities Portal-navbar", "type": "StatusNavBarTabButton", "visible": True} +wallet_navbar_StatusNavBarTabButton = {"checkable": True, "container": mainWindow_StatusAppNavBar, "objectName": "Wallet-navbar", "type": "StatusNavBarTabButton", "visible": True} +settings_navbar_StatusNavBarTabButton = {"checkable": True, "container": mainWindow_StatusAppNavBar, "objectName": "Settings-navbar", "type": "StatusNavBarTabButton", "visible": True} +mainWindow_ProfileNavBarButton = {"container": statusDesktop_mainWindow, "objectName": "statusProfileNavBarTabButton", "type": "StatusNavBarTabButton", "visible": True} + +# Banners +secureYourSeedPhraseBanner_ModuleWarning = {"container": statusDesktop_mainWindow, "objectName": "secureYourSeedPhraseBanner", "type": "ModuleWarning", "visible": True} diff --git a/test/ui-pytest/gui/objects_map/onboarding_names.py b/test/ui-pytest/gui/objects_map/onboarding_names.py new file mode 100755 index 0000000000..33c869f8d4 --- /dev/null +++ b/test/ui-pytest/gui/objects_map/onboarding_names.py @@ -0,0 +1,56 @@ +from . main_names import * + +mainWindow_onboardingBackButton_StatusRoundButton = {"container": statusDesktop_mainWindow, "objectName": "onboardingBackButton", "type": "StatusRoundButton", "visible": True} + +# Allow Notification View +mainWindow_AllowNotificationsView = {"container": statusDesktop_mainWindow, "type": "AllowNotificationsView", "unnamed": 1, "visible": True} +mainWindow_allowNotificationsOnboardingOkButton = {"container": mainWindow_AllowNotificationsView, "objectName": "allowNotificationsOnboardingOkButton", "type": "StatusButton", "visible": True} + +# Welcome View +mainWindow_WelcomeView = {"container": statusDesktop_mainWindow, "type": "WelcomeView", "unnamed": 1, "visible": True} +mainWindow_I_am_new_to_Status_StatusBaseText = {"container": mainWindow_WelcomeView, "objectName": "welcomeViewIAmNewToStatusButton", "type": "StatusButton"} +mainWindow_I_already_use_Status_StatusBaseText = {"container": mainWindow_WelcomeView, "objectName": "welcomeViewIAlreadyUseStatusButton", "type": "StatusFlatButton", "visible": True} + +# Get Keys View +mainWindow_KeysMainView = {"container": statusDesktop_mainWindow, "type": "KeysMainView", "unnamed": 1, "visible": True} +mainWindow_Generate_new_keys_StatusButton = {"checkable": False, "container": mainWindow_KeysMainView, "objectName": "keysMainViewPrimaryActionButton", "type": "StatusButton", "visible": True} + +# Your Profile View +mainWindow_InsertDetailsView = {"container": statusDesktop_mainWindow, "type": "InsertDetailsView", "unnamed": 1, "visible": True} +updatePicButton_StatusRoundButton = {"container": mainWindow_InsertDetailsView, "id": "updatePicButton", "type": "StatusRoundButton", "unnamed": 1, "visible": True} +mainWindow_CanvasItem = {"container": mainWindow_InsertDetailsView, "type": "CanvasItem", "unnamed": 1, "visible": True} +mainWindow_Next_StatusButton = {"container": statusDesktop_mainWindow, "objectName": "onboardingDetailsViewNextButton", "type": "StatusButton", "visible": True, "enabled": True} +mainWindow_inputLayout_ColumnLayout = {"container": statusDesktop_mainWindow, "id": "inputLayout", "type": "ColumnLayout", "unnamed": 1, "visible": True} +mainWindow_statusBaseInput_StatusBaseInput = {"container": mainWindow_inputLayout_ColumnLayout, "objectName": "onboardingDisplayNameInput", "type": "TextEdit", "visible": True} +mainWindow_errorMessage_StatusBaseText = {"container": mainWindow_inputLayout_ColumnLayout, "type": "StatusBaseText", "unnamed": 1, "visible": True} + +# Your emojihash and identicon ring +mainWindow_welcomeScreenUserProfileImage_StatusSmartIdenticon = {"container": mainWindow_InsertDetailsView, "objectName": "welcomeScreenUserProfileImage", "type": "StatusSmartIdenticon", "visible": True} +mainWindow_insertDetailsViewChatKeyTxt_StyledText = {"container": mainWindow_InsertDetailsView, "objectName": "insertDetailsViewChatKeyTxt", "type": "StyledText", "visible": True} +mainWindow_EmojiHash = {"container": mainWindow_InsertDetailsView, "type": "EmojiHash", "unnamed": 1, "visible": True} +mainWindow_userImageCopy_StatusSmartIdenticon = {"container": mainWindow_InsertDetailsView, "id": "userImageCopy", "type": "StatusSmartIdenticon", "unnamed": 1, "visible": True} + + +# Create Password View +mainWindow_CreatePasswordView = {"container": statusDesktop_mainWindow, "type": "CreatePasswordView", "unnamed": 1, "visible": True} +mainWindow_passwordViewNewPassword = {"container": mainWindow_CreatePasswordView, "echoMode": 2, "objectName": "passwordViewNewPassword", "type": "StatusPasswordInput", "visible": True} +mainWindow_passwordViewNewPasswordConfirm = {"container": mainWindow_CreatePasswordView, "echoMode": 2, "objectName": "passwordViewNewPasswordConfirm", "type": "StatusPasswordInput", "visible": True} +mainWindow_Create_password_StatusButton = {"checkable": False, "container": mainWindow_CreatePasswordView, "objectName": "onboardingCreatePasswordButton", "type": "StatusButton", "visible": True, "enabled": True} + +# Confirm Password View +mainWindow_ConfirmPasswordView = {"container": statusDesktop_mainWindow, "type": "ConfirmPasswordView", "unnamed": 1,"visible": True} +mainWindow_confirmAgainPasswordInput = {"container": mainWindow_ConfirmPasswordView, "objectName": "confirmAgainPasswordInput", "type": "StatusPasswordInput", "visible": True} +mainWindow_Finalise_Status_Password_Creation_StatusButton = {"checkable": False, "container": mainWindow_ConfirmPasswordView, "objectName": "confirmPswSubmitBtn", "type": "StatusButton", "visible": True} + +# Login View +mainWindow_LoginView = {"container": statusDesktop_mainWindow, "type": "LoginView", "unnamed": 1, "visible": True} +loginView_submitBtn = {"container": mainWindow_LoginView, "type": "StatusRoundButton", "visible": True} +loginView_passwordInput = {"container": mainWindow_LoginView, "objectName": "loginPasswordInput", "type": "StyledTextField"} +loginView_currentUserNameLabel = {"container": mainWindow_LoginView, "objectName": "currentUserNameLabel", "type": "StatusBaseText"} +loginView_changeAccountBtn = {"container": mainWindow_LoginView, "objectName": "loginChangeAccountButton", "type": "StatusFlatRoundButton"} +accountsView_accountListPanel = {"container": statusDesktop_mainWindow, "objectName": "LoginView_AccountsRepeater", "type": "Repeater", "visible": True} + + +# Touch ID Auth View +mainWindow_TouchIDAuthView = {"container": statusDesktop_mainWindow, "type": "TouchIDAuthView", "unnamed": 1, "visible": True} +mainWindow_touchIdIPreferToUseMyPasswordText = {"container": statusDesktop_mainWindow, "objectName": "touchIdIPreferToUseMyPasswordText", "type": "StatusBaseText"} diff --git a/test/ui-pytest/gui/objects_map/os_names.py b/test/ui-pytest/gui/objects_map/os_names.py new file mode 100644 index 0000000000..cc02ef6588 --- /dev/null +++ b/test/ui-pytest/gui/objects_map/os_names.py @@ -0,0 +1,25 @@ +""" MAC """ +# Open Files Dialog +mainWindow = {"AXRole": "AXWindow", "AXMain": True} +openFileDialog = {"container": mainWindow, "AXRole": "AXSheet", "AXIdentifier": "open-panel"} +openButton = {"container": openFileDialog, "AXRole": "AXButton", "AXIdentifier": "OKButton"} + +# Go To Dialog +goToDialog = {"container": openFileDialog, "AXRole": "AXSheet", "AXIdentifier": "GoToWindow"} +pathTextField = {"container": goToDialog, "AXRole": "AXTextField", "AXIdentifier": "PathTextField"} + +""" WINDOWS """ +# Open File Dialog +file_Dialog = {"type": "Dialog"} +choose_file_Edit = {"container": file_Dialog, "type": "Edit"} +choose_Open_Button = {"container": file_Dialog, "text": "Open", "type": "Button"} + +""" LINUX """ +# Open File Dialog +# Select Image Dialog +please_choose_an_image_QQuickWindow = {"type": "QQuickWindow", "unnamed": 1, "visible": True} +please_choose_an_image_Open_Button = {"container": please_choose_an_image_QQuickWindow, "id": "okButton", "type": "Button", "unnamed": 1, "visible": True} +please_choose_an_image_titleBar_ToolBar = {"container": please_choose_an_image_QQuickWindow, "id": "titleBar", "type": "ToolBar", "unnamed": 1, "visible": True} +titleBar_textInput_TextInputWithHandles = {"container": please_choose_an_image_QQuickWindow, "echoMode": 0, "id": "textInput", "type": "TextInputWithHandles", "unnamed": 1, "visible": True} +view_listView_ListView = {"container": please_choose_an_image_QQuickWindow, "id": "listView", "type": "ListView", "unnamed": 1, "visible": True} +rowitem_Text = {"container": view_listView_ListView, "type": "Text", "unnamed": 1, "visible": True} diff --git a/test/ui-pytest/gui/screens/onboarding.py b/test/ui-pytest/gui/screens/onboarding.py new file mode 100755 index 0000000000..e3b4366df8 --- /dev/null +++ b/test/ui-pytest/gui/screens/onboarding.py @@ -0,0 +1,249 @@ +import logging +import time +from abc import abstractmethod + +import allure +import cv2 + +import configs.testpath +import constants.tesseract +import driver +from gui.components.os.open_file_dialogs import OpenFileDialog +from gui.components.profile_picture_popup import ProfilePicturePopup +from gui.elements.qt.button import Button +from gui.elements.qt.object import QObject +from gui.elements.qt.text_edit import TextEdit +from gui.elements.qt.text_label import TextLabel +from scripts.tools.image import Image +from scripts.utils.system_path import SystemPath + +_logger = logging.getLogger(__name__) + + +class AllowNotificationsView(QObject): + + def __init__(self): + super(AllowNotificationsView, self).__init__('mainWindow_AllowNotificationsView') + self._allow_button = Button('mainWindow_allowNotificationsOnboardingOkButton') + + @allure.step("Allow Notifications") + def allow(self): + self._allow_button.click() + self.wait_until_hidden() + + +class WelcomeScreen(QObject): + + def __init__(self): + super(WelcomeScreen, self).__init__('mainWindow_WelcomeView') + self._new_user_button = Button('mainWindow_I_am_new_to_Status_StatusBaseText') + self._existing_user_button = Button('mainWindow_I_already_use_Status_StatusBaseText') + + @allure.step('Open Keys view') + def get_keys(self) -> 'KeysView': + self._new_user_button.click() + time.sleep(1) + return KeysView().wait_until_appears() + + +class OnboardingScreen(QObject): + + def __init__(self, object_name): + super(OnboardingScreen, self).__init__(object_name) + self._back_button = Button('mainWindow_onboardingBackButton_StatusRoundButton') + + @abstractmethod + def back(self): + pass + + +class KeysView(OnboardingScreen): + + def __init__(self): + super(KeysView, self).__init__('mainWindow_KeysMainView') + self._generate_key_button = Button('mainWindow_Generate_new_keys_StatusButton') + + @allure.step('Open Profile view') + def generate_new_keys(self) -> 'YourProfileView': + self._generate_key_button.click() + return YourProfileView().wait_until_appears() + + @allure.step('Go back') + def back(self) -> WelcomeScreen: + self._back_button.click() + return WelcomeScreen().wait_until_appears() + + +class YourProfileView(OnboardingScreen): + + def __init__(self): + super(YourProfileView, self).__init__('mainWindow_InsertDetailsView') + self._upload_picture_button = Button('updatePicButton_StatusRoundButton') + self._profile_image = QObject('mainWindow_CanvasItem') + self._display_name_text_field = TextEdit('mainWindow_statusBaseInput_StatusBaseInput') + self._erros_text_label = TextLabel('mainWindow_errorMessage_StatusBaseText') + self._next_button = Button('mainWindow_Next_StatusButton') + + @property + @allure.step('Get profile image') + def profile_image(self) -> Image: + return self._profile_image.image + + @property + @allure.step('Get error messages') + def error_message(self) -> str: + return self._erros_text_label.text if self._erros_text_label.is_visible else '' + + @allure.step('Set user display name') + def set_display_name(self, value: str): + self._display_name_text_field.clear().text = value + return self + + @allure.step('Set user image') + def set_user_image(self, fp: SystemPath) -> ProfilePicturePopup: + allure.attach(name='User image', body=fp.read_bytes(), attachment_type=allure.attachment_type.PNG) + self._upload_picture_button.hover() + self._upload_picture_button.click() + file_dialog = OpenFileDialog().wait_until_appears() + file_dialog.open_file(fp) + return ProfilePicturePopup().wait_until_appears() + + @allure.step('Open Emoji and Icon view') + def next(self) -> 'EmojiAndIconView': + self._next_button.click() + time.sleep(1) + return EmojiAndIconView() + + @allure.step('Go back') + def back(self): + self._back_button.click() + return KeysView().wait_until_appears() + + +class EmojiAndIconView(OnboardingScreen): + + def __init__(self): + super(EmojiAndIconView, self).__init__('mainWindow_InsertDetailsView') + self._profile_image = QObject('mainWindow_welcomeScreenUserProfileImage_StatusSmartIdenticon') + self._chat_key_text_label = TextLabel('mainWindow_insertDetailsViewChatKeyTxt_StyledText') + self._next_button = Button('mainWindow_Next_StatusButton') + self._emoji_hash = QObject('mainWindow_EmojiHash') + self._identicon_ring = QObject('mainWindow_userImageCopy_StatusSmartIdenticon') + + @property + @allure.step('Get profile image icon') + def profile_image(self) -> Image: + self._profile_image.image.update_view() + return self._profile_image.image + + @property + @allure.step('Get profile image icon without identicon ring') + def cropped_profile_image(self) -> Image: + # Profile image without identicon_ring + self._profile_image.image.update_view() + self._profile_image.image.crop( + driver.UiTypes.ScreenRectangle( + 20, 20, self._profile_image.image.width - 40, self._profile_image.image.height - 40 + )) + return self._profile_image.image + + @property + @allure.step('Get chat key') + def chat_key(self) -> str: + return self._chat_key_text_label.text.split(':')[1].strip() + + @property + @allure.step('Get emoji hash image') + def emoji_hash(self) -> Image: + return self._emoji_hash.image + + @property + @allure.step('Verify: Identicon ring visible') + def is_identicon_ring_visible(self): + return self._identicon_ring.is_visible + + @allure.step('Open Create password view') + def next(self) -> 'CreatePasswordView': + self._next_button.click() + time.sleep(1) + return CreatePasswordView().wait_until_appears() + + @allure.step('Go back') + def back(self): + self._back_button.click() + return YourProfileView().wait_until_appears() + + @allure.step + @allure.step('Verify: User image contains text') + def is_user_image_contains(self, text: str): + crop = driver.UiTypes.ScreenRectangle( + 20, 20, self._profile_image.image.width - 40, self._profile_image.image.height - 40 + ) + return self.profile_image.has_text(text, constants.tesseract.text_on_profile_image, crop=crop) + + @allure.step + @allure.step('Verify: User image background color') + def is_user_image_background_white(self): + crop = driver.UiTypes.ScreenRectangle( + 20, 20, self._profile_image.image.width - 40, self._profile_image.image.height - 40 + ) + return self.profile_image.has_color(constants.Color.WHITE, crop=crop) + + +class CreatePasswordView(OnboardingScreen): + + def __init__(self): + super(CreatePasswordView, self).__init__('mainWindow_CreatePasswordView') + self._new_password_text_field = TextEdit('mainWindow_passwordViewNewPassword') + self._confirm_password_text_field = TextEdit('mainWindow_passwordViewNewPasswordConfirm') + self._create_button = Button('mainWindow_Create_password_StatusButton') + + @property + @allure.step('Verify: Create password button enabled') + def is_create_password_button_enabled(self) -> bool: + # Verification is_enable can not be used + # LookupError, because of "Enable: True" in object real name, if button disabled + return self._create_button.is_visible + + @allure.step('Set password and open Confirmation password view') + def create_password(self, value: str) -> 'ConfirmPasswordView': + self._new_password_text_field.clear().text = value + self._confirm_password_text_field.clear().text = value + self._create_button.click() + time.sleep(1) + return ConfirmPasswordView().wait_until_appears() + + @allure.step('Go back') + def back(self): + self._back_button.click() + return EmojiAndIconView().wait_until_appears() + + +class ConfirmPasswordView(OnboardingScreen): + + def __init__(self): + super(ConfirmPasswordView, self).__init__('mainWindow_ConfirmPasswordView') + self._confirm_password_text_field = TextEdit('mainWindow_confirmAgainPasswordInput') + self._confirm_button = Button('mainWindow_Finalise_Status_Password_Creation_StatusButton') + + @allure.step('Confirm password') + def confirm_password(self, value: str): + self._confirm_password_text_field.text = value + self._confirm_button.click() + + @allure.step('Go back') + def back(self): + self._back_button.click() + return CreatePasswordView().wait_until_appears() + + +class TouchIDAuthView(OnboardingScreen): + + def __init__(self): + super(TouchIDAuthView, self).__init__('mainWindow_TouchIDAuthView') + self._prefer_password_button = Button('mainWindow_touchIdIPreferToUseMyPasswordText') + + @allure.step('Select prefer password') + def prefer_password(self): + self._prefer_password_button.click() + self.wait_until_hidden() diff --git a/test/ui-pytest/pytest.ini b/test/ui-pytest/pytest.ini index 679ca18d34..29aa9918e9 100644 --- a/test/ui-pytest/pytest.ini +++ b/test/ui-pytest/pytest.ini @@ -3,5 +3,7 @@ log_format = %(asctime)s.%(msecs)03d %(levelname)7s %(name)s %(message).5000s log_cli = true log_cli_level = INFO +addopts = --disable-warnings + markers = - self: framework tests + smoke: Smoke tests diff --git a/test/ui-pytest/requirements.txt b/test/ui-pytest/requirements.txt index 70613be0cf..4a99e3c485 100644 --- a/test/ui-pytest/requirements.txt +++ b/test/ui-pytest/requirements.txt @@ -1 +1,9 @@ pytest==7.4.0 +psutil==5.9.5 +pillow==10.0.0 +opencv-python-headless==4.8.0.74 +numpy~=1.25.1 +pytesseract==0.3.10 +atomacos==3.3.0; platform_system == "Darwin" +allure-pytest==2.13.2 +testrail-api==1.12.0 diff --git a/test/ui-pytest/scripts/tools/image.py b/test/ui-pytest/scripts/tools/image.py new file mode 100755 index 0000000000..73ce7fa26f --- /dev/null +++ b/test/ui-pytest/scripts/tools/image.py @@ -0,0 +1,224 @@ +import logging +import time +import typing +from datetime import datetime + +import allure +import cv2 +import numpy as np +import pytesseract +from PIL import ImageGrab + +import configs +import constants +import driver +from configs.system import IS_LIN +from scripts.tools.ocv import Ocv +from scripts.utils.system_path import SystemPath + +_logger = logging.getLogger(__name__) + + +class Image: + + def __init__(self, object_name: dict): + self.object_name = object_name + self._view = None + + @property + @allure.step('Get image view') + def view(self) -> np.ndarray: + return self._view + + @property + @allure.step('Get image height') + def height(self) -> int: + return self.view.shape[0] + + @property + @allure.step('Get image width') + def width(self) -> int: + return self.view.shape[1] + + @property + @allure.step('Get image is grayscale') + def is_grayscale(self) -> bool: + return self.view.ndim == 2 + + @allure.step('Set image in grayscale') + def set_grayscale(self) -> 'Image': + if not self.is_grayscale: + self._view = cv2.cvtColor(self.view, cv2.COLOR_BGR2GRAY) + return self + + @allure.step('Grab image view from object') + def update_view(self): + _logger.debug(f'Image view was grab from: {self.object_name}') + rect = driver.object.globalBounds(driver.waitForObject(self.object_name)) + img = ImageGrab.grab( + bbox=(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height), + xdisplay=":0" if IS_LIN else None + ) + self._view = cv2.cvtColor(np.array(img), cv2.COLOR_BGR2RGB) + + @allure.step('Save image') + def save(self, path: SystemPath, force: bool = False): + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists() and not force: + raise FileExistsError(path) + cv2.imwrite(str(path), self.view) + + @allure.step('Compare images') + def compare( + self, expected: np.ndarray, threshold: float = 0.99) -> bool: + correlation = Ocv.compare_images(self.view, expected) + result = correlation >= threshold + _logger.info(f'Images equals on: {abs(round(correlation, 4) * 100)}%') + + if result: + _logger.info(f'Screenshot comparison passed') + else: + configs.testpath.TEST_ARTIFACTS.mkdir(parents=True, exist_ok=True) + diff = Ocv.draw_contours(self.view, expected) + + actual_fp = configs.testpath.TEST_ARTIFACTS / f'actual_image.png' + expected_fp = configs.testpath.TEST_ARTIFACTS / f'expected_image.png' + diff_fp = configs.testpath.TEST_ARTIFACTS / f'diff_image.png' + + self.save(actual_fp, force=True) + cv2.imwrite(str(expected_fp), expected) + cv2.imwrite(str(diff_fp), diff) + + allure.attach(name='actual', body=actual_fp.read_bytes(), attachment_type=allure.attachment_type.PNG) + allure.attach(name='expected', body=expected_fp.read_bytes(), attachment_type=allure.attachment_type.PNG) + allure.attach(name='diff', body=diff_fp.read_bytes(), attachment_type=allure.attachment_type.PNG) + + _logger.info( + f"Screenshot comparison failed.\n" + f"Actual, Diff and Expected screenshots are saved:\n" + f"{configs.testpath.TEST_ARTIFACTS.relative_to(configs.testpath.ROOT)}.") + return result + + @allure.step('Crop image') + def crop(self, rect: driver.UiTypes.ScreenRectangle): + assert rect.x + rect.width < self.width + assert rect.y + rect.height < self.height + self._view = self.view[rect.y: (rect.y + rect.height), rect.x: (rect.x + rect.width)] + + @allure.step('Parse text on image') + def to_string(self, custom_config: str): + text: str = pytesseract.image_to_string(self.view, config=custom_config) + _logger.debug(f'Text on image: {text}') + return text + + @allure.step('Verify: Image contains text: {1}') + def has_text(self, text: str, criteria: str, crop: driver.UiTypes.ScreenRectangle = None) -> bool: + self.update_view() + if crop: + self.crop(crop) + + # Search text on image converted in gray color + self.set_grayscale() + fp_gray = configs.testpath.TEST_ARTIFACTS / f'search_region_in_gray_color.png' + self.save(fp_gray, force=True) + if text.lower() in self.to_string(criteria).lower(): + allure.attach(name='search_region', body=fp_gray.read_bytes(), attachment_type=allure.attachment_type.PNG) + return True + + # Search text on image with inverted color + self._view = cv2.bitwise_not(self.view) + fp_invert = configs.testpath.TEST_ARTIFACTS / f'search_region_in_inverted_color.png' + self.save(fp_invert, force=True) + if text.lower() in self.to_string(criteria).lower(): + allure.attach(name='search_region', body=fp_invert.read_bytes(), attachment_type=allure.attachment_type.PNG) + return True + return False + + @allure.step('Search color on image') + def has_color(self, color: constants.Color, denoise: int = 10, crop: driver.UiTypes.ScreenRectangle = None) -> bool: + self.update_view() + if crop: + self.crop(crop) + + initial_view = configs.testpath.TEST_ARTIFACTS / f'{color.name}.png' + self.save(initial_view) + allure.attach(name='search_region', body=initial_view.read_bytes(), attachment_type=allure.attachment_type.PNG) + + contours = self._get_color_contours(color, denoise, apply=True) + + mask_view = configs.testpath.TEST_ARTIFACTS / f'{color.name}_mask.png' + self.save(mask_view) + allure.attach(name='contours', body=mask_view.read_bytes(), attachment_type=allure.attachment_type.PNG) + + self._view = None + return len(contours) >= 1 + + @allure.step('Apply contours with found color on image') + def _get_color_contours( + self, + color: constants.Color, + denoise: int = 10, + apply: bool = False + ) -> typing.List[driver.UiTypes.ScreenRectangle]: + if not self.is_grayscale: + view = cv2.cvtColor(self.view, cv2.COLOR_BGR2HSV) + else: + view = self.view + boundaries = constants.boundaries[color] + + if color == constants.Color.RED: + mask = None + for bond in boundaries: + lower_range = np.array(bond[0]) + upper_range = np.array(bond[1]) + _mask = cv2.inRange(view, lower_range, upper_range) + mask = _mask if mask is None else mask + _mask + else: + lower_range = np.array(boundaries[0]) + upper_range = np.array(boundaries[1]) + mask = cv2.inRange(view, lower_range, upper_range) + + contours = [] + _contours = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + _contours = _contours[0] if len(_contours) == 2 else _contours[1] + for _contour in _contours: + x, y, w, h = cv2.boundingRect(_contour) + # To remove small artifacts, less than denoise variable value + if w * h < denoise: + continue + contours.append(driver.UiTypes.ScreenRectangle(x, y, w, h)) + + if apply: + self._view = cv2.bitwise_and(self.view, self.view, mask=mask) + for contour in contours: + cv2.rectangle( + self.view, + (contour.x, contour.y), + (contour.x + contour.width, contour.y + contour.height), + (36, 255, 12), 2) + + return contours + + +@allure.step('Compare images') +def compare(actual: Image, + expected: typing.Union[str, SystemPath, Image], + threshold: float = 0.99, + timout_sec: int = 1 + ): + if isinstance(expected, str): + expected_fp = configs.testpath.TEST_VP / configs.system.OS_ID / expected + if not expected_fp.exists(): + expected_fp = configs.testpath.TEST_VP / expected + expected = expected_fp + if isinstance(expected, SystemPath): + assert expected.exists(), f'File: {expected} not found' + expected = cv2.imread(str(expected)) + else: + expected = expected.view + start = datetime.now() + while not actual.compare(expected, threshold): + time.sleep(1) + assert (datetime.now() - start).seconds < timout_sec, 'Comparison failed' + _logger.info(f'Screenshot comparison passed') + diff --git a/test/ui-pytest/scripts/tools/ocv.py b/test/ui-pytest/scripts/tools/ocv.py new file mode 100755 index 0000000000..6e675cc4bc --- /dev/null +++ b/test/ui-pytest/scripts/tools/ocv.py @@ -0,0 +1,27 @@ +import cv2 +import numpy as np + + +class Ocv: + + @classmethod + def compare_images(cls, lhd: np.ndarray, rhd: np.ndarray) -> float: + res = cv2.matchTemplate(lhd, rhd, cv2.TM_CCOEFF_NORMED) + _, correlation, _, _ = cv2.minMaxLoc(res) + return correlation + + @classmethod + def draw_contours(cls, lhd: np.ndarray, rhd: np.ndarray) -> np.ndarray: + view = rhd.copy() + + lhd = cv2.cvtColor(lhd, cv2.COLOR_BGRA2GRAY) + _, thresh = cv2.threshold(lhd, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) + contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) + cv2.drawContours(view, contours, -1, (0, 0, 255), 1) + + rhd = cv2.cvtColor(rhd, cv2.COLOR_BGRA2GRAY) + _, thresh = cv2.threshold(rhd, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) + contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) + cv2.drawContours(view, contours, -1, (0, 255, 0), 1) + + return view diff --git a/test/ui-pytest/scripts/utils/local_system.py b/test/ui-pytest/scripts/utils/local_system.py new file mode 100644 index 0000000000..ff0f402604 --- /dev/null +++ b/test/ui-pytest/scripts/utils/local_system.py @@ -0,0 +1,114 @@ +import logging +import os +import signal +import subprocess +import time +from collections import namedtuple +from datetime import datetime + +import allure +import psutil + +import configs +from configs.system import IS_WIN + +_logger = logging.getLogger(__name__) + +process_info = namedtuple('RunInfo', ['pid', 'name', 'create_time']) + + +@allure.step('Find process by name') +def find_process_by_name(process_name: str): + processes = [] + for proc in psutil.process_iter(): + try: + if process_name.lower().split('.')[0] == proc.name().lower().split('.')[0]: + processes.append(process_info( + proc.pid, + proc.name(), + datetime.fromtimestamp(proc.create_time()).strftime("%H:%M:%S.%f")) + ) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + pass + return processes + + +@allure.step('Kill process by name') +def kill_process_by_name(process_name: str, verify: bool = True, timeout_sec: int = 10): + _logger.info(f'Closing process: {process_name}') + processes = find_process_by_name(process_name) + for process in processes: + try: + os.kill(process.pid, signal.SIGILL if IS_WIN else signal.SIGKILL) + except PermissionError as err: + _logger.info(f'Close "{process}" error: {err}') + if verify and processes: + wait_for_close(process_name, timeout_sec) + + +@allure.step('Wait for process start') +def wait_for_started(process_name: str, timeout_sec: int = configs.timeouts.PROCESS_TIMEOUT_SEC): + started_at = time.monotonic() + while True: + process = find_process_by_name(process_name) + if process: + _logger.info(f'Process started: {process_name}, start time: {process[0].create_time}') + return process[0] + time.sleep(1) + _logger.debug(f'Waiting time: {int(time.monotonic() - started_at)} seconds') + assert time.monotonic() - started_at < timeout_sec, f'Start process error: {process_name}' + + +@allure.step('Wait for process close') +def wait_for_close(process_name: str, timeout_sec: int = configs.timeouts.PROCESS_TIMEOUT_SEC): + started_at = time.monotonic() + while True: + if not find_process_by_name(process_name): + break + time.sleep(1) + assert time.monotonic() - started_at < timeout_sec, f'Close process error: {process_name}' + _logger.info(f'Process closed: {process_name}') + + +@allure.step('System execute command') +def execute( + command: list, + shell=True, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + check=False +): + def _is_process_exists(_process) -> bool: + return _process.poll() is None + + def _wait_for_execution(_process): + while _is_process_exists(_process): + time.sleep(1) + + def _get_output(_process): + _wait_for_execution(_process) + return _process.communicate() + + command = " ".join(str(atr) for atr in command) + _logger.info(f'Execute: {command}') + process = subprocess.Popen(command, shell=shell, stderr=stderr, stdout=stdout) + if check and process.returncode != 0: + stdout, stderr = _get_output(process) + raise RuntimeError(stderr) + return process.pid + + +@allure.step('System run command') +def run( + command: list, + shell=True, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + timeout_sec=configs.timeouts.PROCESS_TIMEOUT_SEC, + check=True +): + command = " ".join(str(atr) for atr in command) + _logger.info(f'Execute: {command}') + process = subprocess.run(command, shell=shell, stderr=stderr, stdout=stdout, timeout=timeout_sec) + if check and process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, command, process.stdout, process.stderr) diff --git a/test/ui-pytest/scripts/utils/system_path.py b/test/ui-pytest/scripts/utils/system_path.py index a12b24ed28..f8516f820b 100644 --- a/test/ui-pytest/scripts/utils/system_path.py +++ b/test/ui-pytest/scripts/utils/system_path.py @@ -3,6 +3,8 @@ import os import pathlib import shutil +import allure + _logger = logging.getLogger(__name__) @@ -10,5 +12,10 @@ class SystemPath(pathlib.Path): _accessor = pathlib._normal_accessor # noqa _flavour = pathlib._windows_flavour if os.name == 'nt' else pathlib._posix_flavour # noqa + @allure.step('Delete path') def rmtree(self, ignore_errors=False): shutil.rmtree(self, ignore_errors=ignore_errors) + + @allure.step('Copy path') + def copy_to(self, destination: 'SystemPath'): + shutil.copytree(self, destination, dirs_exist_ok=True) diff --git a/test/ui-pytest/tests/fixtures/aut.py b/test/ui-pytest/tests/fixtures/aut.py new file mode 100644 index 0000000000..326afe291e --- /dev/null +++ b/test/ui-pytest/tests/fixtures/aut.py @@ -0,0 +1,32 @@ +from datetime import datetime + +import allure +import pytest + +import configs +from driver.aut import AUT +from gui.main_window import MainWindow +from scripts.utils import system_path + + +@pytest.fixture() +def aut() -> AUT: + if not configs.APP_DIR.exists(): + pytest.exit(f"Application not found: {configs.APP_DIR}") + _aut = AUT() + yield _aut + + +@pytest.fixture +def user_data(request) -> system_path.SystemPath: + user_data = configs.testpath.STATUS_DATA / f'app_{datetime.now():%H%M%S_%f}' / 'data' + if hasattr(request, 'param'): + system_path.SystemPath(request.param).copy_to(user_data) + yield user_data + + +@pytest.fixture +def main_window(aut: AUT, user_data): + aut.launch(f'-d={user_data.parent}') + yield MainWindow().wait_until_appears().prepare() + aut.detach().stop() diff --git a/test/ui-pytest/tests/fixtures/path.py b/test/ui-pytest/tests/fixtures/path.py index 71ba2ec3ed..9560a36058 100644 --- a/test/ui-pytest/tests/fixtures/path.py +++ b/test/ui-pytest/tests/fixtures/path.py @@ -13,6 +13,13 @@ _logger = logging.getLogger(__name__) def generate_test_data(request): test_path, test_name, test_params = generate_test_info(request.node) configs.testpath.TEST = configs.testpath.RUN / test_path / test_name + node_dir = configs.testpath.TEST / test_params + configs.testpath.TEST_ARTIFACTS = node_dir / 'artifacts' + configs.testpath.TEST_VP = configs.testpath.VP / test_path / test_name + _logger.info( + f'\nArtifacts directory:\t{configs.testpath.TEST_ARTIFACTS.relative_to(configs.testpath.ROOT)}' + f'\nVerification points directory:\t{configs.testpath.TEST_VP.relative_to(configs.testpath.ROOT)}' + ) _logger.info(f'Start test: {test_name}') @@ -25,7 +32,7 @@ def generate_test_info(node): @pytest.fixture(scope='session') -def run_dir(): +def prepare_test_directory(): keep_results = 5 run_name_pattern = 'run_????????_??????' runs = list(sorted(configs.testpath.RESULTS.glob(run_name_pattern))) diff --git a/test/ui-pytest/tests/fixtures/squish.py b/test/ui-pytest/tests/fixtures/squish.py new file mode 100644 index 0000000000..7b767c9151 --- /dev/null +++ b/test/ui-pytest/tests/fixtures/squish.py @@ -0,0 +1,20 @@ +import pytest + +from driver.server import SquishServer + + +@pytest.fixture(scope='session') +def start_squish_server(): + squish_server = SquishServer() + squish_server.stop() + attempt = 3 + while True: + try: + squish_server.start() + break + except AssertionError as err: + attempt -= 1 + if not attempt: + pytest.exit(err) + yield squish_server + squish_server.stop() diff --git a/test/ui-pytest/tests/fixtures/testrail.py b/test/ui-pytest/tests/fixtures/testrail.py new file mode 100644 index 0000000000..ecdf090e04 --- /dev/null +++ b/test/ui-pytest/tests/fixtures/testrail.py @@ -0,0 +1,125 @@ +import logging +import typing + +import pytest +from testrail_api import TestRailAPI + +import configs + +_logger = logging.getLogger(__name__) + +testrail_api = None + +PASS = 1 +FAIL = 5 +RETEST = 4 + + +@pytest.fixture(scope='session') +def init_testrail_api(request): + global testrail_api + if configs.testrail.TESTRAIL_RUN_ID: + _logger.info('TestRail API initializing') + testrail_api = TestRailAPI( + configs.testrail.TESTRAIL_URL, + configs.testrail.TESTRAIL_USER, + configs.testrail.TESTRAIL_PWD + ) + test_case_ids = get_test_ids_in_session(request) + for test_case_id in test_case_ids: + if is_test_case_in_run(test_case_id): + _update_result(test_case_id, RETEST) + _logger.info(f'Test: "{test_case_id}" marked as "Retest"') + else: + _logger.info(f'Report result for test case: {test_case_id} skipped, not in test run') + else: + _logger.info('TestRail report skipped') + + +@pytest.fixture +def check_result(request): + yield + if configs.testrail.TESTRAIL_RUN_ID: + item = request.node + test_case_ids = _find_test_case_id_markers(request) + for test_case_id in test_case_ids: + if is_test_case_in_run(test_case_id): + current_test_status = _get_test_case_status(test_case_id) + if item.rep_call.failed: + if current_test_status != FAIL: + _update_result(test_case_id, FAIL) + _update_comment(test_case_id, f"{request.node.name} FAILED") + else: + if current_test_status != FAIL: + _update_result(test_case_id, PASS) + _update_comment(test_case_id, f"{request.node.name} SUCCESS") + + +def _update_result(test_case_id: int, result: int): + testrail_api.results.add_result_for_case( + run_id=configs.testrail.TESTRAIL_RUN_ID, + case_id=test_case_id, + status_id=result, + ) + + +def _update_comment(test_case_id: int, comment: str): + testrail_api.results.add_result_for_case( + run_id=configs.testrail.TESTRAIL_RUN_ID, + case_id=test_case_id, + comment=comment + ) + + +def _find_test_case_id_markers(request) -> typing.List[int]: + for marker in request.node.own_markers: + if marker.name == 'case': + test_case_ids = marker.args + return test_case_ids + return [] + + +def _get_test_case_status(test_case_id: int) -> int: + test_case_results = testrail_api.results.get_results_for_case(configs.testrail.TESTRAIL_RUN_ID, test_case_id) + try: + result = 0 + while True: + last_test_case_status = test_case_results['results'][result]['status_id'] + if last_test_case_status is None: + result += 1 + else: + return last_test_case_status + except: + return RETEST + + +def is_test_case_in_run(test_case_id: int) -> bool: + try: + testrail_api.results.get_results_for_case(configs.testrail.TESTRAIL_RUN_ID, test_case_id) + except Exception as err: + return False + else: + return True + + +def _get_test_cases(): + results = [] + limit = 250 + chunk = 0 + while True: + tests = testrail_api.tests.get_tests(configs.testrail.TESTRAIL_RUN_ID, offset=chunk)['tests'] + results.extend(tests) + if len(tests) == limit: + chunk += limit + else: + return results + + +def get_test_ids_in_session(request): + tests = request.session.items + ids = [] + for test in tests: + for marker in getattr(test, 'own_markers', []): + if getattr(marker, 'name', '') == 'case': + ids.extend(list(marker.args)) + return set(ids) diff --git a/test/ui-pytest/tests/test_onboarding.py b/test/ui-pytest/tests/test_onboarding.py new file mode 100755 index 0000000000..0fe0ab8064 --- /dev/null +++ b/test/ui-pytest/tests/test_onboarding.py @@ -0,0 +1,95 @@ +import logging +import allure +import pytest +from allure import step + +import configs.timeouts +import driver +from gui.components.before_started_popup import BeforeStartedPopUp +from gui.components.profile_picture_popup import shift_image +from gui.components.splash_screen import SplashScreen +from gui.components.welcome_status_popup import WelcomeStatusPopup +from gui.screens.onboarding import AllowNotificationsView, WelcomeScreen, TouchIDAuthView +from scripts.tools import image + +_logger = logging.getLogger(__name__) +pytestmark = allure.suite("Onboarding") + + +@allure.testcase('https://ethstatus.testrail.net/index.php?/cases/view/703421', 'Generate new keys') +@pytest.mark.case(703421) +@pytest.mark.parametrize('user_name, password, user_image', [ + pytest.param('Test-User _1', '*P@ssw0rd*', None), + pytest.param('_1Test-User', '*P@ssw0rd*', 'tv_signal.jpeg', marks=pytest.mark.smoke), + pytest.param('Test-User', '*P@ssw0rd*', 'tv_signal.png'), +]) +def test_generate_new_keys(main_window, user_name, password, user_image: str): + with step('Open Generate new keys view'): + if configs.system.IS_MAC: + AllowNotificationsView().wait_until_appears().allow() + BeforeStartedPopUp().get_started() + wellcome_screen = WelcomeScreen().wait_until_appears() + keys_screen = wellcome_screen.get_keys() + + with step(f'Setup profile with name: {user_name} and image: {user_image}'): + profile_view = keys_screen.generate_new_keys() + profile_view.set_display_name(user_name) + if user_image is not None: + profile_picture_popup = profile_view.set_user_image(configs.testpath.TEST_FILES / user_image) + profile_picture_popup.make_profile_picture(zoom=5, shift=shift_image(0, 200, 200, 0)) + assert not profile_view.error_message + + with step('Open Profile details view'): + details_view = profile_view.next() + + with step('Verify Profile details'): + if user_image is None: + assert not details_view.is_user_image_background_white() + assert driver.waitFor( + lambda: details_view.is_user_image_contains(user_name[:2]), + configs.timeouts.UI_LOAD_TIMEOUT_MSEC + ) + else: + image.compare( + details_view.cropped_profile_image, + configs.testpath.TEST_VP / f'user_image_onboarding.png', + ) + + chat_key = details_view.chat_key + emoji_hash = details_view.emoji_hash + assert details_view.is_identicon_ring_visible + + with step('Finalize onboarding and prepare main screen'): + create_password_view = details_view.next() + assert not create_password_view.is_create_password_button_enabled + confirm_password_view = create_password_view.create_password(password) + confirm_password_view.confirm_password(password) + if configs.system.IS_MAC: + TouchIDAuthView().wait_until_appears().prefer_password() + SplashScreen().wait_until_appears().wait_until_hidden() + WelcomeStatusPopup().confirm() + + with step('Open User Canvas and verify profile'): + user_canvas = main_window.left_panel.open_user_canvas() + assert user_canvas.user_name == user_name + if user_image is None: + assert driver.waitFor( + lambda: user_canvas.is_user_image_contains(user_name[:2]), + configs.timeouts.UI_LOAD_TIMEOUT_MSEC + ) + + with step('Open Profile popup and verify profile'): + profile_popup = user_canvas.open_profile_popup() + assert profile_popup.user_name == user_name + assert profile_popup.chat_key == chat_key + assert profile_popup.emoji_hash.compare(emoji_hash.view) + if user_image is None: + assert driver.waitFor( + lambda: profile_popup.is_user_image_contains(user_name[:2]), + configs.timeouts.UI_LOAD_TIMEOUT_MSEC + ) + else: + image.compare( + profile_popup.cropped_profile_image, + 'user_image_profile.png', + ) diff --git a/test/ui-pytest/tests/test_self.py b/test/ui-pytest/tests/test_self.py index 515e05a3f9..b615fec069 100755 --- a/test/ui-pytest/tests/test_self.py +++ b/test/ui-pytest/tests/test_self.py @@ -1,13 +1,12 @@ import logging -import pytest +import allure import driver _logger = logging.getLogger(__name__) +pytestmark = allure.suite("Self") -@pytest.mark.self -def test_import_squish(): - _logger.info(str(driver.__dict__)) - driver.snooze(1) +def test_start_aut(main_window): + driver.context.detach()