mirror of
https://github.com/dw-0/kiauh.git
synced 2026-08-03 04:47:56 +05:00
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.
This commit is contained in:
@@ -8,5 +8,16 @@
|
|||||||
# ======================================================================= #
|
# ======================================================================= #
|
||||||
|
|
||||||
from pathlib import Path
|
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
|
MODULE_PATH = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
# Shared registry of supported web clients
|
||||||
|
CLIENTS: Dict[str, Callable[[], BaseWebClient]] = {
|
||||||
|
"mainsail": MainsailData,
|
||||||
|
"fluidd": FluiddData,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
# ======================================================================= #
|
|
||||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
|
||||||
# #
|
|
||||||
# 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
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
# ======================================================================= #
|
|
||||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
|
||||||
# #
|
|
||||||
# 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!")
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# ======================================================================= #
|
|
||||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
|
||||||
# #
|
|
||||||
# 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)
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
# ======================================================================= #
|
|
||||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
|
||||||
# #
|
|
||||||
# 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)
|
|
||||||
@@ -20,6 +20,7 @@ from components.klipper.klipper import Klipper
|
|||||||
from components.webui_client import MODULE_PATH
|
from components.webui_client import MODULE_PATH
|
||||||
from components.webui_client.base_data import (
|
from components.webui_client.base_data import (
|
||||||
BaseWebClient,
|
BaseWebClient,
|
||||||
|
BaseWebClientConfig,
|
||||||
WebClientType,
|
WebClientType,
|
||||||
)
|
)
|
||||||
from components.webui_client.client_dialogs import print_client_port_select_dialog
|
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:
|
with open(config, "w") as f:
|
||||||
f.writelines(lines)
|
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!")
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import textwrap
|
|||||||
from typing import Type
|
from typing import Type
|
||||||
|
|
||||||
from components.webui_client.base_data import BaseWebClient
|
from components.webui_client.base_data import BaseWebClient
|
||||||
from components.webui_client.client_setup import install_client
|
|
||||||
from components.webui_client.client_utils import (
|
from components.webui_client.client_utils import (
|
||||||
get_client_port_selection,
|
get_client_port_selection,
|
||||||
get_nginx_listen_port,
|
get_nginx_listen_port,
|
||||||
set_listen_port,
|
set_listen_port,
|
||||||
)
|
)
|
||||||
|
from components.webui_client.services.web_client_setup_service import (
|
||||||
|
WebClientSetupService,
|
||||||
|
)
|
||||||
from core.logger import Logger
|
from core.logger import Logger
|
||||||
from core.menus import Option
|
from core.menus import Option
|
||||||
from core.menus.base_menu import BaseMenu
|
from core.menus.base_menu import BaseMenu
|
||||||
@@ -65,7 +67,9 @@ class ClientInstallMenu(BaseMenu):
|
|||||||
print(menu, end="")
|
print(menu, end="")
|
||||||
|
|
||||||
def reinstall_client(self, **kwargs) -> None:
|
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:
|
def change_listen_port(self, **kwargs) -> None:
|
||||||
curr_port = self._get_current_port()
|
curr_port = self._get_current_port()
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ from __future__ import annotations
|
|||||||
import textwrap
|
import textwrap
|
||||||
from typing import Type
|
from typing import Type
|
||||||
|
|
||||||
from components.webui_client import client_remove
|
|
||||||
from components.webui_client.base_data import BaseWebClient
|
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 import Option
|
||||||
from core.menus.base_menu import BaseMenu
|
from core.menus.base_menu import BaseMenu
|
||||||
from core.types.color import Color
|
from core.types.color import Color
|
||||||
@@ -100,13 +102,12 @@ class ClientRemoveMenu(BaseMenu):
|
|||||||
print(Color.apply("Nothing selected ...", Color.RED))
|
print(Color.apply("Nothing selected ...", Color.RED))
|
||||||
return
|
return
|
||||||
|
|
||||||
completion_msg = client_remove.run_client_removal(
|
WebClientSetupService(self.client.name).remove(
|
||||||
client=self.client,
|
|
||||||
remove_client=self.remove_client,
|
remove_client=self.remove_client,
|
||||||
remove_client_cfg=self.remove_client_cfg,
|
remove_client_cfg=self.remove_client_cfg,
|
||||||
backup_config=self.backup_config_json,
|
backup_config=self.backup_config_json,
|
||||||
|
interactive=True,
|
||||||
)
|
)
|
||||||
self.message_service.set_message(completion_msg)
|
|
||||||
|
|
||||||
self.remove_client = False
|
self.remove_client = False
|
||||||
self.remove_client_cfg = False
|
self.remove_client_cfg = False
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||||
|
# #
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||||
|
# #
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||||
|
# #
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||||
|
# #
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||||
|
# #
|
||||||
|
# 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",
|
||||||
|
)
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||||
|
# #
|
||||||
|
# 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
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -15,16 +15,17 @@ from components.crowsnest.crowsnest import install_crowsnest
|
|||||||
from components.klipper.services.klipper_setup_service import KlipperSetupService
|
from components.klipper.services.klipper_setup_service import KlipperSetupService
|
||||||
from components.klipperscreen.klipperscreen import install_klipperscreen
|
from components.klipperscreen.klipperscreen import install_klipperscreen
|
||||||
from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
|
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.fluidd_data import FluiddData
|
||||||
from components.webui_client.mainsail_data import MainsailData
|
from components.webui_client.mainsail_data import MainsailData
|
||||||
from components.webui_client.menus.client_install_menu import ClientInstallMenu
|
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 import Option
|
||||||
from core.menus.base_menu import BaseMenu
|
from core.menus.base_menu import BaseMenu
|
||||||
from core.settings.kiauh_settings import KiauhSettings
|
|
||||||
from core.types.color import Color
|
from core.types.color import Color
|
||||||
|
|
||||||
|
|
||||||
@@ -87,20 +88,20 @@ class InstallMenu(BaseMenu):
|
|||||||
if client.client_dir.exists():
|
if client.client_dir.exists():
|
||||||
ClientInstallMenu(client, self.__class__).run()
|
ClientInstallMenu(client, self.__class__).run()
|
||||||
else:
|
else:
|
||||||
install_client(client, settings=KiauhSettings())
|
WebClientSetupService("mainsail").install()
|
||||||
|
|
||||||
def install_mainsail_config(self, **kwargs) -> None:
|
def install_mainsail_config(self, **kwargs) -> None:
|
||||||
install_client_config(MainsailData())
|
WebClientConfigSetupService("mainsail").install()
|
||||||
|
|
||||||
def install_fluidd(self, **kwargs) -> None:
|
def install_fluidd(self, **kwargs) -> None:
|
||||||
client: FluiddData = FluiddData()
|
client: FluiddData = FluiddData()
|
||||||
if client.client_dir.exists():
|
if client.client_dir.exists():
|
||||||
ClientInstallMenu(client, self.__class__).run()
|
ClientInstallMenu(client, self.__class__).run()
|
||||||
else:
|
else:
|
||||||
install_client(client, settings=KiauhSettings())
|
WebClientSetupService("fluidd").install()
|
||||||
|
|
||||||
def install_fluidd_config(self, **kwargs) -> None:
|
def install_fluidd_config(self, **kwargs) -> None:
|
||||||
install_client_config(FluiddData())
|
WebClientConfigSetupService("fluidd").install()
|
||||||
|
|
||||||
def install_klipperscreen(self, **kwargs) -> None:
|
def install_klipperscreen(self, **kwargs) -> None:
|
||||||
install_klipperscreen()
|
install_klipperscreen()
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import List, Literal, Type
|
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 import Option
|
||||||
from core.menus.base_menu import BaseMenu
|
from core.menus.base_menu import BaseMenu
|
||||||
from core.settings.kiauh_settings import KiauhSettings, Repository
|
from core.settings.kiauh_settings import KiauhSettings, Repository
|
||||||
from core.types.color import Color
|
from core.types.color import Color
|
||||||
from procedures.switch_repo import run_switch_repo_routine
|
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
|
# noinspection PyUnusedLocal
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# ======================================================================= #
|
||||||
|
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||||
|
# #
|
||||||
|
# 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"]
|
||||||
@@ -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]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -22,16 +22,18 @@ from components.klipperscreen.klipperscreen import (
|
|||||||
)
|
)
|
||||||
from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
|
from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
|
||||||
from components.moonraker.utils.utils import get_moonraker_status
|
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 (
|
from components.webui_client.client_utils import (
|
||||||
get_client_config_status,
|
get_client_config_status,
|
||||||
get_client_status,
|
get_client_status,
|
||||||
)
|
)
|
||||||
from components.webui_client.fluidd_data import FluiddData
|
from components.webui_client.fluidd_data import FluiddData
|
||||||
from components.webui_client.mainsail_data import MainsailData
|
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.logger import DialogType, Logger
|
||||||
from core.menus import Option
|
from core.menus import Option
|
||||||
from core.menus.base_menu import BaseMenu
|
from core.menus.base_menu import BaseMenu
|
||||||
@@ -203,29 +205,25 @@ class UpdateMenu(BaseMenu):
|
|||||||
def update_mainsail(self, **kwargs) -> None:
|
def update_mainsail(self, **kwargs) -> None:
|
||||||
self._run_update_routine(
|
self._run_update_routine(
|
||||||
"mainsail",
|
"mainsail",
|
||||||
update_client,
|
WebClientSetupService("mainsail").update,
|
||||||
self.mainsail_data,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_mainsail_config(self, **kwargs) -> None:
|
def update_mainsail_config(self, **kwargs) -> None:
|
||||||
self._run_update_routine(
|
self._run_update_routine(
|
||||||
"mainsail_config",
|
"mainsail_config",
|
||||||
update_client_config,
|
WebClientConfigSetupService("mainsail").update,
|
||||||
self.mainsail_data,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_fluidd(self, **kwargs) -> None:
|
def update_fluidd(self, **kwargs) -> None:
|
||||||
self._run_update_routine(
|
self._run_update_routine(
|
||||||
"fluidd",
|
"fluidd",
|
||||||
update_client,
|
WebClientSetupService("fluidd").update,
|
||||||
self.fluidd_data,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_fluidd_config(self, **kwargs) -> None:
|
def update_fluidd_config(self, **kwargs) -> None:
|
||||||
self._run_update_routine(
|
self._run_update_routine(
|
||||||
"fluidd_config",
|
"fluidd_config",
|
||||||
update_client_config,
|
WebClientConfigSetupService("fluidd").update,
|
||||||
self.fluidd_data,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_klipperscreen(self, **kwargs) -> None:
|
def update_klipperscreen(self, **kwargs) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user