From 9521415fc3023abfebb1d1380431ed9447dcdce2 Mon Sep 17 00:00:00 2001 From: test-name Date: Tue, 2 Jul 2024 17:41:42 -0500 Subject: [PATCH] Tests mostly complete; need to add "action required" tests --- .gitignore | 1 + pyproject.toml | 6 +- shiny_deploy.toml => shinylive_deploy.toml | 0 src/shinylive_deploy/app.py | 10 +- src/shinylive_deploy/data.py | 16 +- src/shinylive_deploy/models/base.py | 20 +- src/shinylive_deploy/models/local.py | 25 +- src/shinylive_deploy/models/server.py | 44 +-- Makefile => testing/.gitkeep | 0 Dockerfile => testing/Dockerfile | 0 entrypoint.sh => testing/entrypoint.sh | 1 - testing/src/app.py | 19 ++ testing/src/module/__init__.py | 2 + testing/src_test_webserver/shinyapps/.gitkeep | 0 testing/staging/.gitkeep | 0 testing/test/conftest.py | 47 ++++ testing/test/test__app.py | 122 +++++++++ testing/test/test__base.py | 122 +++++++++ testing/test/test__data.py | 23 ++ testing/test/test__local.py | 101 +++++++ testing/test/test__server.py | 254 ++++++++++++++++++ testing/tests_readme.md | 44 +++ 22 files changed, 804 insertions(+), 53 deletions(-) rename shiny_deploy.toml => shinylive_deploy.toml (100%) rename Makefile => testing/.gitkeep (100%) rename Dockerfile => testing/Dockerfile (100%) rename entrypoint.sh => testing/entrypoint.sh (93%) create mode 100644 testing/src/app.py create mode 100644 testing/src/module/__init__.py create mode 100644 testing/src_test_webserver/shinyapps/.gitkeep create mode 100644 testing/staging/.gitkeep create mode 100644 testing/test/conftest.py create mode 100644 testing/test/test__app.py create mode 100644 testing/test/test__base.py create mode 100644 testing/test/test__data.py create mode 100644 testing/test/test__local.py create mode 100644 testing/test/test__server.py create mode 100644 testing/tests_readme.md diff --git a/.gitignore b/.gitignore index cfcc712..eccb918 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ shinylive/ shinyapps/* # shinyapps/ +.DS_store .vscode/ diff --git a/pyproject.toml b/pyproject.toml index 514fb73..7ad6559 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,16 +9,20 @@ readme = "README.md" dependencies = [ 'shiny', + 'shinylive', + 'gitpython', + 'paramiko', 'pydantic < 2.0', ] [project.optional-dependencies] tests = [ 'fastapi', + 'pytest', 'loguru', 'pyfetch_mimic == 2024.06.14', 'httpx', - 'shinylive', + 'pytest', 'shinyswatch', ] diff --git a/shiny_deploy.toml b/shinylive_deploy.toml similarity index 100% rename from shiny_deploy.toml rename to shinylive_deploy.toml diff --git a/src/shinylive_deploy/app.py b/src/shinylive_deploy/app.py index ca46040..1997baa 100644 --- a/src/shinylive_deploy/app.py +++ b/src/shinylive_deploy/app.py @@ -3,13 +3,13 @@ from pathlib import Path from pydantic import SecretStr -from shinylive_deploy.data import create_config_file from shinylive_deploy.models import LocalShinyDeploy, ServerShinyDeploy, toml def main(): config_file = Path.cwd() / "shinylive_deploy.toml" if not config_file.exists(): + from shinylive_deploy.data import create_config_file create_config_file() return @@ -22,9 +22,11 @@ def main(): shinylive_.deploy() -def _parse_arguments() -> tuple[str, bool]: +def _parse_arguments(test_argvs: list = None) -> tuple[str, bool]: + if test_argvs: + sys.argv = test_argvs if len(sys.argv) < 2 or sys.argv[1] not in ("test", "beta", "prod", "local"): - raise ValueError("\nERROR: One of the following arguments is required -> [ local | test | beta | prod ]\n") + raise ValueError("\nERROR: One of the following arguments is required -> [ local | test | beta | prod ]\n") deploy_mode = sys.argv[1] try: rollback = sys.argv[2] @@ -36,7 +38,7 @@ def _parse_arguments() -> tuple[str, bool]: return deploy_mode, rollback -def _initialize_configuration(deploy_mode: str): +def _initialize_configuration(deploy_mode: str) -> LocalShinyDeploy | ServerShinyDeploy: if deploy_mode in ("test", "beta", "prod"): config = toml["deploy"]["server"] return ServerShinyDeploy( diff --git a/src/shinylive_deploy/data.py b/src/shinylive_deploy/data.py index daca3c8..dac6e18 100644 --- a/src/shinylive_deploy/data.py +++ b/src/shinylive_deploy/data.py @@ -1,13 +1,11 @@ from pathlib import Path - -def create_config_file(): - text = """ +toml_text = """ [general] app_name = "app1" [development] -directory = "src_dev/app1" +directory = "src" [deploy.gitbranch] prod = "main" @@ -18,7 +16,7 @@ def create_config_file(): [deploy.local] directory = "src_test_webserver/shinyapps/" -base_url = "localhost:8000/apps" +base_url = "http://localhost:8000/apps" [deploy.server] host = "127.0.0.1" @@ -28,6 +26,8 @@ def create_config_file(): base_url = "http://localhost:5000" """ - with open(Path.cwd() / "shinylive_deploy.toml", "w") as f: - f.write(text) - \ No newline at end of file + +def create_config_file(): + filepath = Path.cwd() / "shinylive_deploy.toml" + with open(filepath, "w") as f: + f.write(toml_text) diff --git a/src/shinylive_deploy/models/base.py b/src/shinylive_deploy/models/base.py index b4c046d..8cc04a4 100644 --- a/src/shinylive_deploy/models/base.py +++ b/src/shinylive_deploy/models/base.py @@ -6,9 +6,12 @@ import git import tomllib -repo = git.Repo() +filepath = Path.cwd() / "shinylive_deploy.toml" +if not filepath.exists(): + from shinylive_deploy.data import create_config_file + create_config_file() -with open("shiny_deploy.toml", "rb") as f: +with open("shinylive_deploy.toml", "rb") as f: toml = tomllib.load(f) deploy_local = toml["deploy"]["local"] @@ -18,10 +21,9 @@ gitbranch: dict = toml["deploy"].get("gitbranch", {}) - @dataclass class ShinyDeploy: - base_url: str + base_url: str = None app_name: str = toml["general"]["app_name"] dir_deployment: str = None dir_development: str = development.get("directory", "src") @@ -43,16 +45,18 @@ def _message(self): "\n##################################" ) - def _check_requirements(self): - # if len(sys.argv) < 2 or sys.argv[1] not in ("test", "beta", "prod", "local"): - # raise ValueError("\nERROR: One of the following arguments is required -> [ local | test | beta | prod ]\n") - # self.mode = sys.argv[1] + def _check_git_requirements(self): + repo = git.Repo() if self.mode == "prod" and str(repo.active_branch) != self.prod_branch: raise DeployException(f"Missing Requirement: `prod` deployments can only be executed from the `{self.prod_branch}` branch") elif self.mode == "beta" and str(repo.active_branch) != self.beta_branch: raise DeployException(f"Missing Requirement: `beta` deployments can only be executed from the `{self.beta_branch}` branch") def _compile(self): + staging_dir = Path.cwd() / "staging" + if not staging_dir.exists(): + staging_dir.mkdir() + with open(".gitkeep", "w") as f: f.write("") cmd = f"shinylive export {Path(self.dir_development)} {Path(self.dir_staging) / self.deploy_name}" print(f"\nExport Command: {cmd}") subprocess.run(cmd, shell=True, check=True) diff --git a/src/shinylive_deploy/models/local.py b/src/shinylive_deploy/models/local.py index 927486a..5e662f4 100644 --- a/src/shinylive_deploy/models/local.py +++ b/src/shinylive_deploy/models/local.py @@ -7,11 +7,11 @@ class LocalShinyDeploy(ShinyDeploy): def deploy(self): - self._check_requirements() + self._check_git_requirements() self._message() self._compile() - has_backup = self.__manage_backup() + has_backup = self._manage_backup() if has_backup is None: return @@ -20,18 +20,19 @@ def deploy(self): print( "\nCOMPLETE:" - f"\n- Application `{self.app_name}` compiled and deployed locally as `{self.deploy_name}`" + f"\n- `{self.app_name}` compiled and deployed locally as `{self.deploy_name}`" f"\n- Available at {self.base_url}/{self.deploy_name}" - f"\n- Backup available at {self.base_url}/{self.deploy_name}-backup" if has_backup is True else "" ) + if has_backup: + print(f"\n- Backup available at {self.base_url}/{self.deploy_name}-backup") def rollback(self): - self._check_requirements() + self._check_git_requirements() existing_deploy_dir = Path(self.dir_deployment) / self.deploy_name - if not self.__deployed_dir_exists(): + if not self._deployed_dir_exists(): print("\n>>> WARNING <<<: Backback STOPPED. No app directory exists to rollback from.\n") return - if not self.__backup_dir_exists(): + if not self._backup_dir_exists(): print("\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n") return subprocess.run(f"rm -r {existing_deploy_dir}", **subprocess_config) @@ -45,24 +46,24 @@ def rollback(self): f"\n- Available at {self.base_url}/{self.deploy_name}" ) - def __deployed_dir_exists(self): + def _deployed_dir_exists(self): result = subprocess.run(f"ls {self.dir_deployment}", **subprocess_config) directories = result.stdout.split("\n") if Path(self.deploy_name).name in directories: return True return False - def __backup_dir_exists(self): + def _backup_dir_exists(self): result = subprocess.run(f"ls {self.dir_deployment}", **subprocess_config) directories = result.stdout.split("\n") if Path(f"{self.deploy_name}-backup").name in directories: return True return False - def __manage_backup(self): + def _manage_backup(self): existing_deploy_dir = Path(self.dir_deployment) / self.deploy_name - if self.__deployed_dir_exists(): - if self.__backup_dir_exists(): + if self._deployed_dir_exists(): + if self._backup_dir_exists(): print("\n>>> WARNING <<<: Deployment STOPPED. Backup directory already exists. Delete backup directory, or rollback before redeploying.\n") return None subprocess.run(f"mv {existing_deploy_dir} {existing_deploy_dir}-backup", shell=True) diff --git a/src/shinylive_deploy/models/server.py b/src/shinylive_deploy/models/server.py index 785924c..c67cc23 100644 --- a/src/shinylive_deploy/models/server.py +++ b/src/shinylive_deploy/models/server.py @@ -1,12 +1,11 @@ import subprocess -import time from dataclasses import dataclass from pathlib import Path, PurePosixPath from paramiko import AutoAddPolicy, SFTPClient, SSHClient from pydantic import SecretStr -from .base import ShinyDeploy +from .base import ShinyDeploy, DeployException subprocess_config = {"capture_output": True, "text": True, "shell": True, "check": True} @@ -25,38 +24,39 @@ def base_ssh_cmd(self): def deploy(self, testing: bool = False): if not all([self.host, self.user, self.password]): raise ValueError("For ServerShinyDeploy, all of the following are required: host, user, password") - self._check_requirements() + self._check_git_requirements() self._message() self._compile() with SSHClient() as ssh: - ssh = self.__ssh_connection(ssh) + ssh = self._ssh_connection(ssh) sftp = ssh.open_sftp() - has_backup = self.__manage_backup(sftp) + has_backup = self._manage_backup(sftp) if has_backup is None: return - self.__push_app(sftp, testing) + self._push_app(sftp, testing) print( "\nCOMPLETE:" f"\n- `{self.app_name}` compiled and deployed to webserver as `{self.deploy_name}`" f"\n- App available at {self.base_url}/{self.deploy_name}" - f"\n- Backup available at {self.base_url}/{self.deploy_name}-backup" if has_backup is True else "" ) + if has_backup is True: + print(f"\n- Backup available at {self.base_url}/{self.deploy_name}-backup") def rollback(self): - self._check_requirements() + self._check_git_requirements() deployment_dir = PurePosixPath(self.dir_deployment) / self.deploy_name with SSHClient() as ssh: - ssh = self.__ssh_connection(ssh) + ssh = self._ssh_connection(ssh) sftp = ssh.open_sftp() - if not self.__deployed_dir_exists(sftp): + if not self._deployed_dir_exists(sftp): print("\n>>> WARNING <<<: Backback STOPPED. No app directory exists to rollback from.\n") return - if not self.__backup_dir_exists(sftp): + if not self._backup_dir_exists(sftp): print("\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n") return @@ -71,37 +71,43 @@ def rollback(self): f"\n- Available at {self.base_url}/{self.deploy_name}" ) - def __ssh_connection(self, client: SSHClient) -> SSHClient: + def _ssh_connection(self, client: SSHClient) -> SSHClient: client.set_missing_host_key_policy(AutoAddPolicy()) client.connect( - hostname=self.host, port=self.port, username=self.user, password=self.password.get_secret_value() + hostname=self.host, port=self.port, username=self.user, password=self.password.get_secret_value(), look_for_keys=False ) return client - def __deployed_dir_exists(self, sftp: SFTPClient): + def _deployed_dir_exists(self, sftp: SFTPClient): directories = sftp.listdir(str(self.dir_deployment)) if self.deploy_name in directories: return True return False - def __backup_dir_exists(self, sftp: SFTPClient): + def _backup_dir_exists(self, sftp: SFTPClient): directories = sftp.listdir(str(self.dir_deployment)) if f"{self.deploy_name}-backup" in directories: return True return False - def __manage_backup(self, sftp: SFTPClient): + def _confirm_depoy_dir_exists(self, sftp: SFTPClient): + directories = sftp.listdir() + if str(self.dir_deployment) not in directories: + raise DeployException(f"ACTION REQUIRED`{self.dir_deployment}` not found in the ssh target for user `{self.user}`. Create this directory, owned by this user, then try again.") + def _manage_backup(self, sftp: SFTPClient): + self._confirm_depoy_dir_exists(sftp) + deployment_filepath = PurePosixPath(self.dir_deployment) / self.deploy_name print(deployment_filepath) - if self.__deployed_dir_exists(sftp): - if self.__backup_dir_exists(sftp): + if self._deployed_dir_exists(sftp): + if self._backup_dir_exists(sftp): print("\n>>> WARNING <<<: Deployment STOPPED. Backup directory already exists. Delete backup directory, or rollback before redeploying.\n") return None sftp.rename(str(deployment_filepath), f"{deployment_filepath}-backup") return True return False - def __push_app(self, sftp: SFTPClient, testing: bool = False): + def _push_app(self, sftp: SFTPClient, testing: bool = False): staging_filepath = Path(self.dir_staging) / self.deploy_name sftp.mkdir(str(PurePosixPath(self.dir_deployment) / self.deploy_name)) diff --git a/Makefile b/testing/.gitkeep similarity index 100% rename from Makefile rename to testing/.gitkeep diff --git a/Dockerfile b/testing/Dockerfile similarity index 100% rename from Dockerfile rename to testing/Dockerfile diff --git a/entrypoint.sh b/testing/entrypoint.sh similarity index 93% rename from entrypoint.sh rename to testing/entrypoint.sh index dd20278..5f6b5db 100644 --- a/entrypoint.sh +++ b/testing/entrypoint.sh @@ -1,5 +1,4 @@ #!/bin/bash -cat /etc/ssh/ssh_config # Start the first process echo "Starting SSH and python webserver..." diff --git a/testing/src/app.py b/testing/src/app.py new file mode 100644 index 0000000..54c9dd1 --- /dev/null +++ b/testing/src/app.py @@ -0,0 +1,19 @@ +from module import function_to_import +from shiny import App, Inputs, Outputs, Session, ui + +app_ui = ui.page_fluid( + ui.page_sidebar( + ui.sidebar( + ui.markdown("v2024.06.29b") + ) + ), + ui.input_action_button("tests_btn", "Run tests app 1"), + title="App1" +) + + +def server(input: Inputs, output: Outputs, session: Session): + function_to_import() + + +app = App(app_ui, server) \ No newline at end of file diff --git a/testing/src/module/__init__.py b/testing/src/module/__init__.py new file mode 100644 index 0000000..2d32ddf --- /dev/null +++ b/testing/src/module/__init__.py @@ -0,0 +1,2 @@ +def function_to_import(): + print("yay") \ No newline at end of file diff --git a/testing/src_test_webserver/shinyapps/.gitkeep b/testing/src_test_webserver/shinyapps/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/testing/staging/.gitkeep b/testing/staging/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/testing/test/conftest.py b/testing/test/conftest.py new file mode 100644 index 0000000..70b91d2 --- /dev/null +++ b/testing/test/conftest.py @@ -0,0 +1,47 @@ +import shutil +import subprocess +from pathlib import Path + +from shinylive_deploy.data import create_config_file + +create_config_file() + +def pytest_sessionstart(session): + """Execute before all tests""" + subprocess_config = {"capture_output": True, "text": True, "shell": True, "check": True} + # init .git session + git_dir = Path(__file__).parent.parent / ".git" + if git_dir.exists(): + shutil.rmtree(git_dir.resolve()) + subprocess.run("git config --global init.defaultBranch main", **subprocess_config) + subprocess.run("git init .", **subprocess_config) + subprocess.run('git config --global user.name "test-name"', **subprocess_config) + subprocess.run('git config --global user.email "test@email.com"', **subprocess_config) + subprocess.run('git add . && git commit -m "committing initial files so branch can be changed"', **subprocess_config) + subprocess.run("git branch dev", **subprocess_config) + # subprocess.run("git branch main", **subprocess_config) + # subprocess.run("git checkout main", **subprocess_config) + # create config file + + + +def pytest_sessionfinish(session, exitstatus): + """Execute after all tests complete""" + # remove config file + config_filepath = Path(__file__).parent.parent / "shinylive_deploy.toml" + if config_filepath.exists(): + config_filepath.unlink() + # remove .git directory + git_dir = Path(__file__).parent.parent / ".git" + if git_dir.exists(): + shutil.rmtree(git_dir.resolve()) + # remove app1 staging directory + staging_app_dir = Path(__file__).parent.parent / "staging" / "app1" + if staging_app_dir.exists(): + shutil.rmtree(staging_app_dir.resolve()) + staging_app_dir = Path(__file__).parent.parent / "staging" / "app1-test" + if staging_app_dir.exists(): + shutil.rmtree(staging_app_dir.resolve()) + staging_app_dir = Path(__file__).parent.parent / "staging" / "app1-beta" + if staging_app_dir.exists(): + shutil.rmtree(staging_app_dir.resolve()) \ No newline at end of file diff --git a/testing/test/test__app.py b/testing/test/test__app.py new file mode 100644 index 0000000..6862524 --- /dev/null +++ b/testing/test/test__app.py @@ -0,0 +1,122 @@ +import re + +import pytest +from pydantic import SecretStr +from shinylive_deploy.app import _initialize_configuration, _parse_arguments + + +def test_parse_argements_local(capfd): + deploy_mode, rollback = _parse_arguments(["", "local"]) + assert deploy_mode == "local" + assert rollback is False + +def test_parse_argements_prod(capfd): + deploy_mode, rollback = _parse_arguments(["", "prod"]) + assert deploy_mode == "prod" + assert rollback is False + +def test_parse_argements_test(capfd): + deploy_mode, rollback = _parse_arguments(["", "test"]) + assert deploy_mode == "test" + assert rollback is False + +def test_parse_argements_beta(capfd): + deploy_mode, rollback = _parse_arguments(["", "beta"]) + assert deploy_mode == "beta" + assert rollback is False + +def test_parse_argements_rollback_and_r(capfd): + deploy_mode, rollback = _parse_arguments(["", "local", "--rollback"]) + assert deploy_mode == "local" + assert rollback is True + deploy_mode, rollback = _parse_arguments(["", "local", "-r"]) + assert deploy_mode == "local" + assert rollback is True + +def test_parse_argements_invalid_mode(capfd): + with pytest.raises(ValueError, match=re.escape("\nERROR: One of the following arguments is required -> [ local | test | beta | prod ]\n")): + _parse_arguments(["", "super"]) + +def test_parse_argements_invalid_rollback(capfd): + with pytest.raises(ValueError, match=re.escape("2nd optional argument must be `-r` or `--rollback`")): + _parse_arguments(["", "local", "rollback"]) + +def test_initalize_config_local(): + config = _initialize_configuration("local") + assert config.app_name == "app1" + assert config.deploy_name == "app1" + assert config.mode == "local" + assert config.base_url == "http://localhost:8000/apps" + assert config.dir_deployment == "src_test_webserver/shinyapps/" + assert config.dir_development == "src" + assert config.dir_staging == "staging" + assert config.prod_branch == "main" + assert config.beta_branch == "main" + with pytest.raises(AttributeError): + assert config.host + with pytest.raises(AttributeError): + assert config.user + with pytest.raises(AttributeError): + assert config.password + with pytest.raises(AttributeError): + assert config.port + +def test_initalize_config_prod(monkeypatch): + """temporarily patches the object in the test context""" + monkeypatch.setattr('shinylive_deploy.app.getpass', lambda _: "password") + + config = _initialize_configuration("prod") + assert config.app_name == "app1" + assert config.deploy_name == "app1" + assert config.mode == "prod" + assert config.base_url == "http://localhost:5000" + assert config.dir_deployment == "shinyapps" + assert config.dir_development == "src" + assert config.dir_staging == "staging" + assert config.prod_branch == "main" + assert config.beta_branch == "main" + assert config.host == "127.0.0.1" + assert config.user == "shinylive" + assert isinstance(config.password, SecretStr) + assert config.password.get_secret_value() == "password" + assert config.port == 2222 + +def test_initalize_config_test(monkeypatch): + """temporarily patches the object in the test context""" + monkeypatch.setattr('shinylive_deploy.app.getpass', lambda _: "password") + + config = _initialize_configuration("test") + assert config.app_name == "app1" + assert config.deploy_name == "app1-test" + assert config.mode == "test" + assert config.base_url == "http://localhost:5000" + assert config.dir_deployment == "shinyapps" + assert config.dir_development == "src" + assert config.dir_staging == "staging" + assert config.prod_branch == "main" + assert config.beta_branch == "main" + assert config.host == "127.0.0.1" + assert config.user == "shinylive" + assert isinstance(config.password, SecretStr) + assert config.password.get_secret_value() == "password" + assert config.port == 2222 + +def test_initalize_config_beta(monkeypatch): + """temporarily patches the object in the test context""" + monkeypatch.setattr('shinylive_deploy.app.getpass', lambda _: "password") + + config = _initialize_configuration("beta") + assert config.app_name == "app1" + assert config.deploy_name == "app1-beta" + assert config.mode == "beta" + assert config.base_url == "http://localhost:5000" + assert config.dir_deployment == "shinyapps" + assert config.dir_development == "src" + assert config.dir_staging == "staging" + assert config.prod_branch == "main" + assert config.beta_branch == "main" + assert config.host == "127.0.0.1" + assert config.user == "shinylive" + assert isinstance(config.password, SecretStr) + assert config.password.get_secret_value() == "password" + assert config.port == 2222 \ No newline at end of file diff --git a/testing/test/test__base.py b/testing/test/test__base.py new file mode 100644 index 0000000..72168f1 --- /dev/null +++ b/testing/test/test__base.py @@ -0,0 +1,122 @@ +import re +import shutil +import subprocess +from pathlib import Path + +import pytest +from shinylive_deploy.models.base import DeployException, ShinyDeploy + +subprocess_config = {"capture_output": True, "text": True, "shell": True, "check": True} + + +# SUPPORT FUNCTIONS +def generate_message(mode: str, name: str): + shinylive_ = ShinyDeploy(mode=mode) + shinylive_._message() + return f"\n##################################\nDEPLOYMENT MODE: {mode}\nDEPLOYMENT NAME: {name}\n##################################\n" + + +def reset_local_dirs(): + deploy_dir = Path(__file__).parent.parent / "src_test_webserver" / "shinyapps" + if deploy_dir.exists(): + shutil.rmtree(deploy_dir.resolve()) + deploy_dir.mkdir() + staging_dir = Path(__file__).parent.parent / "staging" + if staging_dir.exists(): + shutil.rmtree(staging_dir.resolve()) + staging_dir.mkdir() + +@pytest.fixture() +def dirs_session(): + reset_local_dirs() + yield + reset_local_dirs() + + +def test_message_local(capfd): + message = generate_message("local", "app1") + out, err = capfd.readouterr() + assert out == message + +def test_message_prod(capfd): + message = generate_message("prod", "app1") + out, err = capfd.readouterr() + assert out == message + +def test_message_test(capfd): + message = generate_message("test", "app1-test") + out, err = capfd.readouterr() + assert out == message + +def test_message_beta(capfd): + message = generate_message("beta", "app1-beta") + out, err = capfd.readouterr() + assert out == message + +def test_check_requirements_local_main(capfd): + shinylive_ = ShinyDeploy(mode="local") + subprocess.run("git checkout main", **subprocess_config) + shinylive_._check_git_requirements() + out, err = capfd.readouterr() + assert out == "" + +def test_check_requirements_local_dev(capfd): + shinylive_ = ShinyDeploy(mode="local") + subprocess.run("git checkout dev", **subprocess_config) + shinylive_._check_git_requirements() + out, err = capfd.readouterr() + assert out == "" + +def test_check_requirements_prod_dev(capfd): + shinylive_ = ShinyDeploy(mode="prod") + subprocess.run("git checkout dev", **subprocess_config) + with pytest.raises(DeployException, match=re.escape("Missing Requirement: `prod` deployments can only be executed from the `main` branch")): + shinylive_._check_git_requirements() + +def test_check_requirements_beta_dev(capfd): + shinylive_ = ShinyDeploy(mode="beta", beta_branch="beta") + subprocess.run("git checkout dev", **subprocess_config) + with pytest.raises(DeployException, match=re.escape("Missing Requirement: `beta` deployments can only be executed from the `beta` branch")): + shinylive_._check_git_requirements() + +def test_check_requirements_prod_main(capfd): + shinylive_ = ShinyDeploy(mode="prod") + subprocess.run("git checkout main", **subprocess_config) + shinylive_._check_git_requirements() + out, err = capfd.readouterr() + assert out == "" + +def test_check_requirements_beta_main(capfd): + shinylive_ = ShinyDeploy(mode="beta") + subprocess.run("git checkout main", **subprocess_config) + shinylive_._check_git_requirements() + out, err = capfd.readouterr() + assert out == "" + +def test_compile_local(capfd, dirs_session): + shinylive_ = ShinyDeploy(mode="local") + shinylive_._compile() + out, err = capfd.readouterr() + assert "Export Command: shinylive export src staging/app1" in out + assert "Writing staging/app1/app.json" in out + +def test_compile_prod(capfd, dirs_session): + shinylive_ = ShinyDeploy(mode="prod") + shinylive_._compile() + out, err = capfd.readouterr() + assert "Export Command: shinylive export src staging/app1" in out + assert "Writing staging/app1/app.json" in out + +def test_compile_test(capfd, dirs_session): + shinylive_ = ShinyDeploy(mode="test") + shinylive_._compile() + out, err = capfd.readouterr() + assert "Export Command: shinylive export src staging/app1" in out + assert "Writing staging/app1-test/app.json" in out + +def test_compile_beta(capfd, dirs_session): + shinylive_ = ShinyDeploy(mode="beta") + shinylive_._compile() + out, err = capfd.readouterr() + assert "Export Command: shinylive export src staging/app1" in out + assert "Writing staging/app1-beta/app.json" in out \ No newline at end of file diff --git a/testing/test/test__data.py b/testing/test/test__data.py new file mode 100644 index 0000000..5ea6ccf --- /dev/null +++ b/testing/test/test__data.py @@ -0,0 +1,23 @@ +from pathlib import Path + +import tomllib +from shinylive_deploy.data import create_config_file + + +# TESTS +def test_create_config_file(): + create_config_file() + with open(Path.cwd() / "shinylive_deploy.toml", 'rb') as f: + toml = tomllib.load(f) + assert toml["general"]["app_name"] == "app1" + assert toml["development"]["directory"] == "src" + assert toml["deploy"]["gitbranch"]["prod"] == "main" + assert toml["deploy"]["gitbranch"]["beta"] == "main" + assert toml["deploy"]["staging"]["directory"] == "staging" + assert toml["deploy"]["local"]["directory"] == "src_test_webserver/shinyapps/" + assert toml["deploy"]["local"]["base_url"] == "http://localhost:8000/apps" + assert toml["deploy"]["server"]["host"] == "127.0.0.1" + assert toml["deploy"]["server"]["user"] == "shinylive" + assert toml["deploy"]["server"]["port"] == 2222 + assert toml["deploy"]["server"]["directory"] == "shinyapps" + assert toml["deploy"]["server"]["base_url"] == "http://localhost:5000" \ No newline at end of file diff --git a/testing/test/test__local.py b/testing/test/test__local.py new file mode 100644 index 0000000..fbfb362 --- /dev/null +++ b/testing/test/test__local.py @@ -0,0 +1,101 @@ +import shutil +import subprocess +from pathlib import Path + +import pytest +from shinylive_deploy.app import _initialize_configuration, _parse_arguments + +deployment_stopped_msg = ">>> WARNING <<<: Deployment STOPPED. Backup directory already exists. Delete backup directory, or rollback before redeploying." + +def reset_local_dirs(recreate: bool): + deploy_dir = Path(__file__).parent.parent / "src_test_webserver" / "shinyapps" + staging_dir = Path(__file__).parent.parent / "staging" + + if deploy_dir.exists(): + shutil.rmtree(deploy_dir.resolve()) + if staging_dir.exists(): + shutil.rmtree(staging_dir.resolve()) + + deploy_dir.mkdir() + staging_dir.mkdir() + + with open(deploy_dir / ".gitkeep", "w") as f: f.write("") + with open(staging_dir / ".gitkeep", "w") as f: f.write("") + +@pytest.fixture() +def dirs_session(): + reset_local_dirs(recreate=True) + yield + reset_local_dirs(recreate=False) + +def test_deploy_local(capfd, dirs_session): + mode, rollback = _parse_arguments(["", "local"]) + shinylive_ = _initialize_configuration(mode) + shinylive_.deploy() + out, _ = capfd.readouterr() + assert rollback is False + assert "DEPLOYMENT MODE: local" in out + assert "DEPLOYMENT NAME: app1" in out + assert "Export Command: shinylive export src staging/app1" in out + assert "Writing staging/app1/app.json:" in out + assert "COMPLETE:" in out + assert "- `app1` compiled and deployed locally as `app1`" in out + assert "- Available at http://localhost:8000/apps/app1" in out + assert "- Backup available at http://localhost:8000/apps/app1-backup" not in out + # confirm app was moved out of staging dir + staging_dir = Path(__file__).parent.parent / "app1" + assert staging_dir.exists() is False + # confirm app was moved into deploy dir + deploy_dir = Path(__file__).parent.parent / "src_test_webserver" / "shinyapps" / "app1" + assert deploy_dir.exists() is True + assert Path(deploy_dir / "app.json").exists() is True + assert Path(deploy_dir / "app1-backup").exists() is False + + +def test_deploy_local_create_backup(capfd, dirs_session): + mode, rollback = _parse_arguments(["", "local"]) + shinylive_ = _initialize_configuration(mode) + shinylive_.deploy() + shinylive_.deploy() + out, _ = capfd.readouterr() + assert rollback is False + assert "DEPLOYMENT MODE: local" in out + assert "DEPLOYMENT NAME: app1" in out + assert "Export Command: shinylive export src staging/app1" in out + assert "Writing staging/app1/app.json:" in out + assert "COMPLETE:" in out + assert "- `app1` compiled and deployed locally as `app1`" in out + assert "- Available at http://localhost:8000/apps/app1" in out + assert "- Backup available at http://localhost:8000/apps/app1-backup" in out + + # confirm app was moved out of staging dir + staging_dir = Path(__file__).parent.parent / "app1" + assert staging_dir.exists() is False + # confirm block message not displayed + assert deployment_stopped_msg not in out + # confirm correct existence of directories + deploy_dir = Path(__file__).parent.parent / "src_test_webserver" / "shinyapps" + assert Path(deploy_dir / "app1").exists() is True + assert Path(deploy_dir / "app1" / "app.json").exists() is True + assert Path(deploy_dir / "app1-backup").exists() is True + assert Path(deploy_dir / "app1-backup" / "app.json").exists() is True + +def test_deploy_local_blocked(capfd, dirs_session): + mode, rollback = _parse_arguments(["", "local"]) + shinylive_ = _initialize_configuration(mode) + shinylive_.deploy() + out, _ = capfd.readouterr() + # confirm blocked message NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, _ = capfd.readouterr() + # confirm blocked message IS displayed + assert deployment_stopped_msg in out + assert rollback is False + staging_dir = Path(__file__).parent.parent / "staging" / "app1" + assert staging_dir.exists() is True + assert deployment_stopped_msg in out \ No newline at end of file diff --git a/testing/test/test__server.py b/testing/test/test__server.py new file mode 100644 index 0000000..b2d9896 --- /dev/null +++ b/testing/test/test__server.py @@ -0,0 +1,254 @@ +import shutil +import subprocess +from pathlib import Path + +import pytest +from paramiko import SFTPClient, SSHClient +from shinylive_deploy.app import ( + _initialize_configuration, + _parse_arguments, +) + +deployment_stopped_msg = ">>> WARNING <<<: Deployment STOPPED. Backup directory already exists. Delete backup directory, or rollback before redeploying." + + +def reset_ssh_dirs(ssh: SSHClient, sftp: SFTPClient): + # refresh ssh directories + directories = sftp.listdir() + if "shinyapps" in directories: + ssh.exec_command("rm -rf shinyapps") + ssh.exec_command("mkdir shinyapps") + # refresh local staging directory + staging_dir = Path(__file__).parent.parent / "staging" + if staging_dir.exists(): + shutil.rmtree(staging_dir.resolve()) + staging_dir.mkdir() + with open(staging_dir / ".gitkeep", "w") as f: f.write("") + + +@pytest.fixture() +def sftp_session(monkeypatch): + """temporarily patches the object in the test context""" + monkeypatch.setattr('shinylive_deploy.app.getpass', lambda _: "docker") + + shinylive_ = _initialize_configuration("test") + with SSHClient() as ssh: + ssh = shinylive_._ssh_connection(ssh) + sftp = ssh.open_sftp() + reset_ssh_dirs(ssh, sftp) + yield sftp + reset_ssh_dirs(ssh, sftp) + + +def test_deploy_test(capfd, sftp_session): + mode, rollback = _parse_arguments(["", "test"]) + shinylive_ = _initialize_configuration(mode) + shinylive_.deploy() + out, err = capfd.readouterr() + assert rollback is False + assert "DEPLOYMENT MODE: test" in out + assert "DEPLOYMENT NAME: app1-test" in out + assert "Export Command: shinylive export src staging/app1" in out + assert "Writing staging/app1-test/app.json:" in out + # out, err = capfd.readouterr() + assert "COMPLETE:" in out + assert "- `app1` compiled and deployed to webserver as `app1-test`" in out + assert "- App available at http://localhost:5000/app1-test" in out + assert "- Backup available at http://localhost:5000/app1-test-backup" not in out + # confirm app was moved out of staging dir + staging_dir = Path(__file__).parent.parent / "app1-test" + assert staging_dir.exists() is False + # confirm block message not displayed + assert deployment_stopped_msg not in out + # confirm correct existence of directories + directories = sftp_session.listdir("shinyapps") + assert "app1-test" in directories + assert "app1-test-backup" not in directories + +def test_deploy_test_create_backup(capfd, sftp_session): + mode, rollback = _parse_arguments(["", "test"]) + shinylive_ = _initialize_configuration(mode) + shinylive_.deploy() + shinylive_.deploy() + out, err = capfd.readouterr() + assert rollback is False + assert "DEPLOYMENT MODE: test" in out + assert "DEPLOYMENT NAME: app1-test" in out + assert "Export Command: shinylive export src staging/app1" in out + assert "Writing staging/app1-test/app.json:" in out + assert "COMPLETE:" in out + assert "- `app1` compiled and deployed to webserver as `app1-test`" in out + assert "- App available at http://localhost:5000/app1-test" in out + assert "- Backup available at http://localhost:5000/app1-test-backup" in out + + # confirm app was moved out of staging dir + staging_dir = Path(__file__).parent.parent / "app1-test" + assert staging_dir.exists() is False + # confirm block message not displayed + assert deployment_stopped_msg not in out + # confirm correct existence of directories + directories = sftp_session.listdir("shinyapps") + assert "app1-test" in directories + assert "app1-test-backup" in directories + +def test_deploy_test_blocked(capfd, sftp_session): + mode, rollback = _parse_arguments(["", "test"]) + shinylive_ = _initialize_configuration(mode) + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message still NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message IS displayed + assert deployment_stopped_msg in out + assert rollback is False + staging_dir = Path(__file__).parent.parent / "staging" / "app1-test" + assert staging_dir.exists() is True + assert deployment_stopped_msg in out + +def test_deploy_prod(capfd, sftp_session): + mode, rollback = _parse_arguments(["", "prod"]) + shinylive_ = _initialize_configuration(mode) + shinylive_.deploy() + out, err = capfd.readouterr() + assert rollback is False + assert "DEPLOYMENT MODE: prod" in out + assert "DEPLOYMENT NAME: app1" in out + assert "Export Command: shinylive export src staging/app1" in out + assert "Writing staging/app1/app.json:" in out + # out, err = capfd.readouterr() + assert "COMPLETE:" in out + assert "- `app1` compiled and deployed to webserver as `app1`" in out + assert "- App available at http://localhost:5000/app1" in out + assert "- Backup available at http://localhost:5000/app1-backup" not in out + # confirm app was moved out of staging dir + staging_dir = Path(__file__).parent.parent / "app1" + assert staging_dir.exists() is False + # confirm block message not displayed + assert deployment_stopped_msg not in out + # confirm correct existence of directories + directories = sftp_session.listdir("shinyapps") + assert "app1" in directories + assert "app1-backup" not in directories + +def test_deploy_prod_create_backup(capfd, sftp_session): + mode, rollback = _parse_arguments(["", "prod"]) + shinylive_ = _initialize_configuration(mode) + shinylive_.deploy() + shinylive_.deploy() + out, err = capfd.readouterr() + assert rollback is False + assert "DEPLOYMENT MODE: prod" in out + assert "DEPLOYMENT NAME: app1" in out + assert "Export Command: shinylive export src staging/app1" in out + assert "Writing staging/app1/app.json:" in out + assert "COMPLETE:" in out + assert "- `app1` compiled and deployed to webserver as `app1`" in out + assert "- App available at http://localhost:5000/app1" in out + assert "- Backup available at http://localhost:5000/app1-backup" in out + + # confirm app was moved out of staging dir + staging_dir = Path(__file__).parent.parent / "app1" + assert staging_dir.exists() is False + # confirm block message not displayed + assert deployment_stopped_msg not in out + # confirm correct existence of directories + directories = sftp_session.listdir("shinyapps") + assert "app1" in directories + assert "app1-backup" in directories + +def test_deploy_prod_blocked(capfd, sftp_session): + mode, rollback = _parse_arguments(["", "prod"]) + shinylive_ = _initialize_configuration(mode) + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message still NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message IS displayed + assert deployment_stopped_msg in out + assert rollback is False + staging_dir = Path(__file__).parent.parent / "staging" / "app1" + assert staging_dir.exists() is True + assert deployment_stopped_msg in out + +def test_deploy_beta(capfd, sftp_session): + mode, rollback = _parse_arguments(["", "beta"]) + shinylive_ = _initialize_configuration(mode) + shinylive_.deploy() + out, err = capfd.readouterr() + assert rollback is False + assert "DEPLOYMENT MODE: beta" in out + assert "DEPLOYMENT NAME: app1-beta" in out + assert "Export Command: shinylive export src staging/app1-beta" in out + assert "Writing staging/app1-beta/app.json:" in out + # out, err = capfd.readouterr() + assert "COMPLETE:" in out + assert "- `app1` compiled and deployed to webserver as `app1-beta`" in out + assert "- App available at http://localhost:5000/app1-beta" in out + assert "- Backup available at http://localhost:5000/app1-beta-backup" not in out + # confirm app was moved out of staging dir + staging_dir = Path(__file__).parent.parent / "app1-beta" + assert staging_dir.exists() is False + # confirm block message not displayed + assert deployment_stopped_msg not in out + # confirm correct existence of directories + directories = sftp_session.listdir("shinyapps") + assert "app1-beta" in directories + assert "app1-beta-backup" not in directories + +def test_deploy_beta_create_backup(capfd, sftp_session): + mode, rollback = _parse_arguments(["", "beta"]) + shinylive_ = _initialize_configuration(mode) + shinylive_.deploy() + shinylive_.deploy() + out, err = capfd.readouterr() + assert rollback is False + assert "DEPLOYMENT MODE: beta" in out + assert "DEPLOYMENT NAME: app1" in out + assert "Export Command: shinylive export src staging/app1-beta" in out + assert "Writing staging/app1-beta/app.json:" in out + assert "COMPLETE:" in out + assert "- `app1` compiled and deployed to webserver as `app1-beta`" in out + assert "- App available at http://localhost:5000/app1-beta" in out + assert "- Backup available at http://localhost:5000/app1-beta-backup" in out + + # confirm app was moved out of staging dir + staging_dir = Path(__file__).parent.parent / "app1" + assert staging_dir.exists() is False + # confirm block message not displayed + assert deployment_stopped_msg not in out + # confirm correct existence of directories + directories = sftp_session.listdir("shinyapps") + assert "app1-beta" in directories + assert "app1-beta-backup" in directories + +def test_deploy_beta_blocked(capfd, sftp_session): + mode, rollback = _parse_arguments(["", "beta"]) + shinylive_ = _initialize_configuration(mode) + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message still NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message IS displayed + assert deployment_stopped_msg in out + assert rollback is False + staging_dir = Path(__file__).parent.parent / "staging" / "app1-beta" + assert staging_dir.exists() is True + assert deployment_stopped_msg in out \ No newline at end of file diff --git a/testing/tests_readme.md b/testing/tests_readme.md new file mode 100644 index 0000000..05b7d10 --- /dev/null +++ b/testing/tests_readme.md @@ -0,0 +1,44 @@ +## Setup python virtual environment and activate + +```shell +- ***MUST*** being run from inside root project directory +```shell +uv venv +uv pip install -e '.[tests]' +# Use OS specific command below +.venv\scripts\activate # Windows +source .venv/bin/activate # Linux/MacOS + +## Build and start docker image for ssh testing +- ***MUST*** being run from inside `testing` directory +```shell +docker build . -t shinylive-test +docker run --rm -p 2222:22 -p 5000:5000 shinylive-test +``` +- A new terminal/powershell window will need to be opened for the steps below + +## Run commands to accept and cache ssh key +1. Manually run shinylive_deploy within testing directory +- ***MUST*** being run from inside `testing` directory +```shell +git init . # <- this creates a .git session with `testing`; gets removed when unitests run +shinylive_deploy test +``` +- Input password when prompted: docker +- Accept key if when prompted + - If continued issues with the key are encountered: + - `ssh shinylive@127.0.0.1 -p 2222` | password: `docker` + - Accept key when prompted + - Try `shinylive_deploy test` again + +## FINALLY, run unit tests +```shell +pytest test -vv -x +``` +- If you encounter the `ACTION REQUIRED shinyapps not found in ssh targat`, simply try running the command again. There is code to create this directory, but the a location, only in tests (not the actual app), where it can see that before it checks for its existence. Hopefully will return to this at some point. + +## To kill docker container +- This is a quick, bareboes container built just for ssh testing, it has to be killed in a different terminal session to stop it +- Get container ID: `docker ps` +- Stop using ID: `docker stop ` +- After a short period of time it should stop \ No newline at end of file