"""Comprehensive tests for hermes_cli.profiles module. Tests cover: validation, directory resolution, CRUD operations, active profile management, export/import, renaming, alias collision checks, profile isolation, and shell completion generation. """ import json import os import tarfile from pathlib import Path from unittest.mock import patch, MagicMock import pytest from hermes_cli.profiles import ( validate_profile_name, get_profile_dir, create_profile, delete_profile, list_profiles, set_active_profile, get_active_profile, get_active_profile_name, resolve_profile_env, check_alias_collision, rename_profile, export_profile, import_profile, generate_bash_completion, generate_zsh_completion, _get_profiles_root, _get_default_hermes_home, ) # --------------------------------------------------------------------------- # Shared fixture: redirect Path.home() and HERMES_HOME for profile tests # --------------------------------------------------------------------------- @pytest.fixture() def profile_env(tmp_path, monkeypatch): """Set up an isolated environment for profile tests. * Path.home() -> tmp_path (so _get_profiles_root() = tmp_path/.hermes/profiles) * HERMES_HOME -> tmp_path/.hermes (so get_hermes_home() agrees) * Creates the bare-minimum ~/.hermes directory. """ monkeypatch.setattr(Path, "home", lambda: tmp_path) default_home = tmp_path / ".hermes" default_home.mkdir(exist_ok=True) monkeypatch.setenv("HERMES_HOME", str(default_home)) return tmp_path # =================================================================== # TestValidateProfileName # =================================================================== class TestValidateProfileName: """Tests for validate_profile_name().""" @pytest.mark.parametrize("name", ["coder", "work-bot", "a1", "my_agent"]) def test_valid_names_accepted(self, name): # Should not raise validate_profile_name(name) @pytest.mark.parametrize("name", ["UPPER", "has space", ".hidden", "-leading"]) def test_invalid_names_rejected(self, name): with pytest.raises(ValueError): validate_profile_name(name) def test_too_long_rejected(self): long_name = "a" * 65 with pytest.raises(ValueError): validate_profile_name(long_name) def test_max_length_accepted(self): # 64 chars total: 1 leading + 63 remaining = 64, within [0,63] range name = "a" * 64 validate_profile_name(name) def test_default_accepted(self): # 'default' is a special-case pass-through validate_profile_name("default") def test_empty_string_rejected(self): with pytest.raises(ValueError): validate_profile_name("") # =================================================================== # TestGetProfileDir # =================================================================== class TestGetProfileDir: """Tests for get_profile_dir().""" def test_default_returns_hermes_home(self, profile_env): tmp_path = profile_env result = get_profile_dir("default") assert result == tmp_path / ".hermes" def test_named_profile_returns_profiles_subdir(self, profile_env): tmp_path = profile_env result = get_profile_dir("coder") assert result == tmp_path / ".hermes" / "profiles" / "coder" # =================================================================== # TestCreateProfile # =================================================================== class TestCreateProfile: """Tests for create_profile().""" def test_creates_directory_with_subdirs(self, profile_env): profile_dir = create_profile("coder", no_alias=True) assert profile_dir.is_dir() for subdir in ["memories", "sessions", "skills", "skins", "logs", "plans", "workspace", "cron"]: assert (profile_dir / subdir).is_dir(), f"Missing subdir: {subdir}" def test_duplicate_raises_file_exists(self, profile_env): create_profile("coder", no_alias=True) with pytest.raises(FileExistsError): create_profile("coder", no_alias=True) def test_default_raises_value_error(self, profile_env): with pytest.raises(ValueError, match="default"): create_profile("default", no_alias=True) def test_invalid_name_raises_value_error(self, profile_env): with pytest.raises(ValueError): create_profile("INVALID!", no_alias=True) def test_clone_config_copies_files(self, profile_env): tmp_path = profile_env default_home = tmp_path / ".hermes" # Create source config files in default profile (default_home / "config.yaml").write_text("model: test") (default_home / ".env").write_text("KEY=val") (default_home / "SOUL.md").write_text("Be helpful.") profile_dir = create_profile("coder", clone_config=True, no_alias=True) assert (profile_dir / "config.yaml").read_text() == "model: test" assert (profile_dir / ".env").read_text() == "KEY=val" assert (profile_dir / "SOUL.md").read_text() == "Be helpful." def test_clone_all_copies_entire_tree(self, profile_env): tmp_path = profile_env default_home = tmp_path / ".hermes" # Populate default with some content (default_home / "memories").mkdir(exist_ok=True) (default_home / "memories" / "note.md").write_text("remember this") (default_home / "config.yaml").write_text("model: gpt-4") # Runtime files that should be stripped (default_home / "gateway.pid").write_text("12345") (default_home / "gateway_state.json").write_text("{}") (default_home / "processes.json").write_text("[]") profile_dir = create_profile("coder", clone_all=True, no_alias=True) # Content should be copied assert (profile_dir / "memories" / "note.md").read_text() == "remember this" assert (profile_dir / "config.yaml").read_text() == "model: gpt-4" # Runtime files should be stripped assert not (profile_dir / "gateway.pid").exists() assert not (profile_dir / "gateway_state.json").exists() assert not (profile_dir / "processes.json").exists() def test_clone_config_missing_files_skipped(self, profile_env): """Clone config gracefully skips files that don't exist in source.""" profile_dir = create_profile("coder", clone_config=True, no_alias=True) # No error; optional files just not copied assert not (profile_dir / "config.yaml").exists() assert not (profile_dir / ".env").exists() assert not (profile_dir / "SOUL.md").exists() # =================================================================== # TestDeleteProfile # =================================================================== class TestDeleteProfile: """Tests for delete_profile().""" def test_removes_directory(self, profile_env): profile_dir = create_profile("coder", no_alias=True) assert profile_dir.is_dir() # Mock gateway import to avoid real systemd/launchd interaction with patch("hermes_cli.profiles._cleanup_gateway_service"): delete_profile("coder", yes=True) assert not profile_dir.is_dir() def test_default_raises_value_error(self, profile_env): with pytest.raises(ValueError, match="default"): delete_profile("default", yes=True) def test_nonexistent_raises_file_not_found(self, profile_env): with pytest.raises(FileNotFoundError): delete_profile("nonexistent", yes=True) # =================================================================== # TestListProfiles # =================================================================== class TestListProfiles: """Tests for list_profiles().""" def test_returns_default_when_no_named_profiles(self, profile_env): profiles = list_profiles() names = [p.name for p in profiles] assert "default" in names def test_includes_named_profiles(self, profile_env): create_profile("alpha", no_alias=True) create_profile("beta", no_alias=True) profiles = list_profiles() names = [p.name for p in profiles] assert "alpha" in names assert "beta" in names def test_sorted_alphabetically(self, profile_env): create_profile("zebra", no_alias=True) create_profile("alpha", no_alias=True) create_profile("middle", no_alias=True) profiles = list_profiles() named = [p.name for p in profiles if not p.is_default] assert named == sorted(named) def test_default_is_first(self, profile_env): create_profile("alpha", no_alias=True) profiles = list_profiles() assert profiles[0].name == "default" assert profiles[0].is_default is True # =================================================================== # TestActiveProfile # =================================================================== class TestActiveProfile: """Tests for set_active_profile() / get_active_profile().""" def test_set_and_get_roundtrip(self, profile_env): create_profile("coder", no_alias=True) set_active_profile("coder") assert get_active_profile() == "coder" def test_no_file_returns_default(self, profile_env): assert get_active_profile() == "default" def test_empty_file_returns_default(self, profile_env): tmp_path = profile_env active_path = tmp_path / ".hermes" / "active_profile" active_path.write_text("") assert get_active_profile() == "default" def test_set_to_default_removes_file(self, profile_env): tmp_path = profile_env create_profile("coder", no_alias=True) set_active_profile("coder") active_path = tmp_path / ".hermes" / "active_profile" assert active_path.exists() set_active_profile("default") assert not active_path.exists() def test_set_nonexistent_raises(self, profile_env): with pytest.raises(FileNotFoundError): set_active_profile("nonexistent") # =================================================================== # TestGetActiveProfileName # =================================================================== class TestGetActiveProfileName: """Tests for get_active_profile_name().""" def test_default_hermes_home_returns_default(self, profile_env): # HERMES_HOME points to tmp_path/.hermes which is the default assert get_active_profile_name() == "default" def test_profile_path_returns_profile_name(self, profile_env, monkeypatch): tmp_path = profile_env create_profile("coder", no_alias=True) profile_dir = tmp_path / ".hermes" / "profiles" / "coder" monkeypatch.setenv("HERMES_HOME", str(profile_dir)) assert get_active_profile_name() == "coder" def test_custom_path_returns_custom(self, profile_env, monkeypatch): tmp_path = profile_env custom = tmp_path / "some" / "other" / "path" custom.mkdir(parents=True) monkeypatch.setenv("HERMES_HOME", str(custom)) assert get_active_profile_name() == "custom" # =================================================================== # TestResolveProfileEnv # =================================================================== class TestResolveProfileEnv: """Tests for resolve_profile_env().""" def test_existing_profile_returns_path(self, profile_env): tmp_path = profile_env create_profile("coder", no_alias=True) result = resolve_profile_env("coder") assert result == str(tmp_path / ".hermes" / "profiles" / "coder") def test_default_returns_default_home(self, profile_env): tmp_path = profile_env result = resolve_profile_env("default") assert result == str(tmp_path / ".hermes") def test_nonexistent_raises_file_not_found(self, profile_env): with pytest.raises(FileNotFoundError): resolve_profile_env("nonexistent") def test_invalid_name_raises_value_error(self, profile_env): with pytest.raises(ValueError): resolve_profile_env("INVALID!") # =================================================================== # TestAliasCollision # =================================================================== class TestAliasCollision: """Tests for check_alias_collision().""" def test_normal_name_returns_none(self, profile_env): # Mock 'which' to return not-found with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=1, stdout="") result = check_alias_collision("mybot") assert result is None def test_reserved_name_returns_message(self, profile_env): result = check_alias_collision("hermes") assert result is not None assert "reserved" in result.lower() def test_subcommand_returns_message(self, profile_env): result = check_alias_collision("chat") assert result is not None assert "subcommand" in result.lower() def test_default_is_reserved(self, profile_env): result = check_alias_collision("default") assert result is not None assert "reserved" in result.lower() # =================================================================== # TestRenameProfile # =================================================================== class TestRenameProfile: """Tests for rename_profile().""" def test_renames_directory(self, profile_env): tmp_path = profile_env create_profile("oldname", no_alias=True) old_dir = tmp_path / ".hermes" / "profiles" / "oldname" assert old_dir.is_dir() # Mock alias collision to avoid subprocess calls with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"): new_dir = rename_profile("oldname", "newname") assert not old_dir.is_dir() assert new_dir.is_dir() assert new_dir == tmp_path / ".hermes" / "profiles" / "newname" def test_default_raises_value_error(self, profile_env): with pytest.raises(ValueError, match="default"): rename_profile("default", "newname") def test_rename_to_default_raises_value_error(self, profile_env): create_profile("coder", no_alias=True) with pytest.raises(ValueError, match="default"): rename_profile("coder", "default") def test_nonexistent_raises_file_not_found(self, profile_env): with pytest.raises(FileNotFoundError): rename_profile("nonexistent", "newname") def test_target_exists_raises_file_exists(self, profile_env): create_profile("alpha", no_alias=True) create_profile("beta", no_alias=True) with pytest.raises(FileExistsError): rename_profile("alpha", "beta") # =================================================================== # TestExportImport # =================================================================== class TestExportImport: """Tests for export_profile() / import_profile().""" def test_export_creates_tar_gz(self, profile_env, tmp_path): create_profile("coder", no_alias=True) # Put a marker file so we can verify content profile_dir = get_profile_dir("coder") (profile_dir / "marker.txt").write_text("hello") output = tmp_path / "export" / "coder.tar.gz" output.parent.mkdir(parents=True, exist_ok=True) result = export_profile("coder", str(output)) assert Path(result).exists() assert tarfile.is_tarfile(str(result)) def test_import_restores_from_archive(self, profile_env, tmp_path): # Create and export a profile create_profile("coder", no_alias=True) profile_dir = get_profile_dir("coder") (profile_dir / "marker.txt").write_text("hello") archive_path = tmp_path / "export" / "coder.tar.gz" archive_path.parent.mkdir(parents=True, exist_ok=True) export_profile("coder", str(archive_path)) # Delete the profile, then import it back under a new name import shutil shutil.rmtree(profile_dir) assert not profile_dir.is_dir() imported = import_profile(str(archive_path), name="coder") assert imported.is_dir() assert (imported / "marker.txt").read_text() == "hello" def test_import_to_existing_name_raises(self, profile_env, tmp_path): create_profile("coder", no_alias=True) profile_dir = get_profile_dir("coder") archive_path = tmp_path / "export" / "coder.tar.gz" archive_path.parent.mkdir(parents=True, exist_ok=True) export_profile("coder", str(archive_path)) # Importing to same existing name should fail with pytest.raises(FileExistsError): import_profile(str(archive_path), name="coder") def test_export_nonexistent_raises(self, profile_env, tmp_path): with pytest.raises(FileNotFoundError): export_profile("nonexistent", str(tmp_path / "out.tar.gz")) # =================================================================== # TestProfileIsolation # =================================================================== class TestProfileIsolation: """Verify that two profiles have completely separate paths.""" def test_separate_config_paths(self, profile_env): create_profile("alpha", no_alias=True) create_profile("beta", no_alias=True) alpha_dir = get_profile_dir("alpha") beta_dir = get_profile_dir("beta") assert alpha_dir / "config.yaml" != beta_dir / "config.yaml" assert str(alpha_dir) not in str(beta_dir) def test_separate_state_db_paths(self, profile_env): alpha_dir = get_profile_dir("alpha") beta_dir = get_profile_dir("beta") assert alpha_dir / "state.db" != beta_dir / "state.db" def test_separate_skills_paths(self, profile_env): create_profile("alpha", no_alias=True) create_profile("beta", no_alias=True) alpha_dir = get_profile_dir("alpha") beta_dir = get_profile_dir("beta") assert alpha_dir / "skills" != beta_dir / "skills" # Verify both exist and are independent dirs assert (alpha_dir / "skills").is_dir() assert (beta_dir / "skills").is_dir() # =================================================================== # TestCompletion # =================================================================== class TestCompletion: """Tests for bash/zsh completion generators.""" def test_bash_completion_contains_complete(self): script = generate_bash_completion() assert len(script) > 0 assert "complete" in script def test_zsh_completion_contains_compdef(self): script = generate_zsh_completion() assert len(script) > 0 assert "compdef" in script def test_bash_completion_has_hermes_profiles_function(self): script = generate_bash_completion() assert "_hermes_profiles" in script def test_zsh_completion_has_hermes_function(self): script = generate_zsh_completion() assert "_hermes" in script # =================================================================== # TestGetProfilesRoot / TestGetDefaultHermesHome (internal helpers) # =================================================================== class TestInternalHelpers: """Tests for _get_profiles_root() and _get_default_hermes_home().""" def test_profiles_root_under_home(self, profile_env): tmp_path = profile_env root = _get_profiles_root() assert root == tmp_path / ".hermes" / "profiles" def test_default_hermes_home(self, profile_env): tmp_path = profile_env home = _get_default_hermes_home() assert home == tmp_path / ".hermes" # =================================================================== # Edge cases and additional coverage # =================================================================== class TestEdgeCases: """Additional edge-case tests.""" def test_create_profile_returns_correct_path(self, profile_env): tmp_path = profile_env result = create_profile("mybot", no_alias=True) expected = tmp_path / ".hermes" / "profiles" / "mybot" assert result == expected def test_list_profiles_default_info_fields(self, profile_env): profiles = list_profiles() default = [p for p in profiles if p.name == "default"][0] assert default.is_default is True assert default.gateway_running is False assert default.skill_count == 0 def test_gateway_running_check_with_pid_file(self, profile_env): """Verify _check_gateway_running reads pid file and probes os.kill.""" from hermes_cli.profiles import _check_gateway_running tmp_path = profile_env default_home = tmp_path / ".hermes" # No pid file -> not running assert _check_gateway_running(default_home) is False # Write a PID file with a JSON payload pid_file = default_home / "gateway.pid" pid_file.write_text(json.dumps({"pid": 99999})) # os.kill(99999, 0) should raise ProcessLookupError -> not running assert _check_gateway_running(default_home) is False # Mock os.kill to simulate a running process with patch("os.kill", return_value=None): assert _check_gateway_running(default_home) is True def test_gateway_running_check_plain_pid(self, profile_env): """Pid file containing just a number (legacy format).""" from hermes_cli.profiles import _check_gateway_running tmp_path = profile_env default_home = tmp_path / ".hermes" pid_file = default_home / "gateway.pid" pid_file.write_text("99999") with patch("os.kill", return_value=None): assert _check_gateway_running(default_home) is True def test_profile_name_boundary_single_char(self): """Single alphanumeric character is valid.""" validate_profile_name("a") validate_profile_name("1") def test_profile_name_boundary_all_hyphens(self): """Name starting with hyphen is invalid.""" with pytest.raises(ValueError): validate_profile_name("-abc") def test_profile_name_underscore_start(self): """Name starting with underscore is invalid (must start with [a-z0-9]).""" with pytest.raises(ValueError): validate_profile_name("_abc") def test_clone_from_named_profile(self, profile_env): """Clone config from a named (non-default) profile.""" tmp_path = profile_env # Create source profile with config source_dir = create_profile("source", no_alias=True) (source_dir / "config.yaml").write_text("model: cloned") (source_dir / ".env").write_text("SECRET=yes") target_dir = create_profile( "target", clone_from="source", clone_config=True, no_alias=True, ) assert (target_dir / "config.yaml").read_text() == "model: cloned" assert (target_dir / ".env").read_text() == "SECRET=yes" def test_delete_clears_active_profile(self, profile_env): """Deleting the active profile resets active to default.""" tmp_path = profile_env create_profile("coder", no_alias=True) set_active_profile("coder") assert get_active_profile() == "coder" with patch("hermes_cli.profiles._cleanup_gateway_service"): delete_profile("coder", yes=True) assert get_active_profile() == "default"