From 09dd8298f7862a25dded0a4ea0690fa6411a22a7 Mon Sep 17 00:00:00 2001 From: dw-0 Date: Sat, 11 Jul 2026 00:28:27 +0200 Subject: [PATCH] refactor(webui_client): extract install/remove/update logic into services Replace monolithic client_setup.py, client_config_setup.py and their remove counterparts with service classes under components/webui_client/services. Update menus and core install/update menus to call the new services. Add unit tests for services, client_utils and menu wiring. --- kiauh/components/webui_client/__init__.py | 11 + .../client_config/client_config_remove.py | 98 ---- .../client_config/client_config_setup.py | 126 ----- .../components/webui_client/client_remove.py | 125 ----- kiauh/components/webui_client/client_setup.py | 188 ------- kiauh/components/webui_client/client_utils.py | 16 + .../webui_client/menus/client_install_menu.py | 8 +- .../webui_client/menus/client_remove_menu.py | 9 +- .../tests}/__init__.py | 0 .../menus/tests/test_client_install_menu.py | 105 ++++ .../webui_client/services/__init__.py | 0 .../webui_client/services/tests/__init__.py | 0 .../webui_client/services/tests/conftest.py | 63 +++ .../test_web_client_config_setup_service.py | 415 ++++++++++++++ .../tests/test_web_client_setup_service.py | 530 ++++++++++++++++++ .../web_client_config_setup_service.py | 250 +++++++++ .../services/web_client_setup_service.py | 365 ++++++++++++ .../components/webui_client/tests/__init__.py | 0 .../components/webui_client/tests/conftest.py | 49 ++ .../webui_client/tests/test_client_utils.py | 348 ++++++++++++ kiauh/core/menus/install_menu.py | 19 +- kiauh/core/menus/repo_select_menu.py | 4 +- kiauh/core/menus/tests/__init__.py | 0 kiauh/core/menus/tests/test_base_menu.py | 94 ++++ kiauh/core/menus/tests/test_install_menu.py | 161 ++++++ kiauh/core/menus/tests/test_menu_dispatch.py | 69 +++ .../core/menus/tests/test_repo_select_menu.py | 84 +++ kiauh/core/menus/tests/test_settings_menu.py | 65 +++ kiauh/core/menus/tests/test_update_menu.py | 210 +++++++ kiauh/core/menus/update_menu.py | 22 +- 30 files changed, 2868 insertions(+), 566 deletions(-) delete mode 100644 kiauh/components/webui_client/client_config/client_config_remove.py delete mode 100644 kiauh/components/webui_client/client_config/client_config_setup.py delete mode 100644 kiauh/components/webui_client/client_remove.py delete mode 100644 kiauh/components/webui_client/client_setup.py rename kiauh/components/webui_client/{client_config => menus/tests}/__init__.py (100%) create mode 100644 kiauh/components/webui_client/menus/tests/test_client_install_menu.py create mode 100644 kiauh/components/webui_client/services/__init__.py create mode 100644 kiauh/components/webui_client/services/tests/__init__.py create mode 100644 kiauh/components/webui_client/services/tests/conftest.py create mode 100644 kiauh/components/webui_client/services/tests/test_web_client_config_setup_service.py create mode 100644 kiauh/components/webui_client/services/tests/test_web_client_setup_service.py create mode 100644 kiauh/components/webui_client/services/web_client_config_setup_service.py create mode 100644 kiauh/components/webui_client/services/web_client_setup_service.py create mode 100644 kiauh/components/webui_client/tests/__init__.py create mode 100644 kiauh/components/webui_client/tests/conftest.py create mode 100644 kiauh/components/webui_client/tests/test_client_utils.py create mode 100644 kiauh/core/menus/tests/__init__.py create mode 100644 kiauh/core/menus/tests/test_base_menu.py create mode 100644 kiauh/core/menus/tests/test_install_menu.py create mode 100644 kiauh/core/menus/tests/test_menu_dispatch.py create mode 100644 kiauh/core/menus/tests/test_repo_select_menu.py create mode 100644 kiauh/core/menus/tests/test_settings_menu.py create mode 100644 kiauh/core/menus/tests/test_update_menu.py diff --git a/kiauh/components/webui_client/__init__.py b/kiauh/components/webui_client/__init__.py index 58d7b66c..15164e37 100644 --- a/kiauh/components/webui_client/__init__.py +++ b/kiauh/components/webui_client/__init__.py @@ -8,5 +8,16 @@ # ======================================================================= # from pathlib import Path +from typing import Callable, Dict + +from components.webui_client.base_data import BaseWebClient +from components.webui_client.fluidd_data import FluiddData +from components.webui_client.mainsail_data import MainsailData MODULE_PATH = Path(__file__).resolve().parent + +# Shared registry of supported web clients +CLIENTS: Dict[str, Callable[[], BaseWebClient]] = { + "mainsail": MainsailData, + "fluidd": FluiddData, +} diff --git a/kiauh/components/webui_client/client_config/client_config_remove.py b/kiauh/components/webui_client/client_config/client_config_remove.py deleted file mode 100644 index a6d83437..00000000 --- a/kiauh/components/webui_client/client_config/client_config_remove.py +++ /dev/null @@ -1,98 +0,0 @@ -# ======================================================================= # -# Copyright (C) 2020 - 2026 Dominik Willner # -# # -# This file is part of KIAUH - Klipper Installation And Update Helper # -# https://github.com/dw-0/kiauh # -# # -# This file may be distributed under the terms of the GNU GPLv3 license # -# ======================================================================= # - - -from typing import List, Optional - -from components.klipper.klipper import Klipper -from components.moonraker.moonraker import Moonraker -from components.webui_client.base_data import BaseWebClientConfig -from core.logger import Logger -from core.services.backup_service import BackupService -from core.services.message_service import Message -from core.types.color import Color -from utils.config_utils import remove_config_section -from utils.fs_utils import run_remove_routines -from utils.instance_type import InstanceType -from utils.instance_utils import get_instances - - -def run_client_config_removal( - client_config: BaseWebClientConfig, - kl_instances: List[Klipper], - mr_instances: List[Moonraker], - svc: Optional[BackupService] = None, -) -> Message: - completion_msg = Message( - title=f"{client_config.display_name} Removal Process completed", - color=Color.GREEN, - ) - Logger.print_status(f"Removing {client_config.display_name} ...") - if run_remove_routines(client_config.config_dir): - completion_msg.text.append(f"● {client_config.display_name} removed") - - if svc is None: - svc = BackupService() - - svc.backup_moonraker_conf() - completion_msg = remove_moonraker_config_section( - completion_msg, client_config, mr_instances - ) - - svc.backup_printer_cfg() - completion_msg = remove_printer_config_section( - completion_msg, client_config, kl_instances - ) - - if completion_msg.text: - completion_msg.text.insert(0, "The following actions were performed:") - else: - completion_msg.color = Color.YELLOW - completion_msg.centered = True - completion_msg.text = ["Nothing to remove."] - - return completion_msg - - -def remove_cfg_symlink(client_config: BaseWebClientConfig, message: Message) -> Message: - instances: List[Klipper] = get_instances(Klipper) - kl_instances = [] - for instance in instances: - cfg = instance.base.cfg_dir.joinpath(client_config.config_filename) - if run_remove_routines(cfg): - kl_instances.append(instance) - text = f"{client_config.display_name} removed from instance" - return update_msg(kl_instances, message, text) - - -def remove_printer_config_section( - message: Message, client_config: BaseWebClientConfig, kl_instances: List[Klipper] -) -> Message: - kl_section = client_config.config_section - kl_instances = remove_config_section(kl_section, kl_instances) - text = f"Klipper config section '{kl_section}' removed for instance" - return update_msg(kl_instances, message, text) - - -def remove_moonraker_config_section( - message: Message, client_config: BaseWebClientConfig, mr_instances: List[Moonraker] -) -> Message: - mr_section = f"update_manager {client_config.name}" - mr_instances = remove_config_section(mr_section, mr_instances) - text = f"Moonraker config section '{mr_section}' removed for instance" - return update_msg(mr_instances, message, text) - - -def update_msg(instances: List[InstanceType], message: Message, text: str) -> Message: - if not instances: - return message - - instance_names = [i.service_file_path.stem for i in instances] - message.text.append(f"● {text}: {', '.join(instance_names)}") - return message diff --git a/kiauh/components/webui_client/client_config/client_config_setup.py b/kiauh/components/webui_client/client_config/client_config_setup.py deleted file mode 100644 index ee0cb807..00000000 --- a/kiauh/components/webui_client/client_config/client_config_setup.py +++ /dev/null @@ -1,126 +0,0 @@ -# ======================================================================= # -# Copyright (C) 2020 - 2026 Dominik Willner # -# # -# This file is part of KIAUH - Klipper Installation And Update Helper # -# https://github.com/dw-0/kiauh # -# # -# This file may be distributed under the terms of the GNU GPLv3 license # -# ======================================================================= # -from __future__ import annotations - -import shutil -import subprocess -from pathlib import Path -from typing import List - -from components.klipper.klipper import Klipper -from components.moonraker.moonraker import Moonraker -from components.webui_client.base_data import BaseWebClient, BaseWebClientConfig -from components.webui_client.client_dialogs import ( - print_client_already_installed_dialog, -) -from components.webui_client.client_utils import ( - backup_client_config_data, - detect_client_cfg_conflict, -) -from core.instance_manager.instance_manager import InstanceManager -from core.logger import Logger -from core.services.backup_service import BackupService -from core.settings.kiauh_settings import KiauhSettings -from utils.config_utils import add_config_section, add_config_section_at_top -from utils.fs_utils import create_symlink -from utils.git_utils import git_clone_wrapper, git_pull_wrapper -from utils.input_utils import get_confirm -from utils.instance_utils import get_instances - - -def install_client_config(client_data: BaseWebClient, cfg_backup=True) -> None: - client_config: BaseWebClientConfig = client_data.client_config - display_name = client_config.display_name - - if detect_client_cfg_conflict(client_data): - Logger.print_info("Another Client-Config is already installed! Skipped ...") - return - - if client_config.config_dir.exists(): - print_client_already_installed_dialog(display_name) - if get_confirm(f"Re-install {display_name}?", allow_go_back=True): - shutil.rmtree(client_config.config_dir) - else: - return - - mr_instances: List[Moonraker] = get_instances(Moonraker) - kl_instances = get_instances(Klipper) - - try: - download_client_config(client_config) - create_client_config_symlink(client_config, kl_instances) - - if cfg_backup: - BackupService().backup_printer_config_dir() - - add_config_section( - section=f"update_manager {client_config.name}", - instances=mr_instances, - options=[ - ("type", "git_repo"), - ("primary_branch", "master"), - ("path", str(client_config.config_dir)), - ("origin", str(client_config.repo_url)), - ("managed_services", "klipper"), - ], - ) - add_config_section_at_top(client_config.config_section, kl_instances) - InstanceManager.restart_all(kl_instances) - - except Exception as e: - Logger.print_error(f"{display_name} installation failed!\n{e}") - return - - Logger.print_ok(f"{display_name} installation complete!", start="\n") - - -def download_client_config(client_config: BaseWebClientConfig) -> None: - try: - Logger.print_status(f"Downloading {client_config.display_name} ...") - repo = client_config.repo_url - target_dir = client_config.config_dir - git_clone_wrapper(repo, target_dir) - except Exception: - Logger.print_error(f"Downloading {client_config.display_name} failed!") - raise - - -def update_client_config(client: BaseWebClient) -> None: - client_config: BaseWebClientConfig = client.client_config - - Logger.print_status(f"Updating {client_config.display_name} ...") - - if not client_config.config_dir.exists(): - Logger.print_info( - f"Unable to update {client_config.display_name}. Directory does not exist! Skipping ..." - ) - return - - settings = KiauhSettings() - if settings.kiauh.backup_before_update: - backup_client_config_data(client) - - git_pull_wrapper(client_config.config_dir) - - Logger.print_ok(f"Successfully updated {client_config.display_name}.") - Logger.print_info("Restart Klipper to reload the configuration!") - - -def create_client_config_symlink( - client_config: BaseWebClientConfig, klipper_instances: List[Klipper] -) -> None: - for instance in klipper_instances: - Logger.print_status(f"Create symlink for {client_config.config_filename} ...") - source = Path(client_config.config_dir, client_config.config_filename) - target = instance.base.cfg_dir - Logger.print_status(f"Linking {source} to {target}") - try: - create_symlink(source, target) - except subprocess.CalledProcessError: - Logger.print_error("Creating symlink failed!") diff --git a/kiauh/components/webui_client/client_remove.py b/kiauh/components/webui_client/client_remove.py deleted file mode 100644 index 8a6fe86d..00000000 --- a/kiauh/components/webui_client/client_remove.py +++ /dev/null @@ -1,125 +0,0 @@ -# ======================================================================= # -# Copyright (C) 2020 - 2026 Dominik Willner # -# # -# This file is part of KIAUH - Klipper Installation And Update Helper # -# https://github.com/dw-0/kiauh # -# # -# This file may be distributed under the terms of the GNU GPLv3 license # -# ======================================================================= # -from typing import List - -from components.klipper.klipper import Klipper -from components.moonraker.moonraker import Moonraker -from components.webui_client.base_data import ( - BaseWebClient, -) -from components.webui_client.client_config.client_config_remove import ( - run_client_config_removal, -) -from core.constants import NGINX_SITES_AVAILABLE, NGINX_SITES_ENABLED -from core.logger import Logger -from core.services.backup_service import BackupService -from core.services.message_service import Message -from core.types.color import Color -from utils.config_utils import remove_config_section -from utils.fs_utils import ( - remove_with_sudo, - run_remove_routines, -) -from utils.instance_utils import get_instances - - -def run_client_removal( - client: BaseWebClient, - remove_client: bool, - remove_client_cfg: bool, - backup_config: bool, -) -> Message: - completion_msg = Message( - title=f"{client.display_name} Removal Process completed", - color=Color.GREEN, - ) - mr_instances: List[Moonraker] = get_instances(Moonraker) - kl_instances: List[Klipper] = get_instances(Klipper) - svc = BackupService() - - if backup_config: - version = "" - src = client.client_dir - if src.joinpath(".version").exists(): - with open(src.joinpath(".version"), "r") as v: - version = v.readlines()[0] - - target_path = svc.backup_root.joinpath(f"{client.client_dir.name}_{version}") - success = svc.backup_file( - source_path=client.config_file, - target_path=target_path, - ) - if success: - completion_msg.text.append(f"● {client.config_file.name} backup created") - - if remove_client: - client_name = client.name - if remove_client_dir(client): - completion_msg.text.append(f"● {client.display_name} removed") - if remove_client_nginx_config(client_name): - completion_msg.text.append("● NGINX config removed") - if remove_client_nginx_logs(client, kl_instances): - completion_msg.text.append("● NGINX logs removed") - - svc.backup_moonraker_conf() - section = f"update_manager {client_name}" - handled_instances: List[Moonraker] = remove_config_section( - section, mr_instances - ) - if handled_instances: - names = [i.service_file_path.stem for i in handled_instances] - completion_msg.text.append( - f"● Moonraker config section '{section}' removed for instance: {', '.join(names)}" - ) - - if remove_client_cfg: - cfg_completion_msg = run_client_config_removal( - client.client_config, - kl_instances, - mr_instances, - svc, - ) - if cfg_completion_msg.color == Color.GREEN: - completion_msg.text.extend(cfg_completion_msg.text[1:]) - - if not completion_msg.text: - completion_msg.color = Color.YELLOW - completion_msg.centered = True - completion_msg.text.append("Nothing to remove.") - else: - completion_msg.text.insert(0, "The following actions were performed:") - - return completion_msg - - -def remove_client_dir(client: BaseWebClient) -> bool: - Logger.print_status(f"Removing {client.display_name} ...") - return run_remove_routines(client.client_dir) - - -def remove_client_nginx_config(name: str) -> bool: - Logger.print_status(f"Removing NGINX config for {name.capitalize()} ...") - return remove_with_sudo( - [ - NGINX_SITES_AVAILABLE.joinpath(name), - NGINX_SITES_ENABLED.joinpath(name), - ] - ) - - -def remove_client_nginx_logs(client: BaseWebClient, instances: List[Klipper]) -> bool: - Logger.print_status(f"Removing NGINX logs for {client.display_name} ...") - - files = [client.nginx_access_log, client.nginx_error_log] - if instances: - for instance in instances: - files.append(instance.base.log_dir.joinpath(client.nginx_access_log.name)) - files.append(instance.base.log_dir.joinpath(client.nginx_error_log.name)) - - return remove_with_sudo(files) diff --git a/kiauh/components/webui_client/client_setup.py b/kiauh/components/webui_client/client_setup.py deleted file mode 100644 index 1c2ed03e..00000000 --- a/kiauh/components/webui_client/client_setup.py +++ /dev/null @@ -1,188 +0,0 @@ -# ======================================================================= # -# Copyright (C) 2020 - 2026 Dominik Willner # -# # -# This file is part of KIAUH - Klipper Installation And Update Helper # -# https://github.com/dw-0/kiauh # -# # -# This file may be distributed under the terms of the GNU GPLv3 license # -# ======================================================================= # -import shutil -import tempfile -from pathlib import Path -from typing import List - -from components.klipper.klipper import Klipper -from components.moonraker.moonraker import Moonraker -from components.webui_client import MODULE_PATH -from components.webui_client.base_data import ( - BaseWebClient, - BaseWebClientConfig, - WebClientType, -) -from components.webui_client.client_config.client_config_setup import ( - install_client_config, -) -from components.webui_client.client_dialogs import ( - print_install_client_config_dialog, - print_moonraker_not_found_dialog, -) -from components.webui_client.client_utils import ( - copy_common_vars_nginx_cfg, - copy_upstream_nginx_cfg, - create_nginx_cfg, - detect_client_cfg_conflict, - enable_mainsail_remotemode, - get_client_port_selection, - symlink_webui_nginx_log, -) -from core.instance_manager.instance_manager import InstanceManager -from core.logger import DialogType, Logger -from core.services.backup_service import BackupService -from core.settings.kiauh_settings import KiauhSettings -from core.types.color import Color -from utils.common import check_install_dependencies -from utils.config_utils import add_config_section -from utils.fs_utils import unzip -from utils.input_utils import get_confirm -from utils.instance_utils import get_instances -from utils.sys_utils import ( - cmd_sysctl_service, - download_file, - get_ipv4_addr, -) - - -def install_client( - client: BaseWebClient, - settings: KiauhSettings, - reinstall: bool = False, -) -> None: - mr_instances: List[Moonraker] = get_instances(Moonraker) - - enable_remotemode = False - if not mr_instances: - print_moonraker_not_found_dialog(client.display_name) - if not get_confirm(f"Continue {client.display_name} installation?"): - return - - # if moonraker is not installed or multiple instances - # are installed we enable mainsails remote mode - if ( - client.client == WebClientType.MAINSAIL - and not mr_instances - or len(mr_instances) > 1 - ): - enable_remotemode = True - - kl_instances = get_instances(Klipper) - install_client_cfg = False - client_config: BaseWebClientConfig = client.client_config - if ( - kl_instances - and not client_config.config_dir.exists() - and not detect_client_cfg_conflict(client) - ): - print_install_client_config_dialog(client) - question = f"Download the recommended {client_config.display_name}?" - install_client_cfg = get_confirm(question, allow_go_back=False) - - default_port: int = int(settings.get(client.name, "port")) - port: int = ( - default_port if reinstall else get_client_port_selection(client, settings) - ) - - check_install_dependencies({"nginx"}) - - try: - download_client(client) - if enable_remotemode and client.client == WebClientType.MAINSAIL: - enable_mainsail_remotemode() - - BackupService().backup_printer_config_dir() - add_config_section( - section=f"update_manager {client.name}", - instances=mr_instances, - options=[ - ("persistent_files", ["config.json"]), - ("type", "web"), - ("channel", "stable"), - ("repo", str(client.repo_path)), - ("path", str(client.client_dir)), - ], - ) - InstanceManager.restart_all(mr_instances) - - if install_client_cfg and kl_instances: - install_client_config(client, False) - - copy_upstream_nginx_cfg() - copy_common_vars_nginx_cfg() - create_nginx_cfg( - display_name=client.display_name, - cfg_name=client.name, - template_src=MODULE_PATH.joinpath("assets/nginx_cfg"), - PORT=port, - ROOT_DIR=client.client_dir, - NAME=client.name, - ) - - if kl_instances: - symlink_webui_nginx_log(client, kl_instances) - cmd_sysctl_service("nginx", "restart") - - except Exception as e: - Logger.print_error(e) - Logger.print_dialog( - DialogType.ERROR, - center_content=True, - content=[f"{client.display_name} installation failed!"], - ) - return - - # noinspection HttpUrlsUsage - Logger.print_dialog( - DialogType.CUSTOM, - custom_title=f"{client.display_name} installation complete!", - custom_color=Color.GREEN, - center_content=True, - content=[ - f"Open {client.display_name} now on: http://{get_ipv4_addr()}{'' if port == 80 else f':{port}'}", - ], - ) - - -def download_client(client: BaseWebClient) -> None: - zipfile = f"{client.name.lower()}.zip" - target = Path().home().joinpath(zipfile) - try: - Logger.print_status( - f"Downloading {client.display_name} from {client.download_url} ..." - ) - download_file(client.download_url, target, True) - Logger.print_ok("Download complete!") - - Logger.print_status(f"Extracting {zipfile} ...") - unzip(target, client.client_dir) - target.unlink(missing_ok=True) - Logger.print_ok("OK!") - - except Exception: - Logger.print_error(f"Downloading {client.display_name} failed!") - raise - - -def update_client(client: BaseWebClient) -> None: - Logger.print_status(f"Updating {client.display_name} ...") - if not client.client_dir.exists(): - Logger.print_info( - f"Unable to update {client.display_name}. Directory does not exist! Skipping ..." - ) - return - - with tempfile.NamedTemporaryFile(suffix=".json") as tmp_file: - Logger.print_status( - f"Creating temporary backup of {client.config_file} as {tmp_file.name} ..." - ) - shutil.copy(client.config_file, tmp_file.name) - download_client(client) - shutil.copy(tmp_file.name, client.config_file) diff --git a/kiauh/components/webui_client/client_utils.py b/kiauh/components/webui_client/client_utils.py index 9221fb2a..54408757 100644 --- a/kiauh/components/webui_client/client_utils.py +++ b/kiauh/components/webui_client/client_utils.py @@ -20,6 +20,7 @@ from components.klipper.klipper import Klipper from components.webui_client import MODULE_PATH from components.webui_client.base_data import ( BaseWebClient, + BaseWebClientConfig, WebClientType, ) from components.webui_client.client_dialogs import print_client_port_select_dialog @@ -482,3 +483,18 @@ def set_listen_port(client: BaseWebClient, curr_port: int, new_port: int) -> Non with open(config, "w") as f: f.writelines(lines) + + +def create_client_config_symlink( + client_config: BaseWebClientConfig, klipper_instances: List[Klipper] +) -> None: + """Symlink the client config file into every Klipper instance's config dir.""" + for instance in klipper_instances: + Logger.print_status(f"Create symlink for {client_config.config_filename} ...") + source = Path(client_config.config_dir, client_config.config_filename) + target = instance.base.cfg_dir + Logger.print_status(f"Linking {source} to {target}") + try: + create_symlink(source, target) + except Exception: + Logger.print_error("Creating symlink failed!") diff --git a/kiauh/components/webui_client/menus/client_install_menu.py b/kiauh/components/webui_client/menus/client_install_menu.py index 142b7136..41b9f15d 100644 --- a/kiauh/components/webui_client/menus/client_install_menu.py +++ b/kiauh/components/webui_client/menus/client_install_menu.py @@ -12,12 +12,14 @@ import textwrap from typing import Type from components.webui_client.base_data import BaseWebClient -from components.webui_client.client_setup import install_client from components.webui_client.client_utils import ( get_client_port_selection, get_nginx_listen_port, set_listen_port, ) +from components.webui_client.services.web_client_setup_service import ( + WebClientSetupService, +) from core.logger import Logger from core.menus import Option from core.menus.base_menu import BaseMenu @@ -65,7 +67,9 @@ class ClientInstallMenu(BaseMenu): print(menu, end="") def reinstall_client(self, **kwargs) -> None: - install_client(self.client, settings=self.settings, reinstall=True) + WebClientSetupService(self.client.name).install( + reinstall=True, interactive=True + ) def change_listen_port(self, **kwargs) -> None: curr_port = self._get_current_port() diff --git a/kiauh/components/webui_client/menus/client_remove_menu.py b/kiauh/components/webui_client/menus/client_remove_menu.py index 07ce6b2d..650be934 100644 --- a/kiauh/components/webui_client/menus/client_remove_menu.py +++ b/kiauh/components/webui_client/menus/client_remove_menu.py @@ -11,8 +11,10 @@ from __future__ import annotations import textwrap from typing import Type -from components.webui_client import client_remove from components.webui_client.base_data import BaseWebClient +from components.webui_client.services.web_client_setup_service import ( + WebClientSetupService, +) from core.menus import Option from core.menus.base_menu import BaseMenu from core.types.color import Color @@ -100,13 +102,12 @@ class ClientRemoveMenu(BaseMenu): print(Color.apply("Nothing selected ...", Color.RED)) return - completion_msg = client_remove.run_client_removal( - client=self.client, + WebClientSetupService(self.client.name).remove( remove_client=self.remove_client, remove_client_cfg=self.remove_client_cfg, backup_config=self.backup_config_json, + interactive=True, ) - self.message_service.set_message(completion_msg) self.remove_client = False self.remove_client_cfg = False diff --git a/kiauh/components/webui_client/client_config/__init__.py b/kiauh/components/webui_client/menus/tests/__init__.py similarity index 100% rename from kiauh/components/webui_client/client_config/__init__.py rename to kiauh/components/webui_client/menus/tests/__init__.py diff --git a/kiauh/components/webui_client/menus/tests/test_client_install_menu.py b/kiauh/components/webui_client/menus/tests/test_client_install_menu.py new file mode 100644 index 00000000..7bb592f5 --- /dev/null +++ b/kiauh/components/webui_client/menus/tests/test_client_install_menu.py @@ -0,0 +1,105 @@ +# ======================================================================= # +# Copyright (C) 2020 - 2026 Dominik Willner # +# # +# This file is part of KIAUH - Klipper Installation And Update Helper # +# https://github.com/dw-0/kiauh # +# # +# This file may be distributed under the terms of the GNU GPLv3 license # +# ======================================================================= # +from __future__ import annotations + +from pathlib import Path +from typing import Any, List + +import components.webui_client.menus.client_install_menu as cim_module +import pytest +from components.webui_client.menus.client_install_menu import ClientInstallMenu + + +class _FakeClient: + def __init__(self, name: str = "mainsail") -> None: + self.name = name + self.display_name = name.capitalize() + self.nginx_config = Path("/tmp/nginx/mainsail") + + +def _build_menu(monkeypatch: pytest.MonkeyPatch, current_port: int | None = 80) -> ClientInstallMenu: + # Neutralise singletons / IO from BaseMenu and KiauhSettings. + monkeypatch.setattr(cim_module, "KiauhSettings", lambda: _FakeSettings()) + monkeypatch.setattr(cim_module, "get_nginx_listen_port", lambda cfg: current_port) + return ClientInstallMenu(_FakeClient()) + + +class _FakeSettings: + def __init__(self) -> None: + self._section = _FakeSection() + self.mainsail = self._section + self.fluidd = self._section + + def save(self) -> None: + self._section.saved = True + + def __getitem__(self, key): + return self._section + + +class _FakeSection: + port = 80 + saved = False + + +class TestClientInstallMenu: + def test_options_cover_reinstall_and_port_change(self, monkeypatch) -> None: + menu = _build_menu(monkeypatch) + # BaseMenu may append a "back" footer option depending on the menu's + # footer type; the two install-specific entries must always be present. + assert {"1", "2"}.issubset(menu.options.keys()) + + def test_set_previous_menu_defaults_to_install_menu(self, monkeypatch) -> None: + menu = _build_menu(monkeypatch) + menu.set_previous_menu(None) + from core.menus.install_menu import InstallMenu + + assert menu.previous_menu is InstallMenu + + def test_get_current_port_uses_nginx_value(self, monkeypatch) -> None: + menu = _build_menu(monkeypatch, current_port=8080) + assert menu._get_current_port() == 8080 + + def test_get_current_port_falls_back_to_settings(self, monkeypatch) -> None: + menu = _build_menu(monkeypatch, current_port=None) + # FakeSettings._FakeSection.port == 80 + assert menu._get_current_port() == 80 + + def test_reinstall_delegates_to_web_client_setup_service(self, monkeypatch) -> None: + menu = _build_menu(monkeypatch) + calls: List[Any] = [] + + class _FakeService: + def install(self, **kwargs) -> bool: + calls.append(kwargs) + return True + + monkeypatch.setattr(cim_module, "WebClientSetupService", lambda name: _FakeService()) + menu.reinstall_client() + + assert calls + assert calls[0]["reinstall"] is True + assert calls[0]["interactive"] is True + + def test_change_listen_port_persists_and_restarts_nginx(self, monkeypatch, tmp_path) -> None: + menu = _build_menu(monkeypatch) + captured: dict = {} + + monkeypatch.setattr(cim_module, "get_client_port_selection", lambda *a, **k: 9090) + monkeypatch.setattr(cim_module, "cmd_sysctl_service", lambda svc, action: captured.setdefault("nginx", []).append(action)) + monkeypatch.setattr(cim_module, "set_listen_port", lambda client, c, n: captured.setdefault("set_port", (c, n))) + monkeypatch.setattr(cim_module, "get_ipv4_addr", lambda: "127.0.0.1") + # Inject a fake message service to avoid the real MessageService. + menu.message_service = type("MS", (), {"set_message": lambda self, m: captured.setdefault("msg", m)})() + + menu.change_listen_port() + + assert captured["nginx"] == ["stop", "start"] + assert captured["set_port"] == (80, 9090) + assert menu.client_settings.port == 9090 \ No newline at end of file diff --git a/kiauh/components/webui_client/services/__init__.py b/kiauh/components/webui_client/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kiauh/components/webui_client/services/tests/__init__.py b/kiauh/components/webui_client/services/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kiauh/components/webui_client/services/tests/conftest.py b/kiauh/components/webui_client/services/tests/conftest.py new file mode 100644 index 00000000..91341e3c --- /dev/null +++ b/kiauh/components/webui_client/services/tests/conftest.py @@ -0,0 +1,63 @@ +# ======================================================================= # +# Copyright (C) 2020 - 2026 Dominik Willner # +# # +# This file is part of KIAUH - Klipper Installation And Update Helper # +# https://github.com/dw-0/kiauh # +# # +# This file may be distributed under the terms of the GNU GPLv3 license # +# ======================================================================= # +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import pytest +from components.webui_client.base_data import WebClientType + + +@dataclass +class FakeClientConfig: + name: str = "mainsail-config" + display_name: str = "Mainsail-Config" + config_filename: str = "mainsail.cfg" + config_section: str = "include mainsail.cfg" + repo_url: str = "https://github.com/mainsail-crew/mainsail-config.git" + config_dir: Path = Path("/tmp/mainsail-config") + + +@dataclass +class FakeWebClient: + name: str = "mainsail" + display_name: str = "Mainsail" + client: WebClientType = WebClientType.MAINSAIL + client_dir: Path = Path("/tmp/mainsail") + config_file: Path = Path("/tmp/mainsail/config.json") + repo_path: str = "mainsail-crew/mainsail" + nginx_config: Path = Path("/tmp/nginx/mainsail") + nginx_access_log: Path = Path("/tmp/log/mainsail-access.log") + nginx_error_log: Path = Path("/tmp/log/mainsail-error.log") + download_url: str = "https://example.com/mainsail.zip" + client_config: Any = field(default_factory=FakeClientConfig) + + +@pytest.fixture +def client() -> FakeWebClient: + return FakeWebClient() + + +@pytest.fixture +def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None: + from core.settings.kiauh_settings import KiauhSettings + + KiauhSettings._KiauhSettings__instance = None + KiauhSettings._KiauhSettings__initialized = False + + +@pytest.fixture +def settings(reset_settings) -> Any: + from core.settings.kiauh_settings import KiauhSettings + + return KiauhSettings() + + diff --git a/kiauh/components/webui_client/services/tests/test_web_client_config_setup_service.py b/kiauh/components/webui_client/services/tests/test_web_client_config_setup_service.py new file mode 100644 index 00000000..ca5f16f8 --- /dev/null +++ b/kiauh/components/webui_client/services/tests/test_web_client_config_setup_service.py @@ -0,0 +1,415 @@ +# ======================================================================= # +# Copyright (C) 2020 - 2026 Dominik Willner # +# # +# This file is part of KIAUH - Klipper Installation And Update Helper # +# https://github.com/dw-0/kiauh # +# # +# This file may be distributed under the terms of the GNU GPLv3 license # +# ======================================================================= # +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List + +import pytest +from components.webui_client.services.web_client_config_setup_service import ( + WebClientConfigSetupService, +) + + +@pytest.fixture +def bind_client(client, monkeypatch: pytest.MonkeyPatch): + """Make the service construct the per-test FakeWebClient instead of the real data class.""" + monkeypatch.setattr( + WebClientConfigSetupService, + "CLIENTS", + {"mainsail": lambda: client, "fluidd": lambda: client}, + ) + return client + + +@pytest.fixture +def patched_install_deps(monkeypatch: pytest.MonkeyPatch) -> Dict[str, List[Any]]: + calls: Dict[str, List[Any]] = { + "download": [], + "symlink": [], + "backup_printer": [], + "add_section": [], + "add_section_at_top": [], + "restart": [], + } + module = "components.webui_client.services.web_client_config_setup_service" + monkeypatch.setattr(f"{module}.detect_client_cfg_conflict", lambda c: False) + monkeypatch.setattr(f"{module}.get_instances", lambda model: []) + monkeypatch.setattr( + f"{module}.git_clone_wrapper", + lambda repo, target: calls["download"].append((repo, str(target))), + ) + monkeypatch.setattr( + f"{module}.create_client_config_symlink", + lambda cfg, kl: calls["symlink"].append((cfg.name, kl)), + ) + + class FakeBackup: + def backup_printer_config_dir(self) -> None: + calls["backup_printer"].append(True) + + monkeypatch.setattr(f"{module}.BackupService", FakeBackup) + monkeypatch.setattr( + f"{module}.add_config_section", + lambda **kwargs: calls["add_section"].append(kwargs["section"]), + ) + monkeypatch.setattr( + f"{module}.add_config_section_at_top", + lambda section, instances: calls["add_section_at_top"].append(section), + ) + monkeypatch.setattr( + f"{module}.InstanceManager.restart_all", + staticmethod(lambda instances: calls["restart"].append(len(instances))), + ) + return calls + + +class TestWebClientConfigSetupServiceConstruction: + def test_accepts_known_clients(self) -> None: + for name in ("mainsail", "fluidd"): + svc = WebClientConfigSetupService(name) + assert svc.name == name + + def test_rejects_unknown_client(self) -> None: + with pytest.raises(ValueError): + WebClientConfigSetupService("unknown") + + def test_clients_mapping_is_the_single_shared_source(self) -> None: + # there must be exactly one CLIENTS dict, imported from + # components.webui_client by both web-client services. + from components import webui_client + from components.webui_client.services.web_client_setup_service import ( + WebClientSetupService, + ) + + assert webui_client.CLIENTS is WebClientConfigSetupService.CLIENTS + assert webui_client.CLIENTS is WebClientSetupService.CLIENTS + assert set(WebClientConfigSetupService.CLIENTS.keys()) == {"mainsail", "fluidd"} + + +class TestInstallClientConfig: + def test_installs_when_clean( + self, bind_client, patched_install_deps, tmp_path: Path + ) -> None: + bind_client.client_config.config_dir = tmp_path / "mainsail-config" + result = WebClientConfigSetupService("mainsail").install() + + assert result is True + assert patched_install_deps["download"] + assert patched_install_deps["symlink"] + assert "update_manager mainsail-config" in patched_install_deps["add_section"] + + def test_skips_when_conflict_detected( + self, bind_client, patched_install_deps, monkeypatch + ) -> None: + module = "components.webui_client.services.web_client_config_setup_service" + monkeypatch.setattr(f"{module}.detect_client_cfg_conflict", lambda c: True) + + result = WebClientConfigSetupService("mainsail").install() + + assert result is True + assert patched_install_deps["download"] == [] + + def test_interactive_reinstall_after_confirm( + self, bind_client, patched_install_deps, monkeypatch, tmp_path: Path + ) -> None: + module = "components.webui_client.services.web_client_config_setup_service" + bind_client.client_config.config_dir = tmp_path / "mainsail-config" + bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True) + removed: List[Path] = [] + monkeypatch.setattr(f"{module}.shutil.rmtree", lambda p: removed.append(p)) + monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True) + + result = WebClientConfigSetupService("mainsail").install(interactive=True) + + assert result is True + assert removed == [bind_client.client_config.config_dir] + assert patched_install_deps["download"] + + def test_interactive_decline_reinstall_skips( + self, bind_client, patched_install_deps, monkeypatch, tmp_path: Path + ) -> None: + module = "components.webui_client.services.web_client_config_setup_service" + bind_client.client_config.config_dir = tmp_path / "mainsail-config" + bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr( + f"{module}.shutil.rmtree", lambda p: pytest.fail("no rmtree") + ) + monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: False) + + result = WebClientConfigSetupService("mainsail").install(interactive=True) + + assert result is True + assert patched_install_deps["download"] == [] + + def test_non_interactive_existing_dir_skips( + self, bind_client, patched_install_deps, monkeypatch, tmp_path: Path + ) -> None: + module = "components.webui_client.services.web_client_config_setup_service" + bind_client.client_config.config_dir = tmp_path / "mainsail-config" + bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr( + f"{module}.get_confirm", + lambda *a, **k: pytest.fail("should not prompt in headless mode"), + ) + + result = WebClientConfigSetupService("mainsail").install(interactive=False) + + assert result is True + assert patched_install_deps["download"] == [] + + def test_install_failure_returns_false( + self, bind_client, patched_install_deps, monkeypatch, tmp_path: Path + ) -> None: + bind_client.client_config.config_dir = tmp_path / "mainsail-config" + module = "components.webui_client.services.web_client_config_setup_service" + monkeypatch.setattr( + f"{module}.git_clone_wrapper", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + result = WebClientConfigSetupService("mainsail").install() + + assert result is False + + +class TestUpdateClientConfig: + def test_update_skips_when_dir_missing( + self, bind_client, monkeypatch, tmp_path: Path + ) -> None: + bind_client.client_config.config_dir = tmp_path / "mainsail-config" + module = "components.webui_client.services.web_client_config_setup_service" + pulled: List[Any] = [] + monkeypatch.setattr( + f"{module}.git_pull_wrapper", lambda *a, **k: pulled.append("pull") + ) + + result = WebClientConfigSetupService("mainsail").update() + + assert result is True + assert pulled == [] + + def test_update_pulls_when_dir_exists( + self, bind_client, monkeypatch, tmp_path: Path + ) -> None: + module = "components.webui_client.services.web_client_config_setup_service" + bind_client.client_config.config_dir = tmp_path / "mainsail-config" + bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True) + pulled: List[Any] = [] + monkeypatch.setattr( + f"{module}.git_pull_wrapper", lambda *a, **k: pulled.append("pull") + ) + monkeypatch.setattr(f"{module}.backup_client_config_data", lambda c: None) + + result = WebClientConfigSetupService("mainsail").update() + + assert result is True + assert pulled == ["pull"] + + def test_update_failure_returns_false( + self, bind_client, monkeypatch, tmp_path: Path + ) -> None: + module = "components.webui_client.services.web_client_config_setup_service" + bind_client.client_config.config_dir = tmp_path / "mainsail-config" + bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr( + f"{module}.git_pull_wrapper", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")), + ) + monkeypatch.setattr(f"{module}.backup_client_config_data", lambda c: None) + + result = WebClientConfigSetupService("mainsail").update() + + assert result is False + + def test_update_non_interactive_omits_restart_hint( + self, bind_client, monkeypatch, tmp_path: Path + ) -> None: + module = "components.webui_client.services.web_client_config_setup_service" + bind_client.client_config.config_dir = tmp_path / "mainsail-config" + bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(f"{module}.git_pull_wrapper", lambda *a, **k: None) + monkeypatch.setattr(f"{module}.backup_client_config_data", lambda c: None) + printed: List[str] = [] + monkeypatch.setattr( + f"{module}.Logger.print_info", lambda msg, *a, **k: printed.append(str(msg)) + ) + + WebClientConfigSetupService("mainsail").update(interactive=False) + + assert not any("Restart Klipper" in m for m in printed) + + def test_update_interactive_shows_restart_hint( + self, bind_client, monkeypatch, tmp_path: Path + ) -> None: + module = "components.webui_client.services.web_client_config_setup_service" + bind_client.client_config.config_dir = tmp_path / "mainsail-config" + bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(f"{module}.git_pull_wrapper", lambda *a, **k: None) + monkeypatch.setattr(f"{module}.backup_client_config_data", lambda c: None) + printed: List[str] = [] + monkeypatch.setattr( + f"{module}.Logger.print_info", lambda msg, *a, **k: printed.append(str(msg)) + ) + + WebClientConfigSetupService("mainsail").update(interactive=True) + + assert any("Restart Klipper" in m for m in printed) + + +class TestRemoveClientConfig: + def test_remove_signature_rejects_unused_interactive_parameter( + self, bind_client, monkeypatch + ) -> None: + module = "components.webui_client.services.web_client_config_setup_service" + monkeypatch.setattr(f"{module}.get_instances", lambda model: []) + monkeypatch.setattr(f"{module}.run_remove_routines", lambda p: True) + monkeypatch.setattr(f"{module}.remove_config_section", lambda s, i: i) + + class FakeBackup: + def backup_moonraker_conf(self) -> None: + pass + + def backup_printer_cfg(self) -> None: + pass + + monkeypatch.setattr(f"{module}.BackupService", FakeBackup) + monkeypatch.setattr( + f"{module}.MessageService", + lambda: type("MS", (), {"set_message": lambda self, m: None})(), + ) + + with pytest.raises(TypeError): + WebClientConfigSetupService("mainsail").remove(interactive=True) + + def test_remove_runs_dir_and_section_cleanup( + self, bind_client, monkeypatch + ) -> None: + module = "components.webui_client.services.web_client_config_setup_service" + removed: List[str] = [] + sections: List[str] = [] + monkeypatch.setattr(f"{module}.get_instances", lambda model: []) + monkeypatch.setattr( + f"{module}.run_remove_routines", + lambda p: removed.append(str(p)) or True, + ) + monkeypatch.setattr( + f"{module}.remove_config_section", + lambda section, instances: sections.append(section) or instances, + ) + + class FakeBackup: + def backup_moonraker_conf(self) -> None: + pass + + def backup_printer_cfg(self) -> None: + pass + + monkeypatch.setattr(f"{module}.BackupService", FakeBackup) + monkeypatch.setattr( + f"{module}.MessageService", + lambda: type("MS", (), {"set_message": lambda self, m: None})(), + ) + + result = WebClientConfigSetupService("mainsail").remove() + + assert result is True + assert any("mainsail-config" in p for p in removed) + + def test_remove_failure_returns_false(self, bind_client, monkeypatch) -> None: + module = "components.webui_client.services.web_client_config_setup_service" + monkeypatch.setattr(f"{module}.get_instances", lambda model: []) + monkeypatch.setattr( + f"{module}.run_remove_routines", + lambda p: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + class FakeBackup: + def backup_moonraker_conf(self) -> None: + pass + + def backup_printer_cfg(self) -> None: + pass + + monkeypatch.setattr(f"{module}.BackupService", FakeBackup) + monkeypatch.setattr( + f"{module}.MessageService", + lambda: type("MS", (), {"set_message": lambda self, m: None})(), + ) + + result = WebClientConfigSetupService("mainsail").remove() + + assert result is False + + +class TestRemoveConfig: + """The config-removal operation mutates the filesystem and returns the + completion message. Its name must reflect that it does the removal, not + merely build a message.""" + + def test_old_build_removal_message_name_no_longer_exists(self) -> None: + assert not hasattr(WebClientConfigSetupService, "build_removal_message") + + def test_remove_config_performs_destructive_removal_and_returns_message( + self, bind_client, monkeypatch + ) -> None: + module = "components.webui_client.services.web_client_config_setup_service" + removed: List[str] = [] + monkeypatch.setattr(f"{module}.get_instances", lambda model: []) + monkeypatch.setattr( + f"{module}.run_remove_routines", + lambda p: removed.append(str(p)) or True, + ) + monkeypatch.setattr( + f"{module}.remove_config_section", + lambda section, instances: instances, + ) + + class FakeBackup: + def backup_moonraker_conf(self) -> None: + pass + + def backup_printer_cfg(self) -> None: + pass + + monkeypatch.setattr(f"{module}.BackupService", FakeBackup) + + message = WebClientConfigSetupService("mainsail").remove_config( + kl_instances=[], mr_instances=[], backup_config=False + ) + + assert removed # destructive removal actually ran + assert message.text # completion message populated + assert any("config" in line.lower() for line in message.text) + + def test_remove_config_nothing_to_remove_message( + self, bind_client, monkeypatch + ) -> None: + module = "components.webui_client.services.web_client_config_setup_service" + monkeypatch.setattr(f"{module}.get_instances", lambda model: []) + monkeypatch.setattr(f"{module}.run_remove_routines", lambda p: False) + monkeypatch.setattr( + f"{module}.remove_config_section", + lambda section, instances: instances, + ) + + class FakeBackup: + def backup_moonraker_conf(self) -> None: + pass + + def backup_printer_cfg(self) -> None: + pass + + monkeypatch.setattr(f"{module}.BackupService", FakeBackup) + + message = WebClientConfigSetupService("mainsail").remove_config( + kl_instances=[], mr_instances=[] + ) + + assert "Nothing to remove." in message.text diff --git a/kiauh/components/webui_client/services/tests/test_web_client_setup_service.py b/kiauh/components/webui_client/services/tests/test_web_client_setup_service.py new file mode 100644 index 00000000..d2b9cd3e --- /dev/null +++ b/kiauh/components/webui_client/services/tests/test_web_client_setup_service.py @@ -0,0 +1,530 @@ +# ======================================================================= # +# Copyright (C) 2020 - 2026 Dominik Willner # +# # +# This file is part of KIAUH - Klipper Installation And Update Helper # +# https://github.com/dw-0/kiauh # +# # +# This file may be distributed under the terms of the GNU GPLv3 license # +# ======================================================================= # +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List + +import pytest +from components.webui_client.base_data import WebClientType +from components.webui_client.services.web_client_setup_service import ( + WebClientSetupService, +) + + +@pytest.fixture +def bind_client(client, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + WebClientSetupService, "CLIENTS", {"mainsail": lambda: client, "fluidd": lambda: client} + ) + return client + + +class FakeInstance: + def __init__(self, suffix: str = "") -> None: + self.suffix = suffix + self.service_file_path = Path(f"service-{suffix}.service") + self.base = type("Base", (), {"log_dir": Path(f"/tmp/log-{suffix}")})() + + +@pytest.fixture +def patch_install_deps(monkeypatch: pytest.MonkeyPatch) -> Dict[str, List[Any]]: + calls: Dict[str, List[Any]] = { + "download_client": [], + "enable_remotemode": [], + "backup_printer": [], + "add_config_section": [], + "restart_all": [], + "install_client_config": [], + "copy_upstream": [], + "copy_common_vars": [], + "create_nginx_cfg": [], + "symlink_logs": [], + "restart_nginx": [], + } + module = "components.webui_client.services.web_client_setup_service" + monkeypatch.setattr(f"{module}.get_instances", lambda model: []) + monkeypatch.setattr(f"{module}.check_install_dependencies", lambda packages: None) + monkeypatch.setattr( + f"{module}._download_client", + lambda client: calls["download_client"].append(client.name), + ) + monkeypatch.setattr( + f"{module}.enable_mainsail_remotemode", + lambda: calls["enable_remotemode"].append(True), + ) + + class FakeBackup: + def backup_printer_config_dir(self) -> None: + calls["backup_printer"].append(True) + + def backup_moonraker_conf(self) -> None: + calls["backup_printer"].append("moonraker_conf") + + monkeypatch.setattr(f"{module}.BackupService", FakeBackup) + monkeypatch.setattr( + f"{module}.add_config_section", + lambda **kwargs: calls["add_config_section"].append(kwargs), + ) + monkeypatch.setattr( + f"{module}.InstanceManager.restart_all", + staticmethod(lambda instances: calls["restart_all"].append(len(instances))), + ) + monkeypatch.setattr( + f"{module}.WebClientConfigSetupService", + lambda name: type( + "FakeCfgSvc", + (), + { + "install": lambda self, cfg_backup=True, interactive=True: ( + calls["install_client_config"].append((name, cfg_backup, interactive)) + or True + ) + }, + )(), + ) + monkeypatch.setattr( + f"{module}.copy_upstream_nginx_cfg", lambda: calls["copy_upstream"].append(True) + ) + monkeypatch.setattr( + f"{module}.copy_common_vars_nginx_cfg", + lambda: calls["copy_common_vars"].append(True), + ) + monkeypatch.setattr( + f"{module}.create_nginx_cfg", + lambda **kwargs: calls["create_nginx_cfg"].append(kwargs), + ) + monkeypatch.setattr( + f"{module}.symlink_webui_nginx_log", + lambda client, instances: calls["symlink_logs"].append( + (client.name, len(instances)) + ), + ) + monkeypatch.setattr( + f"{module}.cmd_sysctl_service", + lambda service, action: calls["restart_nginx"].append((service, action)), + ) + return calls + + +class TestWebClientSetupServiceConstruction: + @pytest.mark.parametrize("name", ["mainsail", "fluidd"]) + def test_accepts_known_clients(self, name: str) -> None: + svc = WebClientSetupService(name) + assert svc.name == name + + def test_rejects_unknown_client(self) -> None: + with pytest.raises(ValueError): + WebClientSetupService("unknown") + + +class TestInstallClient: + def test_interactive_install_runs_all_steps( + self, bind_client, patch_install_deps, monkeypatch + ) -> None: + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service.get_client_port_selection", + lambda c, s, reconfigure=False: 80, + ) + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service.get_confirm", + lambda *a, **k: True, + ) + + result = WebClientSetupService("mainsail").install() + + assert result is True + assert patch_install_deps["download_client"] == ["mainsail"] + assert patch_install_deps["create_nginx_cfg"] + assert patch_install_deps["restart_nginx"] == [("nginx", "restart")] + + def test_headless_install_uses_explicit_port_and_cfg( + self, bind_client, patch_install_deps, monkeypatch, tmp_path: Path + ) -> None: + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service.get_confirm", + lambda *a, **k: pytest.fail("should not prompt in headless mode"), + ) + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service.get_client_port_selection", + lambda *a, **k: pytest.fail("should not select port interactively"), + ) + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service.get_instances", + lambda model: [FakeInstance()] if model.__name__ == "Klipper" else [], + ) + bind_client.client_config.config_dir = tmp_path / "mainsail-config" + + result = WebClientSetupService("mainsail").install( + interactive=False, port=8080, install_client_cfg=True, continue_without_moonraker=True + ) + + assert result is True + assert patch_install_deps["download_client"] == ["mainsail"] + assert patch_install_deps["install_client_config"] == [("mainsail", False, False)] + nginx_call = patch_install_deps["create_nginx_cfg"][0] + assert nginx_call["PORT"] == 8080 + + def test_reinstall_uses_default_port_without_prompting( + self, bind_client, patch_install_deps, monkeypatch + ) -> None: + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service.get_instances", + lambda model: [FakeInstance()] if model.__name__ == "Moonraker" else [], + ) + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service.get_client_port_selection", + lambda *a, **k: pytest.fail("should not prompt for port during reinstall"), + ) + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service.get_confirm", + lambda *a, **k: pytest.fail("should not prompt during reinstall"), + ) + + result = WebClientSetupService("mainsail").install(reinstall=True, interactive=True) + + assert result is True + nginx_call = patch_install_deps["create_nginx_cfg"][0] + assert nginx_call["PORT"] == 80 + + def test_reinstall_explicit_port_overrides_default( + self, bind_client, patch_install_deps, monkeypatch + ) -> None: + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service.get_instances", + lambda model: [FakeInstance()] if model.__name__ == "Moonraker" else [], + ) + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service.get_client_port_selection", + lambda *a, **k: pytest.fail("should not prompt when port is explicit"), + ) + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service.get_confirm", + lambda *a, **k: pytest.fail("should not prompt during reinstall"), + ) + + result = WebClientSetupService("mainsail").install( + reinstall=True, interactive=True, port=9090 + ) + + assert result is True + nginx_call = patch_install_deps["create_nginx_cfg"][0] + assert nginx_call["PORT"] == 9090 + + def test_headless_install_without_moonraker_returns_false( + self, bind_client, patch_install_deps + ) -> None: + result = WebClientSetupService("mainsail").install( + interactive=False, continue_without_moonraker=False + ) + + assert result is False + assert patch_install_deps["download_client"] == [] + + def test_install_failure_returns_false( + self, bind_client, patch_install_deps, monkeypatch + ) -> None: + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service._download_client", + lambda client: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + result = WebClientSetupService("mainsail").install( + interactive=False, continue_without_moonraker=True + ) + + assert result is False + + def test_headless_install_failure_does_not_show_error_dialog( + self, bind_client, patch_install_deps, monkeypatch + ) -> None: + module = "components.webui_client.services.web_client_setup_service" + monkeypatch.setattr( + f"{module}._download_client", + lambda client: (_ for _ in ()).throw(RuntimeError("boom")), + ) + monkeypatch.setattr( + f"{module}.Logger.print_dialog", + lambda *a, **k: pytest.fail("should not show error dialog in headless mode"), + ) + + result = WebClientSetupService("mainsail").install( + interactive=False, continue_without_moonraker=True + ) + + assert result is False + + def test_headless_install_does_not_show_completion_dialog( + self, bind_client, patch_install_deps, monkeypatch + ) -> None: + module = "components.webui_client.services.web_client_setup_service" + monkeypatch.setattr( + f"{module}.get_confirm", + lambda *a, **k: pytest.fail("should not prompt in headless mode"), + ) + monkeypatch.setattr( + f"{module}.Logger.print_dialog", + lambda *a, **k: pytest.fail("should not show dialog in headless mode"), + ) + + result = WebClientSetupService("mainsail").install( + interactive=False, continue_without_moonraker=True + ) + + assert result is True + + def test_interactive_install_shows_completion_dialog( + self, bind_client, patch_install_deps, monkeypatch + ) -> None: + module = "components.webui_client.services.web_client_setup_service" + monkeypatch.setattr( + f"{module}.get_confirm", lambda *a, **k: True + ) + monkeypatch.setattr( + f"{module}.get_client_port_selection", + lambda c, s, reconfigure=False: 80, + ) + dialog_calls: List[Any] = [] + monkeypatch.setattr( + f"{module}.Logger.print_dialog", + lambda *a, **k: dialog_calls.append(k), + ) + + WebClientSetupService("mainsail").install(interactive=True) + + assert dialog_calls + + +class TestUpdateClient: + def test_update_downloads_and_restores_config( + self, bind_client, monkeypatch + ) -> None: + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service._download_client", + lambda c: None, + ) + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service.shutil.copy", + lambda src, dst: None, + ) + bind_client.client_dir.mkdir(parents=True, exist_ok=True) + bind_client.config_file.write_text("{}") + + result = WebClientSetupService("mainsail").update() + + assert result is True + + def test_update_missing_dir_returns_true( + self, bind_client, monkeypatch, tmp_path: Path + ) -> None: + bind_client.client_dir = tmp_path / "does-not-exist" + pulled: List[Any] = [] + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service._download_client", + lambda c: pulled.append("download"), + ) + + result = WebClientSetupService("mainsail").update() + + assert result is True + assert pulled == [] + + def test_update_failure_returns_false( + self, bind_client, monkeypatch + ) -> None: + bind_client.client_dir.mkdir(parents=True, exist_ok=True) + bind_client.config_file.write_text("{}") + monkeypatch.setattr( + "components.webui_client.services.web_client_setup_service._download_client", + lambda c: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + result = WebClientSetupService("mainsail").update() + + assert result is False + + def test_update_accepts_interactive_parameter( + self, bind_client, monkeypatch + ) -> None: + module = "components.webui_client.services.web_client_setup_service" + bind_client.client_dir.mkdir(parents=True, exist_ok=True) + bind_client.config_file.write_text("{}") + monkeypatch.setattr(f"{module}._download_client", lambda c: None) + monkeypatch.setattr(f"{module}.shutil.copy", lambda s, d: None) + + result = WebClientSetupService("mainsail").update(interactive=False) + + assert result is True + + def test_update_headless_does_not_show_dialog( + self, bind_client, monkeypatch + ) -> None: + module = "components.webui_client.services.web_client_setup_service" + bind_client.client_dir.mkdir(parents=True, exist_ok=True) + bind_client.config_file.write_text("{}") + monkeypatch.setattr(f"{module}._download_client", lambda c: None) + monkeypatch.setattr(f"{module}.shutil.copy", lambda s, d: None) + monkeypatch.setattr( + f"{module}.Logger.print_dialog", + lambda *a, **k: pytest.fail("should not show dialog in headless update"), + ) + + result = WebClientSetupService("mainsail").update(interactive=False) + + assert result is True + + +class TestRemoveClientHelpers: + """Directly exercise the small removal helper methods so the destructive + remove path is covered beyond the integrated ``remove()`` test.""" + + def test_remove_client_dir_returns_run_remove_result(self, bind_client, monkeypatch) -> None: + module = "components.webui_client.services.web_client_setup_service" + monkeypatch.setattr( + f"{module}.run_remove_routines", lambda p: True + ) + svc = WebClientSetupService("mainsail") + assert svc._remove_client_dir() is True + + def test_remove_client_nginx_config_delegates_to_sudo(self, bind_client, monkeypatch) -> None: + module = "components.webui_client.services.web_client_setup_service" + removed: List[Any] = [] + monkeypatch.setattr( + f"{module}.remove_with_sudo", lambda files: removed.append(files) or True + ) + svc = WebClientSetupService("mainsail") + assert svc._remove_client_nginx_config("mainsail") is True + assert removed # files passed through + + def test_remove_client_nginx_logs_appends_per_instance_paths( + self, bind_client, monkeypatch + ) -> None: + module = "components.webui_client.services.web_client_setup_service" + passed: List[Any] = [] + monkeypatch.setattr( + f"{module}.remove_with_sudo", lambda files: passed.append(files) or True + ) + + class FakeKlipperInstance: + def __init__(self, suffix: str) -> None: + self.suffix = suffix + self.base = type("Base", (), {"log_dir": Path(f"/tmp/log-{suffix}")})() + + svc = WebClientSetupService("mainsail") + result = svc._remove_client_nginx_logs( + svc.client, [FakeKlipperInstance("a"), FakeKlipperInstance("b")] + ) + + assert result is True + # 2 base log files + 2 per instance * 2 = 6 files total + assert len(passed[0]) == 6 + + +class TestRemoteModeLogic: + @pytest.mark.parametrize( + "client_name, instance_count, expected", + [ + ("mainsail", 0, True), + ("mainsail", 1, False), + ("mainsail", 2, True), + ("fluidd", 0, False), + ("fluidd", 2, False), + ], + ) + def test_should_enable_remote_mode( + self, client_name: str, instance_count: int, expected: bool + ) -> None: + svc = WebClientSetupService(client_name) + mr_instances = [FakeInstance(str(i)) for i in range(instance_count)] + + result = svc._should_enable_remote_mode(mr_instances) + + assert result is expected + + def test_should_enable_remote_mode_rejects_non_mainsail( + self) -> None: + svc = WebClientSetupService("mainsail") + svc.client = type("NotMainsail", (), {"client": WebClientType.FLUIDD})() + + assert svc._should_enable_remote_mode([]) is False + + +class TestRemoveClient: + def test_remove_client_and_config( + self, bind_client, monkeypatch + ) -> None: + module = "components.webui_client.services.web_client_setup_service" + removed_dir: List[str] = [] + sections: List[str] = [] + monkeypatch.setattr(f"{module}.get_instances", lambda model: []) + monkeypatch.setattr( + f"{module}.run_remove_routines", + lambda p: removed_dir.append(str(p)) or True, + ) + monkeypatch.setattr(f"{module}.remove_with_sudo", lambda files: True) + + class FakeBackup: + def backup_moonraker_conf(self) -> None: + pass + + def backup_file(self, **kwargs) -> bool: + return True + + monkeypatch.setattr(f"{module}.BackupService", FakeBackup) + monkeypatch.setattr( + f"{module}.remove_config_section", + lambda section, instances: sections.append(section) or instances, + ) + build_called: List[Any] = [] + monkeypatch.setattr( + f"{module}.WebClientConfigSetupService", + lambda name: type( + "FakeCfgSvc", + (), + { + "remove_config": lambda self, kl_instances, mr_instances, backup_config=True, svc=None: ( + build_called.append(name) or type( + "Msg", (), {"color": 2, "text": ["x", "config removed"]} + )() + ) + }, + )(), + ) + monkeypatch.setattr( + f"{module}.MessageService", + lambda: type("MS", (), {"set_message": lambda self, m: None})(), + ) + + result = WebClientSetupService("mainsail").remove( + remove_client=True, remove_client_cfg=True, backup_config=False + ) + + assert result is True + assert build_called == ["mainsail"] + assert "update_manager mainsail" in sections + + def test_remove_failure_returns_false( + self, bind_client, monkeypatch + ) -> None: + module = "components.webui_client.services.web_client_setup_service" + monkeypatch.setattr(f"{module}.get_instances", lambda model: []) + monkeypatch.setattr( + f"{module}.run_remove_routines", + lambda p: (_ for _ in ()).throw(RuntimeError("boom")), + ) + monkeypatch.setattr( + f"{module}.MessageService", + lambda: type("MS", (), {"set_message": lambda self, m: None})(), + ) + + result = WebClientSetupService("mainsail").remove( + remove_client=True, remove_client_cfg=False, backup_config=False + ) + + assert result is False \ No newline at end of file diff --git a/kiauh/components/webui_client/services/web_client_config_setup_service.py b/kiauh/components/webui_client/services/web_client_config_setup_service.py new file mode 100644 index 00000000..acb276e3 --- /dev/null +++ b/kiauh/components/webui_client/services/web_client_config_setup_service.py @@ -0,0 +1,250 @@ +# ======================================================================= # +# Copyright (C) 2020 - 2026 Dominik Willner # +# # +# This file is part of KIAUH - Klipper Installation And Update Helper # +# https://github.com/dw-0/kiauh # +# # +# This file may be distributed under the terms of the GNU GPLv3 license # +# ======================================================================= # +from __future__ import annotations + +import shutil +import traceback +from typing import List + +from components.klipper.klipper import Klipper +from components.moonraker.moonraker import Moonraker +from components.webui_client import CLIENTS +from components.webui_client.base_data import BaseWebClient, BaseWebClientConfig +from components.webui_client.client_dialogs import print_client_already_installed_dialog +from components.webui_client.client_utils import ( + backup_client_config_data, + create_client_config_symlink, + detect_client_cfg_conflict, +) +from core.instance_manager.instance_manager import InstanceManager +from core.logger import Logger +from core.services.backup_service import BackupService +from core.services.message_service import Message, MessageService +from core.settings.kiauh_settings import KiauhSettings +from core.types.color import Color +from utils.config_utils import ( + add_config_section, + add_config_section_at_top, + remove_config_section, +) +from utils.fs_utils import run_remove_routines +from utils.git_utils import git_clone_wrapper, git_pull_wrapper +from utils.input_utils import get_confirm +from utils.instance_utils import get_instances + + +class WebClientConfigSetupService: + """Headless-capable service for installing, updating and removing web client configs.""" + + CLIENTS = CLIENTS + + def __init__(self, name: str) -> None: + if name not in self.CLIENTS: + raise ValueError(f"Unknown web client: {name}") + self.name = name + self.client: BaseWebClient = self.CLIENTS[name]() + self.settings = KiauhSettings() + + def install( + self, + cfg_backup: bool = True, + interactive: bool = True, + ) -> bool: + """Install the client config for this service's client. + + Returns ``True`` on success or when the install is legitimately skipped + (conflict or already installed), and ``False`` when installation fails. + """ + client_config: BaseWebClientConfig = self.client.client_config + display_name = client_config.display_name + + if detect_client_cfg_conflict(self.client): + Logger.print_info("Another Client-Config is already installed! Skipped ...") + return True + + if client_config.config_dir.exists(): + if interactive: + print_client_already_installed_dialog(display_name) + if get_confirm(f"Re-install {display_name}?", allow_go_back=True): + shutil.rmtree(client_config.config_dir) + else: + return True + else: + Logger.print_info( + f"{display_name} is already installed; " + "skipping non-interactive install." + ) + return True + + mr_instances: List[Moonraker] = get_instances(Moonraker) + kl_instances: List[Klipper] = get_instances(Klipper) + + try: + self.__download_client_config(client_config) + create_client_config_symlink(client_config, kl_instances) + + if cfg_backup: + BackupService().backup_printer_config_dir() + + add_config_section( + section=f"update_manager {client_config.name}", + instances=mr_instances, + options=[ + ("type", "git_repo"), + ("primary_branch", "master"), + ("path", str(client_config.config_dir)), + ("origin", str(client_config.repo_url)), + ("managed_services", "klipper"), + ], + ) + add_config_section_at_top(client_config.config_section, kl_instances) + InstanceManager.restart_all(kl_instances) + except Exception: + Logger.print_error(traceback.format_exc()) + Logger.print_error(f"{display_name} installation failed!") + return False + + Logger.print_ok(f"{display_name} installation complete!", start="\n") + return True + + def update(self, interactive: bool = True) -> bool: + """Update the client config. Honors ``interactive`` to gate the + post-update "Restart Klipper" hint. + """ + client_config: BaseWebClientConfig = self.client.client_config + + Logger.print_status(f"Updating {client_config.display_name} ...") + + if not client_config.config_dir.exists(): + Logger.print_info( + f"Unable to update {client_config.display_name}. " + "Directory does not exist! Skipping ..." + ) + return True + + if self.settings.kiauh.backup_before_update: + backup_client_config_data(self.client) + + try: + git_pull_wrapper(client_config.config_dir) + except Exception: + Logger.print_error(traceback.format_exc()) + Logger.print_error(f"Updating {client_config.display_name} failed!") + return False + + Logger.print_ok(f"Successfully updated {client_config.display_name}.") + if interactive: + Logger.print_info("Restart Klipper to reload the configuration!") + return True + + def remove( + self, + backup_config: bool = True, + ) -> bool: + """Remove the client config dir, symlinks and config sections. + + Returns ``True`` on success and ``False`` if removal failed. + """ + client_config: BaseWebClientConfig = self.client.client_config + try: + message = self.remove_config( + kl_instances=get_instances(Klipper), + mr_instances=get_instances(Moonraker), + backup_config=backup_config, + ) + MessageService().set_message(message) + except Exception: + Logger.print_error(traceback.format_exc()) + Logger.print_error(f"Error while removing {client_config.display_name}!") + return False + return True + + def remove_config( + self, + kl_instances: List[Klipper], + mr_instances: List[Moonraker], + backup_config: bool = True, + svc: BackupService | None = None, + ) -> Message: + """Remove the client config dir, its symlinks and config sections. + + This method performs the actual (destructive) removal work and returns + the resulting completion ``Message``. It is named ``remove_config`` + (not ``build_*``) so the call site obviously mutates the filesystem. + ``WebClientSetupService.remove`` merges this message into the combined + client-removal message without double-setting it. + """ + client_config: BaseWebClientConfig = self.client.client_config + completion_msg = Message( + title=f"{client_config.display_name} Removal Process completed", + color=Color.GREEN, + ) + Logger.print_status(f"Removing {client_config.display_name} ...") + if run_remove_routines(client_config.config_dir): + completion_msg.text.append(f"● {client_config.display_name} removed") + + if svc is None: + svc = BackupService() + + svc.backup_moonraker_conf() + self.__remove_moonraker_config_section( + completion_msg, client_config, mr_instances + ) + svc.backup_printer_cfg() + self.__remove_printer_config_section( + completion_msg, client_config, kl_instances + ) + + if completion_msg.text: + completion_msg.text.insert(0, "The following actions were performed:") + else: + completion_msg.color = Color.YELLOW + completion_msg.centered = True + completion_msg.text = ["Nothing to remove."] + return completion_msg + + def __download_client_config(self, client_config: BaseWebClientConfig) -> None: + Logger.print_status(f"Downloading {client_config.display_name} ...") + git_clone_wrapper(client_config.repo_url, client_config.config_dir) + + @staticmethod + def __update_msg(instances: list, message: Message, text: str) -> Message: + if not instances: + return message + instance_names = [i.service_file_path.stem for i in instances] + message.text.append(f"● {text}: {', '.join(instance_names)}") + return message + + def __remove_printer_config_section( + self, + message: Message, + client_config: BaseWebClientConfig, + kl_instances: List[Klipper], + ) -> None: + kl_section = client_config.config_section + handled = remove_config_section(kl_section, kl_instances) + self.__update_msg( + handled, + message, + f"Klipper config section '{kl_section}' removed for instance", + ) + + def __remove_moonraker_config_section( + self, + message: Message, + client_config: BaseWebClientConfig, + mr_instances: List[Moonraker], + ) -> None: + mr_section = f"update_manager {client_config.name}" + handled = remove_config_section(mr_section, mr_instances) + self.__update_msg( + handled, + message, + f"Moonraker config section '{mr_section}' removed for instance", + ) diff --git a/kiauh/components/webui_client/services/web_client_setup_service.py b/kiauh/components/webui_client/services/web_client_setup_service.py new file mode 100644 index 00000000..b0bddf18 --- /dev/null +++ b/kiauh/components/webui_client/services/web_client_setup_service.py @@ -0,0 +1,365 @@ +# ======================================================================= # +# Copyright (C) 2020 - 2026 Dominik Willner # +# # +# This file is part of KIAUH - Klipper Installation And Update Helper # +# https://github.com/dw-0/kiauh # +# # +# This file may be distributed under the terms of the GNU GPLv3 license # +# ======================================================================= # +from __future__ import annotations + +import shutil +import tempfile +import traceback +from pathlib import Path +from typing import List + +from components.klipper.klipper import Klipper +from components.moonraker.moonraker import Moonraker +from components.webui_client import CLIENTS, MODULE_PATH +from components.webui_client.base_data import BaseWebClient, WebClientType +from components.webui_client.client_dialogs import ( + print_install_client_config_dialog, + print_moonraker_not_found_dialog, +) +from components.webui_client.client_utils import ( + copy_common_vars_nginx_cfg, + copy_upstream_nginx_cfg, + create_nginx_cfg, + detect_client_cfg_conflict, + enable_mainsail_remotemode, + get_client_port_selection, + symlink_webui_nginx_log, +) +from components.webui_client.services.web_client_config_setup_service import ( + WebClientConfigSetupService, +) +from core.constants import NGINX_SITES_AVAILABLE, NGINX_SITES_ENABLED +from core.instance_manager.instance_manager import InstanceManager +from core.logger import DialogType, Logger +from core.services.backup_service import BackupService +from core.services.message_service import Message, MessageService +from core.settings.kiauh_settings import KiauhSettings +from core.types.color import Color +from utils.common import check_install_dependencies +from utils.config_utils import add_config_section, remove_config_section +from utils.fs_utils import remove_with_sudo, run_remove_routines, unzip +from utils.input_utils import get_confirm +from utils.instance_utils import get_instances +from utils.sys_utils import cmd_sysctl_service, download_file, get_ipv4_addr + + +class WebClientSetupService: + """Headless-capable service for installing, updating and removing web clients.""" + + CLIENTS = CLIENTS + + def __init__(self, name: str) -> None: + if name not in self.CLIENTS: + raise ValueError(f"Unknown web client: {name}") + self.name = name + self.client: BaseWebClient = self.CLIENTS[name]() + self.settings = KiauhSettings() + + def install( + self, + reinstall: bool = False, + interactive: bool = True, + port: int | None = None, + install_client_cfg: bool | None = None, + continue_without_moonraker: bool = False, + ) -> bool: + """Install the web client. + + When called from the TUI, choices are prompted interactively. The CLI + passes explicit values and ``interactive=False``. + + Returns ``True`` on success and ``False`` when the installation could + not be completed. + """ + mr_instances: List[Moonraker] = get_instances(Moonraker) + + enable_remotemode = False + if not mr_instances: + if interactive: + print_moonraker_not_found_dialog(self.client.display_name) + if not get_confirm( + f"Continue {self.client.display_name} installation?" + ): + return False + elif not continue_without_moonraker: + Logger.print_info( + f"Moonraker not installed; skipping {self.client.display_name} installation." + ) + return False + + enable_remotemode = self._should_enable_remote_mode(mr_instances) + + kl_instances: List[Klipper] = get_instances(Klipper) + install_cfg = False + client_config = self.client.client_config + if ( + kl_instances + and not client_config.config_dir.exists() + and not detect_client_cfg_conflict(self.client) + ): + if interactive: + print_install_client_config_dialog(self.client) + question = f"Download the recommended {client_config.display_name}?" + install_cfg = get_confirm(question, allow_go_back=False) + else: + install_cfg = bool(install_client_cfg) + + default_port: int = int(self.settings.get(self.client.name, "port")) + if port is not None: + resolved_port = port + elif interactive and not reinstall: + resolved_port = get_client_port_selection(self.client, self.settings) + else: + resolved_port = default_port + + check_install_dependencies({"nginx"}) + + try: + _download_client(self.client) + if enable_remotemode and self.client.client == WebClientType.MAINSAIL: + enable_mainsail_remotemode() + + BackupService().backup_printer_config_dir() + add_config_section( + section=f"update_manager {self.client.name}", + instances=mr_instances, + options=[ + ("persistent_files", ["config.json"]), + ("type", "web"), + ("channel", "stable"), + ("repo", str(self.client.repo_path)), + ("path", str(self.client.client_dir)), + ], + ) + InstanceManager.restart_all(mr_instances) + + if install_cfg and kl_instances: + WebClientConfigSetupService(self.name).install( + cfg_backup=False, interactive=interactive + ) + + copy_upstream_nginx_cfg() + copy_common_vars_nginx_cfg() + create_nginx_cfg( + display_name=self.client.display_name, + cfg_name=self.client.name, + template_src=MODULE_PATH.joinpath("assets/nginx_cfg"), + PORT=resolved_port, + ROOT_DIR=self.client.client_dir, + NAME=self.client.name, + ) + + if kl_instances: + symlink_webui_nginx_log(self.client, kl_instances) + cmd_sysctl_service("nginx", "restart") + except Exception: + Logger.print_error(traceback.format_exc()) + if interactive: + Logger.print_dialog( + DialogType.ERROR, + center_content=True, + content=[f"{self.client.display_name} installation failed!"], + ) + return False + + webui_url: str = f"http://{get_ipv4_addr()}{'' if resolved_port == 80 else f':{resolved_port}'}" + if interactive: + Logger.print_dialog( + DialogType.CUSTOM, + custom_title=f"{self.client.display_name} installation complete!", + custom_color=Color.GREEN, + center_content=True, + content=[f"Open {self.client.display_name} now on: {webui_url}"], + ) + else: + Logger.print_info( + f"Installation of {self.client.display_name} complete! URL: {webui_url}" + ) + + return True + + def _should_enable_remote_mode(self, mr_instances: List[Moonraker]) -> bool: + """Return whether Mainsail remote mode should be enabled. + + Remote mode is required when Mainsail is installed without a local + Moonraker instance or when more than one Moonraker instance exists. + """ + return self.client.client == WebClientType.MAINSAIL and ( + not mr_instances or len(mr_instances) > 1 + ) + + def update(self, interactive: bool = True) -> bool: + """Update the web client. Returns ``True`` on success, ``False`` on failure.""" + Logger.print_status(f"Updating {self.client.display_name} ...") + if not self.client.client_dir.exists(): + Logger.print_info( + f"Unable to update {self.client.display_name}. " + "Directory does not exist! Skipping ..." + ) + return True + + try: + with tempfile.NamedTemporaryFile(suffix=".json") as tmp_file: + Logger.print_status( + f"Creating temporary backup of {self.client.config_file} " + f"as {tmp_file.name} ..." + ) + shutil.copy(self.client.config_file, tmp_file.name) + _download_client(self.client) + shutil.copy(tmp_file.name, self.client.config_file) + except Exception: + Logger.print_error(traceback.format_exc()) + Logger.print_error(f"Updating {self.client.display_name} failed!") + return False + + return True + + def remove( + self, + remove_client: bool = False, + remove_client_cfg: bool = False, + backup_config: bool = True, + interactive: bool = True, + ) -> bool: + """Remove the web client and (optionally) its config. + + Returns ``True`` on success and ``False`` if removal failed. + """ + try: + message = self._build_removal_message( + remove_client=remove_client, + remove_client_cfg=remove_client_cfg, + backup_config=backup_config, + interactive=interactive, + ) + MessageService().set_message(message) + except Exception: + Logger.print_error(traceback.format_exc()) + Logger.print_error(f"Error while removing {self.client.display_name}!") + return False + return True + + def _build_removal_message( + self, + remove_client: bool, + remove_client_cfg: bool, + backup_config: bool, + interactive: bool, + ) -> Message: + completion_msg = Message( + title=f"{self.client.display_name} Removal Process completed", + color=Color.GREEN, + ) + mr_instances: List[Moonraker] = get_instances(Moonraker) + kl_instances: List[Klipper] = get_instances(Klipper) + svc = BackupService() + + if backup_config: + version = "" + src = self.client.client_dir + if src.joinpath(".version").exists(): + with open(src.joinpath(".version"), "r") as v: + version = v.readlines()[0] + + target_path = svc.backup_root.joinpath( + f"{self.client.client_dir.name}_{version}" + ) + success = svc.backup_file( + source_path=self.client.config_file, + target_path=target_path, + ) + if success: + completion_msg.text.append( + f"● {self.client.config_file.name} backup created" + ) + + if remove_client: + if self._remove_client_dir(): + completion_msg.text.append(f"● {self.client.display_name} removed") + if self._remove_client_nginx_config(self.client.name): + completion_msg.text.append("● NGINX config removed") + if self._remove_client_nginx_logs(self.client, kl_instances): + completion_msg.text.append("● NGINX logs removed") + + svc.backup_moonraker_conf() + section = f"update_manager {self.client.name}" + handled_instances = remove_config_section(section, mr_instances) + if handled_instances: + names = [i.service_file_path.stem for i in handled_instances] + completion_msg.text.append( + f"● Moonraker config section '{section}' removed for " + f"instance: {', '.join(names)}" + ) + + if remove_client_cfg: + cfg_svc = WebClientConfigSetupService(self.name) + cfg_message = cfg_svc.remove_config( + kl_instances=kl_instances, + mr_instances=mr_instances, + backup_config=backup_config, + svc=svc, + ) + if cfg_message.color == Color.GREEN: + completion_msg.text.extend(cfg_message.text[1:]) + + if not completion_msg.text: + completion_msg.color = Color.YELLOW + completion_msg.centered = True + completion_msg.text.append("Nothing to remove.") + else: + completion_msg.text.insert(0, "The following actions were performed:") + + return completion_msg + + def _remove_client_dir(self) -> bool: + Logger.print_status(f"Removing {self.client.display_name} ...") + return bool(run_remove_routines(self.client.client_dir)) + + def _remove_client_nginx_config(self, name: str) -> bool: + Logger.print_status(f"Removing NGINX config for {name.capitalize()} ...") + return bool( + remove_with_sudo([ + NGINX_SITES_AVAILABLE.joinpath(name), + NGINX_SITES_ENABLED.joinpath(name), + ]) + ) + + def _remove_client_nginx_logs( + self, client: BaseWebClient, instances: List[Klipper] + ) -> bool: + Logger.print_status(f"Removing NGINX logs for {client.display_name} ...") + files = [client.nginx_access_log, client.nginx_error_log] + if instances: + for instance in instances: + files.append( + instance.base.log_dir.joinpath(client.nginx_access_log.name) + ) + files.append( + instance.base.log_dir.joinpath(client.nginx_error_log.name) + ) + return bool(remove_with_sudo(files)) + + +def _download_client(client: BaseWebClient) -> None: + zipfile = f"{client.name.lower()}.zip" + target = Path().home().joinpath(zipfile) + try: + Logger.print_status( + f"Downloading {client.display_name} from {client.download_url} ..." + ) + download_file(client.download_url, target, True) + Logger.print_ok("Download complete!") + + Logger.print_status(f"Extracting {zipfile} ...") + unzip(target, client.client_dir) + target.unlink(missing_ok=True) + Logger.print_ok("OK!") + except Exception: + Logger.print_error(f"Downloading {client.display_name} failed!") + raise diff --git a/kiauh/components/webui_client/tests/__init__.py b/kiauh/components/webui_client/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kiauh/components/webui_client/tests/conftest.py b/kiauh/components/webui_client/tests/conftest.py new file mode 100644 index 00000000..e069e5ff --- /dev/null +++ b/kiauh/components/webui_client/tests/conftest.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import pytest +from components.webui_client.base_data import WebClientType + + +@dataclass +class FakeClientConfig: + name: str = "mainsail-config" + display_name: str = "Mainsail-Config" + config_dir: Path = Path("/tmp/mainsail-config") + config_filename: str = "mainsail.cfg" + config_section: str = "include mainsail.cfg" + repo_url: str = "https://github.com/mainsail-crew/mainsail-config.git" + + +@dataclass +class FakeWebClient: + name: str = "mainsail" + display_name: str = "Mainsail" + client: WebClientType = WebClientType.MAINSAIL + client_dir: Path = Path("/tmp/mainsail") + config_file: Path = Path("/tmp/mainsail/config.json") + repo_path: str = "mainsail-crew/mainsail" + nginx_config: Path = Path("/tmp/nginx/mainsail") + nginx_access_log: Path = Path("/tmp/log/mainsail-access.log") + nginx_error_log: Path = Path("/tmp/log/mainsail-error.log") + download_url: str = "https://example.com/mainsail.zip" + client_config: Any = field(default_factory=FakeClientConfig) + + +@pytest.fixture +def client() -> FakeWebClient: + return FakeWebClient() + + +@pytest.fixture +def settings(monkeypatch: pytest.MonkeyPatch) -> Any: + from core.settings.kiauh_settings import KiauhSettings + + KiauhSettings._KiauhSettings__instance = None + KiauhSettings._KiauhSettings__initialized = False + return KiauhSettings() + + diff --git a/kiauh/components/webui_client/tests/test_client_utils.py b/kiauh/components/webui_client/tests/test_client_utils.py new file mode 100644 index 00000000..69dd8e56 --- /dev/null +++ b/kiauh/components/webui_client/tests/test_client_utils.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, List + +import pytest +from components.webui_client import client_utils +from components.webui_client.base_data import WebClientType +from components.webui_client.client_utils import ( + backup_client_config_data, + backup_client_data, + create_client_config_symlink, + detect_client_cfg_conflict, + get_client_status, + get_current_client_config, + get_download_url, + get_local_client_version, + get_next_free_port, + get_nginx_listen_port, + get_remote_client_version, + read_ports_from_nginx_configs, + set_listen_port, +) + + +class TestGetLocalClientVersion: + def test_returns_none_when_client_dir_missing(self, client) -> None: + client.client_dir = Path("/does/not/exist") + assert get_local_client_version(client) is None + + def test_reads_release_info_json(self, client, tmp_path: Path) -> None: + client.client_dir = tmp_path + release = tmp_path / "release_info.json" + release.write_text('{"version": "v2.0.0"}') + + assert get_local_client_version(client) == "v2.0.0" + + def test_falls_back_to_version_file(self, client, tmp_path: Path) -> None: + client.client_dir = tmp_path + (tmp_path / ".version").write_text("v1.2.3\n") + + assert get_local_client_version(client) == "v1.2.3" + + def test_returns_none_for_empty_version_file(self, client, tmp_path: Path) -> None: + client.client_dir = tmp_path + (tmp_path / ".version").write_text("") + + assert get_local_client_version(client) is None + + +class TestGetRemoteClientVersion: + def test_returns_tag_when_available(self, monkeypatch, client) -> None: + monkeypatch.setattr( + client_utils, "get_latest_remote_tag", lambda repo: "v3.0.0" + ) + assert get_remote_client_version(client) == "v3.0.0" + + def test_returns_none_when_tag_empty(self, monkeypatch, client) -> None: + monkeypatch.setattr(client_utils, "get_latest_remote_tag", lambda repo: "") + assert get_remote_client_version(client) is None + + def test_returns_none_on_error(self, monkeypatch, client) -> None: + monkeypatch.setattr( + client_utils, + "get_latest_remote_tag", + lambda repo: (_ for _ in ()).throw(RuntimeError("network")), + ) + assert get_remote_client_version(client) is None + + +class TestGetDownloadUrl: + def test_returns_stable_url_when_not_unstable(self, monkeypatch, client) -> None: + class FakeSettings: + def get(self, name, key): + return False + + monkeypatch.setattr(client_utils, "KiauhSettings", FakeSettings) + url = get_download_url("https://example.com/repo", client) + assert "latest/download" in url + + def test_returns_unstable_url_when_available(self, monkeypatch, client) -> None: + class FakeSettings: + def get(self, name, key): + return True + + monkeypatch.setattr(client_utils, "KiauhSettings", FakeSettings) + monkeypatch.setattr( + client_utils, "get_latest_unstable_tag", lambda repo: "v9.9.9" + ) + url = get_download_url("https://example.com/repo", client) + assert "v9.9.9" in url + + +class TestDetectClientCfgConflict: + def test_mainsail_conflicts_with_fluidd_installed( + self, monkeypatch, client + ) -> None: + def fake_status(c): + code = 2 if c.client == WebClientType.FLUIDD else 0 + return type("S", (), {"status": code})() + + monkeypatch.setattr(client_utils, "get_client_config_status", fake_status) + client.client = WebClientType.MAINSAIL + assert detect_client_cfg_conflict(client) is True + + def test_fluidd_conflicts_with_mainsail_installed( + self, monkeypatch, client + ) -> None: + def fake_status(c): + code = 2 if c.client == WebClientType.MAINSAIL else 0 + return type("S", (), {"status": code})() + + monkeypatch.setattr(client_utils, "get_client_config_status", fake_status) + client.client = WebClientType.FLUIDD + assert detect_client_cfg_conflict(client) is True + + +class TestGetNextFreePort: + def test_returns_lowest_unused_port(self) -> None: + assert get_next_free_port([80, 81]) == 82 + + def test_starts_at_80(self) -> None: + assert get_next_free_port([]) == 80 + + +class TestNginxPortParsing: + def test_parses_plain_listen_port(self, tmp_path: Path) -> None: + cfg = tmp_path / "site" + cfg.write_text("server {\n listen 8080;\n}\n") + assert get_nginx_listen_port(cfg) == 8080 + + def test_parses_listen_port_with_host(self, tmp_path: Path) -> None: + cfg = tmp_path / "site" + cfg.write_text("server {\n listen 127.0.0.1:9090;\n}\n") + assert get_nginx_listen_port(cfg) == 9090 + + def test_returns_none_when_no_listen(self, tmp_path: Path) -> None: + cfg = tmp_path / "site" + cfg.write_text("server {\n}\n") + assert get_nginx_listen_port(cfg) is None + + def test_reads_all_configs_in_enabled_dir( + self, monkeypatch, tmp_path: Path + ) -> None: + sites = tmp_path / "sites-enabled" + sites.mkdir() + (sites / "a").write_text("listen 1000;") + (sites / "b").write_text("listen 2000;") + monkeypatch.setattr(client_utils, "NGINX_SITES_ENABLED", sites) + + ports = read_ports_from_nginx_configs() + assert ports == [1000, 2000] + + def test_returns_empty_when_enabled_dir_missing(self, monkeypatch) -> None: + monkeypatch.setattr(client_utils, "NGINX_SITES_ENABLED", Path("/missing")) + assert read_ports_from_nginx_configs() == [] + + +class TestSetListenPort: + def test_replaces_port_in_config( + self, client, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + client.name = "mainsail" + monkeypatch.setattr(client_utils, "NGINX_SITES_AVAILABLE", tmp_path) + cfg = tmp_path / "mainsail" + cfg.write_text("server {\n listen 80;\n}\n") + + set_listen_port(client, 80, 8080) + + assert "listen 8080" in cfg.read_text() + + +class TestCreateClientConfigSymlink: + def test_creates_symlink_per_instance( + self, monkeypatch, client, tmp_path: Path + ) -> None: + client.client_config.config_dir = tmp_path / "cfg" + client.client_config.config_filename = "mainsail.cfg" + called: List[Any] = [] + monkeypatch.setattr( + client_utils, "create_symlink", lambda s, t: called.append((s, t)) + ) + + class FakeInstance: + base = type("Base", (), {"cfg_dir": tmp_path / "printer"})() + + create_client_config_symlink(client.client_config, [FakeInstance()]) + + assert len(called) == 1 + + def test_symlink_failure_logs_error_and_continues( + self, monkeypatch, client, tmp_path: Path + ) -> None: + client.client_config.config_dir = tmp_path / "cfg" + client.client_config.config_filename = "mainsail.cfg" + + attempt: List[Any] = [] + + def flaky_create_symlink(source, target) -> None: + attempt.append(target) + if len(attempt) == 1: + raise RuntimeError("permission denied") + + monkeypatch.setattr(client_utils, "create_symlink", flaky_create_symlink) + errors: List[str] = [] + monkeypatch.setattr( + client_utils.Logger, + "print_error", + lambda msg, *a, **k: errors.append(str(msg)), + ) + + class FakeInstance: + def __init__(self, cfg: Path) -> None: + self.base = type("Base", (), {"cfg_dir": cfg})() + + create_client_config_symlink( + client.client_config, + [FakeInstance(tmp_path / "a"), FakeInstance(tmp_path / "b")], + ) + + assert len(attempt) == 2 # failure did not abort the loop + assert any("symlink" in m.lower() for m in errors) + + +class TestBackupClientData: + def test_backs_up_client_dir_and_config_file( + self, monkeypatch, client, tmp_path: Path + ) -> None: + client.client_dir = tmp_path / "mainsail" + client.client_dir.mkdir() + (client.client_dir / ".version").write_text("v1\n") + client.config_file = client.client_dir / "config.json" + client.config_file.write_text("{}") + + calls: List[str] = [] + + class FakeBackup: + backup_root = tmp_path / "backups" + + def backup_directory(self, **kwargs): + calls.append("dir") + + def backup_file(self, **kwargs): + calls.append("file") + + monkeypatch.setattr(client_utils, "BackupService", FakeBackup) + backup_client_data(client) + + assert "dir" in calls + assert "file" in calls + + +class TestBackupClientConfigData: + def test_backs_up_config_dir(self, monkeypatch, client, tmp_path: Path) -> None: + client.client_dir = tmp_path / "mainsail" + client.client_dir.mkdir() + (client.client_dir / ".version").write_text("v1\n") + client.client_config.config_dir = tmp_path / "mainsail-config" + + calls: List[str] = [] + + class FakeBackup: + backup_root = tmp_path / "backups" + + def backup_directory(self, **kwargs): + calls.append("dir") + + monkeypatch.setattr(client_utils, "BackupService", FakeBackup) + backup_client_config_data(client) + + assert "dir" in calls + + +class TestGetClientStatus: + def test_sets_status_not_installed_when_dir_missing( + self, monkeypatch, client, tmp_path: Path + ) -> None: + client.client_dir = tmp_path / "missing" + monkeypatch.setattr( + client_utils, + "get_install_status", + lambda *args, **kwargs: type( + "S", (), {"status": 2, "local": None, "remote": None} + )(), + ) + + status = get_client_status(client) + assert status.status == 0 + + +class TestGetCurrentClientConfig: + def test_returns_dash_when_no_config_dirs(self, monkeypatch) -> None: + monkeypatch.setattr( + client_utils, + "MainsailData", + lambda: type( + "M", + (), + { + "client_config": type( + "C", (), {"config_dir": Path("/no/mainsail")} + )() + }, + )(), + ) + monkeypatch.setattr( + client_utils, + "FluiddData", + lambda: type( + "F", + (), + {"client_config": type("C", (), {"config_dir": Path("/no/fluidd")})()}, + )(), + ) + + result = get_current_client_config() + assert "-" in result + + def test_returns_single_installed_name(self, monkeypatch, tmp_path: Path) -> None: + cfg_dir = tmp_path / "mainsail-config" + cfg_dir.mkdir() + monkeypatch.setattr( + client_utils, + "MainsailData", + lambda: type( + "M", + (), + { + "client_config": type( + "C", + (), + {"config_dir": cfg_dir, "display_name": "Mainsail-Config"}, + )() + }, + )(), + ) + monkeypatch.setattr( + client_utils, + "FluiddData", + lambda: type( + "F", + (), + {"client_config": type("C", (), {"config_dir": Path("/no/fluidd")})()}, + )(), + ) + + result = get_current_client_config() + assert "Mainsail-Config" in result diff --git a/kiauh/core/menus/install_menu.py b/kiauh/core/menus/install_menu.py index d1577d4e..2a94db26 100644 --- a/kiauh/core/menus/install_menu.py +++ b/kiauh/core/menus/install_menu.py @@ -15,16 +15,17 @@ from components.crowsnest.crowsnest import install_crowsnest from components.klipper.services.klipper_setup_service import KlipperSetupService from components.klipperscreen.klipperscreen import install_klipperscreen from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService -from components.webui_client.client_config.client_config_setup import ( - install_client_config, -) -from components.webui_client.client_setup import install_client from components.webui_client.fluidd_data import FluiddData from components.webui_client.mainsail_data import MainsailData from components.webui_client.menus.client_install_menu import ClientInstallMenu +from components.webui_client.services.web_client_config_setup_service import ( + WebClientConfigSetupService, +) +from components.webui_client.services.web_client_setup_service import ( + WebClientSetupService, +) from core.menus import Option from core.menus.base_menu import BaseMenu -from core.settings.kiauh_settings import KiauhSettings from core.types.color import Color @@ -87,20 +88,20 @@ class InstallMenu(BaseMenu): if client.client_dir.exists(): ClientInstallMenu(client, self.__class__).run() else: - install_client(client, settings=KiauhSettings()) + WebClientSetupService("mainsail").install() def install_mainsail_config(self, **kwargs) -> None: - install_client_config(MainsailData()) + WebClientConfigSetupService("mainsail").install() def install_fluidd(self, **kwargs) -> None: client: FluiddData = FluiddData() if client.client_dir.exists(): ClientInstallMenu(client, self.__class__).run() else: - install_client(client, settings=KiauhSettings()) + WebClientSetupService("fluidd").install() def install_fluidd_config(self, **kwargs) -> None: - install_client_config(FluiddData()) + WebClientConfigSetupService("fluidd").install() def install_klipperscreen(self, **kwargs) -> None: install_klipperscreen() diff --git a/kiauh/core/menus/repo_select_menu.py b/kiauh/core/menus/repo_select_menu.py index c93114e3..76d29487 100644 --- a/kiauh/core/menus/repo_select_menu.py +++ b/kiauh/core/menus/repo_select_menu.py @@ -10,13 +10,13 @@ from __future__ import annotations from typing import List, Literal, Type -from core.logger import Logger, DialogType +from core.logger import DialogType, Logger from core.menus import Option from core.menus.base_menu import BaseMenu from core.settings.kiauh_settings import KiauhSettings, Repository from core.types.color import Color from procedures.switch_repo import run_switch_repo_routine -from utils.input_utils import get_string_input, get_number_input, get_confirm +from utils.input_utils import get_confirm, get_number_input, get_string_input # noinspection PyUnusedLocal diff --git a/kiauh/core/menus/tests/__init__.py b/kiauh/core/menus/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kiauh/core/menus/tests/test_base_menu.py b/kiauh/core/menus/tests/test_base_menu.py new file mode 100644 index 00000000..3ea12eea --- /dev/null +++ b/kiauh/core/menus/tests/test_base_menu.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from typing import List, Type + +import pytest +from core.menus import FooterType, Option +from core.menus.base_menu import ( + BaseMenu, + MenuTitleStyle, + PostInitCaller, + print_back_footer, + print_back_help_footer, + print_blank_footer, + print_header, + print_quit_footer, +) + + +class ConcreteMenu(BaseMenu, metaclass=PostInitCaller): + title = "Concrete" + footer_type = FooterType.BACK + + def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None: + self.previous_menu = previous_menu + + def set_options(self) -> None: + self.options = { + "1": Option(method=lambda **k: None), + } + + def print_menu(self) -> None: + pass + + +@pytest.fixture +def concrete(monkeypatch: pytest.MonkeyPatch) -> ConcreteMenu: + monkeypatch.setattr("core.menus.base_menu.print_header", lambda: None) + return ConcreteMenu() + + +class TestBaseMenuHelpers: + def test_print_header_outputs_banner(self, capsys) -> None: + print_header() + captured = capsys.readouterr() + assert "KIAUH" in captured.out + + def test_print_quit_footer(self, capsys) -> None: + print_quit_footer() + assert "Quit" in capsys.readouterr().out + + def test_print_back_footer(self, capsys) -> None: + print_back_footer() + assert "Back" in capsys.readouterr().out + + def test_print_back_help_footer(self, capsys) -> None: + print_back_help_footer() + out = capsys.readouterr().out + assert "Back" in out + assert "Help" in out + + def test_print_blank_footer(self, capsys) -> None: + print_blank_footer() + assert "╝" in capsys.readouterr().out + + +class TestBaseMenuLifecycle: + def test_direct_instantiation_raises(self) -> None: + with pytest.raises(NotImplementedError): + BaseMenu() + + def test_options_include_back_for_back_footer(self, concrete: ConcreteMenu) -> None: + assert "b" in concrete.options + + def test_go_back_does_nothing_without_previous_menu( + self, concrete: ConcreteMenu + ) -> None: + concrete.previous_menu = None + # should not raise + concrete._BaseMenu__go_back() + + def test_exit_calls_system_exit(self, monkeypatch: pytest.MonkeyPatch) -> None: + exits: List[int] = [] + monkeypatch.setattr("core.menus.base_menu.sys.exit", lambda c: exits.append(c)) + + menu = ConcreteMenu() + menu._BaseMenu__exit() + + assert exits == [0] + + +class TestMenuTitleStyle: + def test_style_values(self) -> None: + assert MenuTitleStyle.PLAIN.value == "plain" + assert MenuTitleStyle.STYLED.value == "styled" diff --git a/kiauh/core/menus/tests/test_install_menu.py b/kiauh/core/menus/tests/test_install_menu.py new file mode 100644 index 00000000..e1c2484d --- /dev/null +++ b/kiauh/core/menus/tests/test_install_menu.py @@ -0,0 +1,161 @@ +# ======================================================================= # +# Copyright (C) 2020 - 2026 Dominik Willner # +# # +# This file is part of KIAUH - Klipper Installation And Update Helper # +# https://github.com/dw-0/kiauh # +# # +# This file may be distributed under the terms of the GNU GPLv3 license # +# ======================================================================= # +from __future__ import annotations + +from typing import Any, List + +import core.menus.install_menu as install_menu_module +import pytest +from core.menus.install_menu import InstallMenu + + +@pytest.fixture +def menu(monkeypatch: pytest.MonkeyPatch) -> InstallMenu: + # Avoid the heavyweight singleton setup services loading real instances. + monkeypatch.setattr(install_menu_module, "KlipperSetupService", lambda: object()) + monkeypatch.setattr(install_menu_module, "MoonrakerSetupService", lambda: object()) + return InstallMenu() + + +def _fake_data(client_dir_exists: bool) -> Any: + return type( + "Client", + (), + { + "client_dir": type( + "P", + (), + {"exists": lambda self: client_dir_exists}, + )(), + }, + )() + + +class TestInstallMenuWiring: + def test_options_expose_every_install_entry(self, menu: InstallMenu) -> None: + for key in ("1", "2", "3", "4", "5", "6", "7", "8"): + assert key in menu.options + + def test_set_previous_menu_defaults_to_main_menu( + self, menu: InstallMenu, monkeypatch + ) -> None: + # importing MainMenu here avoids an import cycle in the module under test + from core.menus.main_menu import MainMenu + + menu.set_previous_menu(None) + assert menu.previous_menu is MainMenu + + def test_install_mainsail_when_absent_calls_setup_service( + self, menu: InstallMenu, monkeypatch + ) -> None: + calls: List[Any] = [] + monkeypatch.setattr( + install_menu_module, "MainsailData", lambda: _fake_data(False) + ) + monkeypatch.setattr( + install_menu_module, + "WebClientSetupService", + lambda name: type( + "S", (), {"install": lambda self: calls.append(name) or True} + )(), + ) + + menu.install_mainsail() + + assert calls == ["mainsail"] + + def test_install_mainsail_when_present_opens_client_install_menu( + self, menu: InstallMenu, monkeypatch + ) -> None: + opened: List[Any] = [] + monkeypatch.setattr( + install_menu_module, "MainsailData", lambda: _fake_data(True) + ) + + class _FakeClientInstallMenu: + def __init__(self, client, previous_menu) -> None: + opened.append((client, previous_menu)) + + def run(self) -> None: + pass + + monkeypatch.setattr( + install_menu_module, "ClientInstallMenu", _FakeClientInstallMenu + ) + + menu.install_mainsail() + + assert len(opened) == 1 + + def test_install_fluidd_when_absent_calls_setup_service( + self, menu: InstallMenu, monkeypatch + ) -> None: + calls: List[Any] = [] + monkeypatch.setattr( + install_menu_module, "FluiddData", lambda: _fake_data(False) + ) + monkeypatch.setattr( + install_menu_module, + "WebClientSetupService", + lambda name: type( + "S", (), {"install": lambda self: calls.append(name) or True} + )(), + ) + + menu.install_fluidd() + + assert calls == ["fluidd"] + + def test_install_mainsail_config_delegates_to_config_service( + self, menu: InstallMenu, monkeypatch + ) -> None: + calls: List[Any] = [] + monkeypatch.setattr( + install_menu_module, + "WebClientConfigSetupService", + lambda name: type( + "S", (), {"install": lambda self: calls.append(name) or True} + )(), + ) + + menu.install_mainsail_config() + + assert calls == ["mainsail"] + + def test_install_fluidd_config_delegates_to_config_service( + self, menu: InstallMenu, monkeypatch + ) -> None: + calls: List[Any] = [] + monkeypatch.setattr( + install_menu_module, + "WebClientConfigSetupService", + lambda name: type( + "S", (), {"install": lambda self: calls.append(name) or True} + )(), + ) + + menu.install_fluidd_config() + + assert calls == ["fluidd"] + + def test_install_klipperscreen_and_crowsnest_delegates( + self, menu: InstallMenu, monkeypatch + ) -> None: + calls: List[str] = [] + monkeypatch.setattr( + install_menu_module, "install_klipperscreen", lambda: calls.append("ks") + ) + monkeypatch.setattr( + install_menu_module, "install_crowsnest", lambda: calls.append("cn") + ) + + menu.install_klipperscreen() + menu.install_crowsnest() + + assert calls == ["ks", "cn"] \ No newline at end of file diff --git a/kiauh/core/menus/tests/test_menu_dispatch.py b/kiauh/core/menus/tests/test_menu_dispatch.py new file mode 100644 index 00000000..40d8e183 --- /dev/null +++ b/kiauh/core/menus/tests/test_menu_dispatch.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import Any, Dict, List + +import pytest +from core.menus.main_menu import MainMenu + + +@pytest.fixture +def fake_menu(monkeypatch: pytest.MonkeyPatch): + """Provide an isolated fake menu class and a call log for each test.""" + calls: List[Dict[str, Any]] = [] + + class FakeMenu: + def __init__(self, **kwargs: Any) -> None: + calls.append(kwargs) + + def run(self) -> None: + pass + + yield FakeMenu, calls + + +@pytest.fixture +def reset_main_menu(monkeypatch: pytest.MonkeyPatch) -> None: + # silence status fetching during menu construction if any + monkeypatch.setattr( + "core.menus.main_menu.MainMenu._fetch_status", lambda self: None + ) + + +@pytest.mark.parametrize( + "option_key, target", + [ + ("1", "InstallMenu"), + ("2", "UpdateMenu"), + ("3", "RemoveMenu"), + ("4", "AdvancedMenu"), + ("5", "BackupMenu"), + ("s", "SettingsMenu"), + ("e", "ExtensionsMenu"), + ], +) +def test_main_menu_routes_to_submenu( + option_key: str, + target: str, + monkeypatch: pytest.MonkeyPatch, + reset_main_menu, + fake_menu, +) -> None: + fake_menu_cls, calls = fake_menu + monkeypatch.setattr(f"core.menus.main_menu.{target}", fake_menu_cls) + + menu = MainMenu() + option = menu.options[option_key] + option.method(opt_index=option.opt_index, opt_data=option.opt_data) + + assert len(calls) == 1 + assert calls[0].get("previous_menu") is MainMenu + + +def test_main_menu_quit_exits(monkeypatch: pytest.MonkeyPatch, reset_main_menu) -> None: + exits: List[int] = [] + monkeypatch.setattr("core.menus.main_menu.sys.exit", lambda code: exits.append(code)) + + menu = MainMenu() + menu.options["q"].method() + + assert exits == [0] diff --git a/kiauh/core/menus/tests/test_repo_select_menu.py b/kiauh/core/menus/tests/test_repo_select_menu.py new file mode 100644 index 00000000..dd385d8a --- /dev/null +++ b/kiauh/core/menus/tests/test_repo_select_menu.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Any, List + +import pytest +from core.menus.repo_select_menu import RepoSelectMenu + + +class FakeRepo: + def __init__(self, url: str = "https://example.com/repo.git", branch: str = "master") -> None: + self.url = url + self.branch = branch + + +@pytest.fixture +def patched_menu(monkeypatch: pytest.MonkeyPatch) -> RepoSelectMenu: + class FakeSettings: + class _K: + repositories: List[Any] = [] + + class _M: + repositories: List[Any] = [] + + klipper = _K() + moonraker = _M() + + def save(self) -> None: + pass + + monkeypatch.setattr( + "core.menus.repo_select_menu.KiauhSettings", lambda: FakeSettings() + ) + monkeypatch.setattr( + "core.menus.repo_select_menu.run_switch_repo_routine", + lambda *a, **k: None, + ) + + return RepoSelectMenu("klipper", repos=[FakeRepo()]) + + +class TestRepoSelectMenuConstruction: + def test_title_for_klipper(self) -> None: + menu = RepoSelectMenu("klipper", repos=[]) + assert "Klipper" in menu.title + + def test_title_for_moonraker(self) -> None: + menu = RepoSelectMenu("moonraker", repos=[]) + assert "Moonraker" in menu.title + + def test_options_include_add_remove_back( + self, patched_menu: RepoSelectMenu + ) -> None: + assert "a" in patched_menu.options + assert "r" in patched_menu.options + assert "b" in patched_menu.options + + def test_repository_options_are_indexed( + self, patched_menu: RepoSelectMenu + ) -> None: + assert "1" in patched_menu.options + + +class TestRepoSelectMenuActions: + def test_select_repository_runs_switch_routine( + self, patched_menu: RepoSelectMenu, monkeypatch: pytest.MonkeyPatch + ) -> None: + called: List[Any] = [] + monkeypatch.setattr( + "core.menus.repo_select_menu.run_switch_repo_routine", + lambda name, url, branch: called.append((name, url, branch)), + ) + + repo = FakeRepo("https://github.com/k/klipper.git", "main") + patched_menu.select_repository(opt_data=repo) + + assert called == [("klipper", "https://github.com/k/klipper.git", "main")] + + def test_remove_repository_does_nothing_when_empty( + self, patched_menu: RepoSelectMenu, monkeypatch: pytest.MonkeyPatch + ) -> None: + patched_menu.repos = [] + patched_menu.set_options() + # should not raise + patched_menu.remove_repository() diff --git a/kiauh/core/menus/tests/test_settings_menu.py b/kiauh/core/menus/tests/test_settings_menu.py new file mode 100644 index 00000000..0fa5889a --- /dev/null +++ b/kiauh/core/menus/tests/test_settings_menu.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import pytest +from core.menus.settings_menu import SettingsMenu + + +@pytest.fixture +def patched_settings_menu(monkeypatch: pytest.MonkeyPatch) -> SettingsMenu: + class FakeRepo: + def __init__(self): + self.repositories = [] + + class FakeKiauh: + backup_before_update = True + + class FakeSettings: + kiauh = FakeKiauh() + mainsail = type("M", (), {"unstable_releases": False})() + fluidd = type("F", (), {"unstable_releases": False})() + klipper = FakeRepo() + moonraker = FakeRepo() + + def save(self) -> None: + pass + + monkeypatch.setattr( + "core.menus.settings_menu.KiauhSettings", lambda: FakeSettings() + ) + monkeypatch.setattr( + "core.menus.settings_menu.get_klipper_status", + lambda: type("S", (), {"repo": None, "repo_url": "", "branch": ""})(), + ) + monkeypatch.setattr( + "core.menus.settings_menu.get_moonraker_status", + lambda: type("S", (), {"repo": None, "repo_url": "", "branch": ""})(), + ) + + return SettingsMenu() + + +class TestSettingsMenuConstruction: + def test_options_cover_settings(self, patched_settings_menu: SettingsMenu) -> None: + assert {"1", "2", "3", "4", "5"}.issubset(patched_settings_menu.options) + + def test_loads_backup_setting(self, patched_settings_menu: SettingsMenu) -> None: + assert patched_settings_menu.auto_backups_enabled is True + + +class TestToggleMethods: + def test_toggle_mainsail_release(self, patched_settings_menu: SettingsMenu) -> None: + patched_settings_menu.mainsail_unstable = False + patched_settings_menu.toggle_mainsail_release() + assert patched_settings_menu.mainsail_unstable is True + + def test_toggle_fluidd_release(self, patched_settings_menu: SettingsMenu) -> None: + patched_settings_menu.fluidd_unstable = False + patched_settings_menu.toggle_fluidd_release() + assert patched_settings_menu.fluidd_unstable is True + + def test_toggle_backup_before_update( + self, patched_settings_menu: SettingsMenu + ) -> None: + patched_settings_menu.auto_backups_enabled = True + patched_settings_menu.toggle_backup_before_update() + assert patched_settings_menu.auto_backups_enabled is False diff --git a/kiauh/core/menus/tests/test_update_menu.py b/kiauh/core/menus/tests/test_update_menu.py new file mode 100644 index 00000000..3a502350 --- /dev/null +++ b/kiauh/core/menus/tests/test_update_menu.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from typing import Any, List + +import pytest +from core.menus.update_menu import UpdateMenu + + +def _make_status(status: int = 2, local: str | None = "v1", remote: str | None = "v2"): + return type( + "ComponentStatus", (), {"status": status, "local": local, "remote": remote} + )() + + +@pytest.fixture +def patched_menu(monkeypatch: pytest.MonkeyPatch) -> UpdateMenu: + monkeypatch.setattr( + "core.menus.update_menu.get_klipper_status", + lambda: _make_status(), + ) + monkeypatch.setattr( + "core.menus.update_menu.get_moonraker_status", + lambda: _make_status(), + ) + monkeypatch.setattr( + "core.menus.update_menu.get_client_status", + lambda *args, **kwargs: _make_status(), + ) + monkeypatch.setattr( + "core.menus.update_menu.get_client_config_status", + lambda *args, **kwargs: _make_status(), + ) + monkeypatch.setattr( + "core.menus.update_menu.get_klipperscreen_status", + lambda: _make_status(), + ) + monkeypatch.setattr( + "core.menus.update_menu.get_crowsnest_status", + lambda: _make_status(), + ) + monkeypatch.setattr( + "core.menus.update_menu.update_system_package_lists", lambda silent: None + ) + monkeypatch.setattr("core.menus.update_menu.get_upgradable_packages", lambda: []) + + class FakeSpinner: + def __init__(self, *a, **k): + pass + + def start(self): + pass + + def stop(self): + pass + + monkeypatch.setattr("core.menus.base_menu.Spinner", FakeSpinner) + + return UpdateMenu() + + +class TestUpdateMenuConstruction: + def test_options_cover_all_components(self, patched_menu: UpdateMenu) -> None: + expected = {"a", "1", "2", "3", "4", "5", "6", "7", "8", "9", "b"} + assert set(patched_menu.options.keys()) == expected + + def test_status_data_marked_installed(self, patched_menu: UpdateMenu) -> None: + for name in ["klipper", "moonraker", "mainsail", "fluidd"]: + assert patched_menu.status_data[name]["installed"] is True + + +class TestUpdateRoutine: + def test_run_update_routine_skips_not_installed( + self, patched_menu: UpdateMenu, monkeypatch: pytest.MonkeyPatch + ) -> None: + patched_menu.status_data["klipper"]["installed"] = False + called: List[Any] = [] + + patched_menu._run_update_routine("klipper", lambda: called.append(True)) + + assert called == [] + + def test_run_update_routine_skips_up_to_date( + self, patched_menu: UpdateMenu + ) -> None: + patched_menu.status_data["klipper"]["local"] = "v1" + patched_menu.status_data["klipper"]["remote"] = "v1" + called: List[Any] = [] + + patched_menu._run_update_routine("klipper", lambda: called.append(True)) + + assert called == [] + + def test_run_update_routine_executes_when_update_available( + self, patched_menu: UpdateMenu, monkeypatch: pytest.MonkeyPatch + ) -> None: + patched_menu.status_data["klipper"]["installed"] = True + patched_menu.status_data["klipper"]["local"] = "v1" + patched_menu.status_data["klipper"]["remote"] = "v2" + called: List[Any] = [] + monkeypatch.setattr( + "core.menus.update_menu.get_klipper_status", lambda: _make_status() + ) + + patched_menu._run_update_routine("klipper", lambda: called.append(True)) + + assert called == [True] + + +class TestSystemUpdates: + def test_no_packages_logs_info(self, patched_menu: UpdateMenu) -> None: + patched_menu.packages = [] + # should not raise + patched_menu._run_system_updates() + + def test_fetch_status_translates_runtime_error_to_warning( + self, patched_menu: UpdateMenu, monkeypatch: pytest.MonkeyPatch + ) -> None: + # when ``apt-get update`` fails, ``update_system_package_lists`` + # raises ``RuntimeError``. The update menu is a presentation boundary — + # it must catch, log a warning and show an empty upgradable list instead + # of crashing the menu. + def _raise(*_a, **_k): + raise RuntimeError("apt-get update failed") + + monkeypatch.setattr( + "core.menus.update_menu.update_system_package_lists", _raise + ) + monkeypatch.setattr( + "core.menus.update_menu.get_upgradable_packages", lambda: [] + ) + + patched_menu._fetch_system_package_update_status() + + assert patched_menu.packages == [] + assert patched_menu.package_count == 0 + + def test_packages_trigger_upgrade_flow( + self, patched_menu: UpdateMenu, monkeypatch: pytest.MonkeyPatch + ) -> None: + patched_menu.packages = ["curl", "git"] + upgraded: List[List[str]] = [] + monkeypatch.setattr("core.menus.update_menu.get_confirm", lambda *a, **k: True) + monkeypatch.setattr( + "core.menus.update_menu.upgrade_system_packages", + lambda pkgs: upgraded.append(pkgs), + ) + monkeypatch.setattr( + "core.menus.update_menu.update_system_package_lists", lambda silent: None + ) + monkeypatch.setattr( + "core.menus.update_menu.get_upgradable_packages", lambda: [] + ) + + patched_menu._run_system_updates() + + assert upgraded == [["curl", "git"]] + + +class TestUpdateAll: + def test_update_all_invokes_each_component_update( + self, patched_menu: UpdateMenu, monkeypatch: pytest.MonkeyPatch + ) -> None: + calls: List[str] = [] + monkeypatch.setattr( + patched_menu, "update_klipper", lambda **k: calls.append("klipper") + ) + monkeypatch.setattr( + patched_menu, "update_moonraker", lambda **k: calls.append("moonraker") + ) + monkeypatch.setattr( + patched_menu, "update_mainsail", lambda **k: calls.append("mainsail") + ) + monkeypatch.setattr( + patched_menu, + "update_mainsail_config", + lambda **k: calls.append("mainsail_config"), + ) + monkeypatch.setattr( + patched_menu, "update_fluidd", lambda **k: calls.append("fluidd") + ) + monkeypatch.setattr( + patched_menu, + "update_fluidd_config", + lambda **k: calls.append("fluidd_config"), + ) + monkeypatch.setattr( + patched_menu, + "update_klipperscreen", + lambda **k: calls.append("klipperscreen"), + ) + monkeypatch.setattr( + patched_menu, "update_crowsnest", lambda **k: calls.append("crowsnest") + ) + monkeypatch.setattr( + patched_menu, "upgrade_system_packages", lambda **k: calls.append("system") + ) + + patched_menu.update_all() + + assert set(calls) == { + "klipper", + "moonraker", + "mainsail", + "mainsail_config", + "fluidd", + "fluidd_config", + "klipperscreen", + "crowsnest", + "system", + } diff --git a/kiauh/core/menus/update_menu.py b/kiauh/core/menus/update_menu.py index a2d4536a..ce0e3bc1 100644 --- a/kiauh/core/menus/update_menu.py +++ b/kiauh/core/menus/update_menu.py @@ -22,16 +22,18 @@ from components.klipperscreen.klipperscreen import ( ) from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService from components.moonraker.utils.utils import get_moonraker_status -from components.webui_client.client_config.client_config_setup import ( - update_client_config, -) -from components.webui_client.client_setup import update_client from components.webui_client.client_utils import ( get_client_config_status, get_client_status, ) from components.webui_client.fluidd_data import FluiddData from components.webui_client.mainsail_data import MainsailData +from components.webui_client.services.web_client_config_setup_service import ( + WebClientConfigSetupService, +) +from components.webui_client.services.web_client_setup_service import ( + WebClientSetupService, +) from core.logger import DialogType, Logger from core.menus import Option from core.menus.base_menu import BaseMenu @@ -203,29 +205,25 @@ class UpdateMenu(BaseMenu): def update_mainsail(self, **kwargs) -> None: self._run_update_routine( "mainsail", - update_client, - self.mainsail_data, + WebClientSetupService("mainsail").update, ) def update_mainsail_config(self, **kwargs) -> None: self._run_update_routine( "mainsail_config", - update_client_config, - self.mainsail_data, + WebClientConfigSetupService("mainsail").update, ) def update_fluidd(self, **kwargs) -> None: self._run_update_routine( "fluidd", - update_client, - self.fluidd_data, + WebClientSetupService("fluidd").update, ) def update_fluidd_config(self, **kwargs) -> None: self._run_update_routine( "fluidd_config", - update_client_config, - self.fluidd_data, + WebClientConfigSetupService("fluidd").update, ) def update_klipperscreen(self, **kwargs) -> None: