Repository: redhat-qe-security/SCAutolib
Issue category: Feature Request

Issue:
Extend CLI to make GUI calls
It would be helpful to be able to run some common GUI functions from the CLI.  This could help with some manual test development as well as provide an interface that can be used by test frameworks that rely on running commands remotely.

At a minimum, I think we need cli access to the following GUI functions:
- assert_text
- click_on
- check_home_screen
Extend CLI to make GUI calls
It would be helpful to be able to run some common GUI functions from the CLI.  This could help with some manual test development as well as provide an interface that can be used by test frameworks that rely on running commands remotely.

At a minimum, I think we need cli access to the following GUI functions:
- assert_text
- click_on
- check_home_screen


Hints:

Patch:
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(
-                "<html lang=\"en\">\n"
-                "<head>\n"
-                "<meta charset=\"UTF-8\">\n"
-                "<meta name=\"viewport\" content=\"width=device-width, "
-                "initial-scale=1.0\">\n"
-                "<title>Test Results</title>\n"
-                "</head>\n"
-                "<body style=\"background-color:#000;\">\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(
+                    "<html lang=\"en\">\n"
+                    "<head>\n"
+                    "<meta charset=\"UTF-8\">\n"
+                    "<meta name=\"viewport\" content=\"width=device-width, "
+                    "initial-scale=1.0\">\n"
+                    "<title>Test Results</title>\n"
+                    "</head>\n"
+                    "<body style=\"background-color:#000;\">\n"
+                )
 
         fmt = "<span style=\"color:limegreen;\">"
         fmt += "%(asctime)s</span> "
@@ -284,25 +313,36 @@ def __init__(self, wait_time: float = 5, res_dir_name: str = None):
             logging.Formatter("<p>" + fmt + "</p>")
         )
 
+        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):
                 "</html>\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


Test patch: