diff --git a/.github/workflows/flake8.yaml b/.github/workflows/flake8.yaml index 8111bb9..dae99be 100644 --- a/.github/workflows/flake8.yaml +++ b/.github/workflows/flake8.yaml @@ -21,6 +21,13 @@ jobs: with: python-version: 3 + - name: Install krb5-config deps + run: sudo apt-get update -y + && sudo apt-get upgrade -y + && sudo apt-get install -y + libkrb5-dev + gcc + - name: Install dependencies run: python3 -m pip install tox diff --git a/SCAutolib/cli_commands.py b/SCAutolib/cli_commands.py index 2c66a40..7743d87 100644 --- a/SCAutolib/cli_commands.py +++ b/SCAutolib/cli_commands.py @@ -6,15 +6,53 @@ from pathlib import Path from sys import exit +from collections import OrderedDict + from SCAutolib import logger, exceptions, schema_user from SCAutolib.controller import Controller from SCAutolib.enums import ReturnCode -@click.group() +def check_conf_path(conf): + return click.Path(exists=True, resolve_path=True)(conf) + + +# In Help output, force the subcommand list to match the order +# listed in this file. Solution was found here: +# https://github.com/pallets/click/issues/513#issuecomment-301046782 +class NaturalOrderGroup(click.Group): + """ + Command group trying to list subcommands in the order they were added. + Example use:: + + @click.group(cls=NaturalOrderGroup) + + If passing dict of commands from other sources, ensure they are of type + OrderedDict and properly ordered, otherwise order of them will be random + and newly added will come to the end. + """ + def __init__(self, name=None, commands=None, **attrs): + if commands is None: + commands = OrderedDict() + elif not isinstance(commands, OrderedDict): + commands = OrderedDict(commands) + click.Group.__init__(self, name=name, + commands=commands, + **attrs) + + def list_commands(self, ctx): + """ + List command names as they are in commands dict. + + If the dict is OrderedDict, it will preserve the order commands + were added. + """ + return self.commands.keys() + + +@click.group(cls=NaturalOrderGroup) @click.option("--conf", "-c", default="./conf.json", - type=click.Path(exists=True, resolve_path=True), show_default=True, help="Path to JSON configuration file.") @click.option('--force', "-f", is_flag=True, default=False, show_default=True, @@ -29,35 +67,13 @@ def cli(ctx, force, verbose, conf): logger.setLevel(verbose) ctx.ensure_object(dict) # Create a dict to store the context ctx.obj["FORCE"] = force # Store the force option in the context - ctx.obj["CONTROLLER"] = Controller(conf) - - -@click.command() -@click.option("--ca-type", "-t", - required=False, - default='all', - type=click.Choice(['all', 'local', 'ipa'], case_sensitive=False), - show_default=True, - help="Type of the CA to be configured. If not set, all CA's " - "from the config file would be configured") -@click.pass_context -def setup_ca(ctx, ca_type): - """ - Configure the CA's in the config file. If more than one CA is - specified, specified CA type would be configured. - """ - cnt = ctx.obj["CONTROLLER"] - if ca_type == 'all': - cnt.setup_local_ca(force=ctx.obj["FORCE"]) - cnt.setup_ipa_client(force=ctx.obj["FORCE"]) - elif ca_type == 'local': - cnt.setup_local_ca(force=ctx.obj["FORCE"]) - elif ca_type == 'ipa': - cnt.setup_ipa_client(force=ctx.obj["FORCE"]) - exit(ReturnCode.SUCCESS.value) + parsed_conf = None + if ctx.invoked_subcommand != "gui": + parsed_conf = check_conf_path(conf) + ctx.obj["CONTROLLER"] = Controller(parsed_conf) -@click.command() +@cli.command() @click.option("--gdm", "-g", required=False, default=False, @@ -85,7 +101,32 @@ def prepare(ctx, gdm, install_missing, graphical): exit(ReturnCode.SUCCESS.value) -@click.command() +@cli.command() +@click.option("--ca-type", "-t", + required=False, + default='all', + type=click.Choice(['all', 'local', 'ipa'], case_sensitive=False), + show_default=True, + help="Type of the CA to be configured. If not set, all CA's " + "from the config file would be configured") +@click.pass_context +def setup_ca(ctx, ca_type): + """ + Configure the CA's in the config file. If more than one CA is + specified, specified CA type would be configured. + """ + cnt = ctx.obj["CONTROLLER"] + if ca_type == 'all': + cnt.setup_local_ca(force=ctx.obj["FORCE"]) + cnt.setup_ipa_client(force=ctx.obj["FORCE"]) + elif ca_type == 'local': + cnt.setup_local_ca(force=ctx.obj["FORCE"]) + elif ca_type == 'ipa': + cnt.setup_ipa_client(force=ctx.obj["FORCE"]) + exit(ReturnCode.SUCCESS.value) + + +@cli.command() @click.argument("name", required=True, default=None) @@ -154,7 +195,7 @@ def setup_user(ctx, name, card_dir, card_type, passwd, pin, user_type): exit(ReturnCode.SUCCESS.value) -@click.command() +@cli.command() @click.pass_context def cleanup(ctx): """ @@ -165,7 +206,113 @@ def cleanup(ctx): exit(ReturnCode.SUCCESS.value) -cli.add_command(setup_ca) -cli.add_command(prepare) -cli.add_command(setup_user) -cli.add_command(cleanup) +@cli.group(cls=NaturalOrderGroup, chain=True) +@click.option("--install-missing", "-i", + required=False, + default=False, + is_flag=True, + help="Install missing packages") +@click.pass_context +def gui(ctx, install_missing): + """ Run GUI Test commands """ + pass + + +@gui.command() +def init(): + """ Initialize GUI for testing """ + return "init" + + +@gui.command() +@click.option("--no", + required=False, + default=False, + is_flag=True, + help="Reverse the action") +@click.argument("name") +def assert_text(name, no): + """ Check if a word is found on the screen """ + if no: + return f"assert_no_text:{name}" + return f"assert_text:{name}" + + +@gui.command() +@click.argument("name") +def click_on(name): + """ Click on object containing word """ + return f"click_on:{name}" + + +@gui.command() +@click.option("--no", + required=False, + default=False, + is_flag=True, + help="Reverse the action") +def check_home_screen(no): + """ Check if screen appears to be the home screen """ + if no: + return "check_no_home_screen" + return "check_home_screen" + + +@gui.command() +@click.argument("keys") +def kb_send(keys): + """ Send key(s) to keyboard """ + return f"kb_send:{keys}" + + +@gui.command() +@click.argument("keys") +def kb_write(keys): + """ Send string to keyboard """ + return f"kb_write:{keys}" + + +@gui.command() +def done(): + """ cleanup after testing """ + return "done" + + +@gui.result_callback() +@click.pass_context +def run_all(ctx, actions, install_missing): + """ Run all cli actions in order """ + ctx.obj["CONTROLLER"].setup_graphical(install_missing, True) + + from SCAutolib.models.gui import GUI + gui = GUI(from_cli=True) + for action in actions: + if "init" in action: + gui.__enter__() + if "assert_text" in action: + assert_text = action.split(":", 1)[1] + gui.assert_text(assert_text) + if "assert_no_text" in action: + assert_text = action.split(":", 1)[1] + gui.assert_no_text(assert_text) + if "click_on" in action: + click_on = action.split(":", 1)[1] + gui.click_on(click_on) + if "check_home_screen" in action: + gui.check_home_screen() + if "check_no_home_screen" in action: + gui.check_home_screen(False) + if "kb_send" in action: + params = action.split(":", 1)[1].split()[0] + gui.kb_send(params) + if "kb_write" in action: + params = action.split(":", 1)[1].split()[0] + gui.kb_write(params) + gui.kb_send('enter') + if "done" in action: + gui.__exit__(None, None, None) + ctx.obj["CONTROLLER"].cleanup() + + +if __name__ == "__main__": + cli() diff --git a/SCAutolib/controller.py b/SCAutolib/controller.py index 52266e6..9b8dece 100644 --- a/SCAutolib/controller.py +++ b/SCAutolib/controller.py @@ -34,7 +34,7 @@ class Controller: def conf_path(self): return self._lib_conf_path - def __init__(self, config: Union[Path, str], params: {} = None): + def __init__(self, config: Union[Path, str] = None, params: {} = None): """ Constructor will parse and check input configuration file. If some required fields in the configuration are missing, CLI parameters @@ -55,15 +55,18 @@ def __init__(self, config: Union[Path, str], params: {} = None): # Check params # Parse config file - self._lib_conf_path = config.absolute() if isinstance(config, Path) \ - else Path(config).absolute() - - with self._lib_conf_path.open("r") as f: - tmp_conf = json.load(f) - if tmp_conf is None: - raise exceptions.SCAutolibException( - "Data are not loaded correctly.") - self.lib_conf = self._validate_configuration(tmp_conf, params) + self.lib_conf = None + if config: + self._lib_conf_path = config.absolute() if isinstance(config, Path) \ + else Path(config).absolute() + + with self._lib_conf_path.open("r") as f: + tmp_conf = json.load(f) + if tmp_conf is None: + raise exceptions.SCAutolibException( + "Data are not loaded correctly.") + self.lib_conf = self._validate_configuration(tmp_conf, params) + self.users = [] for d in (LIB_DIR, LIB_BACKUP, LIB_DUMP, LIB_DUMP_USERS, LIB_DUMP_CAS, LIB_DUMP_CARDS, LIB_DUMP_CONFS): @@ -147,12 +150,6 @@ def setup_system(self, install_missing: bool, gdm: bool, graphical: bool): packages = ["opensc", "httpd", "sssd", "sssd-tools", "gnutls-utils", "openssl", "nss-tools"] - if gdm: - packages.append("gdm") - - if graphical: - # ffmpeg-free is in EPEL repo - packages += ["tesseract", "ffmpeg-free"] # Prepare for virtual cards if any(c["card_type"] == CardType.virtual @@ -181,21 +178,7 @@ def setup_system(self, install_missing: bool, gdm: bool, graphical: bool): raise exceptions.SCAutolibException(msg) if graphical: - if not isDistro('fedora'): - run(['dnf', 'groupinstall', 'Server with GUI', '-y', - '--allowerasing']) - run(['pip', 'install', 'python-uinput']) - else: - # Fedora doesn't have server with GUI group so installed gdm - # manually and also python3-uinput should be installed from RPM - run(['dnf', 'install', 'gdm', 'python3-uinput', '-y']) - # disable subscription message - run(['systemctl', '--global', 'mask', - 'org.gnome.SettingsDaemon.Subscription.target']) - # disable welcome message - self.dconf_file.create() - self.dconf_file.save() - run('dconf update') + self.setup_graphical(install_missing, gdm) if not isDistro('fedora'): run(['dnf', 'groupinstall', "Smart Card Support", '-y', @@ -216,6 +199,38 @@ def setup_system(self, install_missing: bool, gdm: bool, graphical: bool): dump_to_json(user.User(username="root", password=self.lib_conf["root_passwd"])) + def setup_graphical(self, install_missing: bool, gdm: bool): + packages = ["gcc", "tesseract", "ffmpeg-free"] + + if gdm: + packages.append("gdm") + + missing = _check_packages(packages) + if install_missing and missing: + _install_packages(missing) + elif missing: + msg = "Can't continue with graphical. Some packages are missing: " \ + f"{', '.join(missing)}" + logger.critical(msg) + raise exceptions.SCAutolibException(msg) + + if not isDistro('fedora'): + run(['dnf', 'groupinstall', 'Server with GUI', '-y', + '--allowerasing']) + run(['pip', 'install', 'python-uinput']) + else: + # Fedora doesn't have server with GUI group so installed gdm + # manually and also python3-uinput should be installed from RPM + run(['dnf', 'install', 'gdm', 'python3-uinput', '-y']) + # disable subscription message + run(['systemctl', '--global', 'mask', + 'org.gnome.SettingsDaemon.Subscription.target']) + # disable welcome message + if not self.dconf_file.exists(): + self.dconf_file.create() + self.dconf_file.save() + run('dconf update') + def setup_local_ca(self, force: bool = False): """ Setup local CA based on configuration from the configuration file. All diff --git a/SCAutolib/models/file.py b/SCAutolib/models/file.py index 91784a1..f3c170e 100644 --- a/SCAutolib/models/file.py +++ b/SCAutolib/models/file.py @@ -92,6 +92,12 @@ def remove(self): f"Removed file {self._conf_file}." ) + def exists(self): + """ + Checks if a file exists. Returns boolean. + """ + return self._conf_file.exists() + def set(self, key: str, value: Union[int, str, bool], section: str = None, separator: str = "="): """ diff --git a/SCAutolib/models/gui.py b/SCAutolib/models/gui.py index 93ae660..f1abf8d 100644 --- a/SCAutolib/models/gui.py +++ b/SCAutolib/models/gui.py @@ -1,7 +1,6 @@ import inspect -import os -from os.path import join from time import sleep, time +from pathlib import Path import cv2 import keyboard @@ -24,7 +23,14 @@ def __init__(self, directory: str, html_file: str = None): """ self.directory = directory self.html_file = html_file + + taken_images = [str(image).split('/')[-1] + for image in Path(directory).iterdir()] + taken_images.sort(reverse=True) + self.screenshot_num = 1 + if len(taken_images) > 0: + self.screenshot_num = int(taken_images[0].split('.')[0]) + 1 def screenshot(self, timeout: float = 30): """Runs ffmpeg to take a screenshot. @@ -237,7 +243,8 @@ def wrapper(self, *args, **kwargs): class GUI: """Represents the GUI and allows controlling the system under test.""" - def __init__(self, wait_time: float = 5, res_dir_name: str = None): + def __init__(self, wait_time: float = 5, res_dir_name: str = None, + from_cli: bool = False): """Initializes the GUI of system under test. :param wait_time: Time to wait after each action @@ -247,30 +254,52 @@ def __init__(self, wait_time: float = 5, res_dir_name: str = None): self.wait_time = wait_time self.gdm_init_time = 10 + self.from_cli = from_cli # Create the directory for screenshots + self.html_directory = Path("/tmp/SC-tests") + if not self.html_directory.exists(): + self.html_directory.mkdir() if res_dir_name: - self.html_directory = '/tmp/SC-tests/' + res_dir_name + self.html_directory = self.html_directory.joinpath(res_dir_name) + elif from_cli: + run_dirs = [str(run_dir).split('/')[-1] + for run_dir in self.html_directory.iterdir() + if "cli_gui" in str(run_dir)] + run_dirs.sort(reverse=True) + + last_run_dir = Path(run_dirs[0]) if len(run_dirs) > 0 else None + if last_run_dir and not last_run_dir.joinpath('done').exists(): + # Use the old run directory + logger.debug("Using HTML logging file from last time.") + self.html_directory = self.html_directory.joinpath( + last_run_dir) + else: + # Create new run directory + logger.debug("Creating new HTML logging file.") + self.html_directory = self.html_directory.joinpath( + str(int(time())) + '_cli_gui') else: calling_func = inspect.stack()[1][3] - self.html_directory = '/tmp/SC-tests/' + str(int(time())) - self.html_directory += "_" + calling_func + self.html_directory = self.html_directory.joinpath( + str(int(time())) + '_' + calling_func) - self.screenshot_directory = self.html_directory + "/screenshots" + self.screenshot_directory = self.html_directory.joinpath("screenshots") # will create both dirs - os.makedirs(self.screenshot_directory, exist_ok=True) + self.screenshot_directory.mkdir(parents=True, exist_ok=True) - self.html_file = join(self.html_directory, "index.html") - with open(self.html_file, 'w') as fp: - fp.write( - "\n" - "\n" - "\n" - "\n" - "Test Results\n" - "\n" - "\n" - ) + self.html_file = self.html_directory.joinpath("index.html") + if not self.html_file.exists(): + with open(self.html_file, 'w') as fp: + fp.write( + "\n" + "\n" + "\n" + "\n" + "Test Results\n" + "\n" + "\n" + ) fmt = "" fmt += "%(asctime)s " @@ -284,25 +313,36 @@ def __init__(self, wait_time: float = 5, res_dir_name: str = None): logging.Formatter("

" + fmt + "

") ) + if self.from_cli: + logger.addHandler(self.fileHandler) + self.mouse = Mouse() # workaround for keyboard library # otherwise the first character is not sent keyboard.send('enter') - def __enter__(self): + # create screen object to use from calls self.screen = Screen(self.screenshot_directory, self.html_file) + + def __enter__(self): # By restarting gdm, the system gets into defined state run(['systemctl', 'restart', 'gdm'], check=True) # Cannot screenshot before gdm starts displaying # This would break the display sleep(self.gdm_init_time) - logger.addHandler(self.fileHandler) + if not self.from_cli: + logger.addHandler(self.fileHandler) return self def __exit__(self, type, value, traceback): + done_file = self.html_directory.joinpath('done') + print(done_file) + if done_file.exists(): + return + run(['systemctl', 'stop', 'gdm'], check=True) with open(self.html_file, 'a') as fp: @@ -311,7 +351,13 @@ def __exit__(self, type, value, traceback): "\n" ) - logger.removeHandler(self.fileHandler) + print(done_file) + with open(done_file, 'w') as fp: + fp.write("done") + + if not self.from_cli: + logger.removeHandler(self.fileHandler) + logger.info(f"HTML file with results created in {self.html_directory}.") @action_decorator @@ -377,7 +423,18 @@ def click_on(self, key: str, timeout: float = 30): def kb_write(self, *args, **kwargs): # delay is a workaround needed for keyboard library kwargs.setdefault('delay', 0.1) - keyboard.write(*args, **kwargs) + + word = args[0] + last = "" + for char in word: + if char.isupper(): + if last != "": + keyboard.write(*[last], **kwargs) + last = "" + keyboard.send(f"shift+{char.lower()}") + else: + last = f"{last}{char}" + keyboard.write(*[last], **kwargs) @action_decorator @log_decorator