"""Tests for config module."""
import json
import pytest
from pathlib import Path
from maniple_mcp.config import (
CONFIG_VERSION,
ClaudeTeamConfig,
CommandsConfig,
ConfigError,
DefaultsConfig,
EventsConfig,
IssueTrackerConfig,
TerminalConfig,
default_config,
load_config,
save_config,
)
class TestDefaultConfig:
"""Tests for default_config function."""
def test_returns_claude_team_config(self):
"""default_config returns a ClaudeTeamConfig instance."""
config = default_config()
assert isinstance(config, ClaudeTeamConfig)
def test_default_version(self):
"""Default config has current version."""
config = default_config()
assert config.version == CONFIG_VERSION
def test_default_commands(self):
"""Default commands are None."""
config = default_config()
assert config.commands.claude is None
assert config.commands.codex is None
def test_default_defaults(self):
"""Default spawn_workers defaults."""
config = default_config()
assert config.defaults.agent_type == "claude"
assert config.defaults.skip_permissions is False
assert config.defaults.use_worktree is True
assert config.defaults.layout == "auto"
def test_default_terminal(self):
"""Default terminal backend is None (auto-detect)."""
config = default_config()
assert config.terminal.backend is None
def test_default_events(self):
"""Default events config values."""
config = default_config()
assert config.events.max_size_mb == 1
assert config.events.recent_hours == 24
assert config.events.stale_threshold_minutes == 10
def test_default_issue_tracker(self):
"""Default issue tracker override is None."""
config = default_config()
assert config.issue_tracker.override is None
class TestSaveConfig:
"""Tests for save_config function."""
def test_creates_parent_directory(self, tmp_path: Path):
"""save_config creates parent directories if needed."""
nested_path = tmp_path / "a" / "b" / "config.json"
config = default_config()
save_config(config, nested_path)
assert nested_path.exists()
def test_writes_valid_json(self, tmp_path: Path):
"""Saved config is valid JSON."""
config_path = tmp_path / "config.json"
config = default_config()
save_config(config, config_path)
data = json.loads(config_path.read_text())
assert isinstance(data, dict)
def test_returns_path_written(self, tmp_path: Path):
"""save_config returns the path that was written."""
config_path = tmp_path / "config.json"
config = default_config()
result = save_config(config, config_path)
assert result == config_path
def test_saves_all_fields(self, tmp_path: Path):
"""All config fields are persisted."""
config_path = tmp_path / "config.json"
config = ClaudeTeamConfig(
version=1,
commands=CommandsConfig(claude="/custom/claude", codex="/custom/codex"),
defaults=DefaultsConfig(
agent_type="codex",
skip_permissions=True,
use_worktree=False,
layout="new",
),
terminal=TerminalConfig(backend="tmux"),
events=EventsConfig(max_size_mb=5, recent_hours=48, stale_threshold_minutes=15),
issue_tracker=IssueTrackerConfig(override="beads"),
)
save_config(config, config_path)
data = json.loads(config_path.read_text())
assert data["version"] == 1
assert data["commands"]["claude"] == "/custom/claude"
assert data["commands"]["codex"] == "/custom/codex"
assert data["defaults"]["agent_type"] == "codex"
assert data["defaults"]["skip_permissions"] is True
assert data["defaults"]["use_worktree"] is False
assert data["defaults"]["layout"] == "new"
assert data["terminal"]["backend"] == "tmux"
assert data["events"]["max_size_mb"] == 5
assert data["events"]["recent_hours"] == 48
assert data["events"]["stale_threshold_minutes"] == 15
assert data["issue_tracker"]["override"] == "beads"
def test_json_is_formatted(self, tmp_path: Path):
"""Saved JSON is indented for readability."""
config_path = tmp_path / "config.json"
config = default_config()
save_config(config, config_path)
content = config_path.read_text()
# Indented JSON has newlines and spaces
assert "\n" in content
assert " " in content
def test_file_ends_with_newline(self, tmp_path: Path):
"""Saved file ends with newline."""
config_path = tmp_path / "config.json"
config = default_config()
save_config(config, config_path)
content = config_path.read_text()
assert content.endswith("\n")
class TestLoadConfig:
"""Tests for load_config function."""
def test_creates_default_when_missing(self, tmp_path: Path):
"""load_config returns defaults without writing when file doesn't exist."""
config_path = tmp_path / "config.json"
assert not config_path.exists()
config = load_config(config_path)
assert not config_path.exists()
assert isinstance(config, ClaudeTeamConfig)
def test_loads_existing_config(self, tmp_path: Path):
"""load_config reads existing config file."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"commands": {"claude": "/my/claude"},
"defaults": {"agent_type": "codex"},
"terminal": {"backend": "iterm"},
"events": {"max_size_mb": 10},
"issue_tracker": {"override": "pebbles"},
}))
config = load_config(config_path)
assert config.commands.claude == "/my/claude"
assert config.defaults.agent_type == "codex"
assert config.terminal.backend == "iterm"
assert config.events.max_size_mb == 10
assert config.issue_tracker.override == "pebbles"
def test_partial_config_uses_defaults(self, tmp_path: Path):
"""Missing sections use default values."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"version": 1}))
config = load_config(config_path)
# All other fields should have defaults
assert config.commands.claude is None
assert config.defaults.agent_type == "claude"
assert config.terminal.backend is None
assert config.events.max_size_mb == 1
assert config.issue_tracker.override is None
def test_empty_sections_use_defaults(self, tmp_path: Path):
"""Empty section objects use field defaults."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"commands": {},
"defaults": {},
"terminal": {},
"events": {},
"issue_tracker": {},
}))
config = load_config(config_path)
assert config.defaults.agent_type == "claude"
assert config.defaults.skip_permissions is False
def test_roundtrip_preserves_values(self, tmp_path: Path):
"""Saving and loading preserves all config values."""
config_path = tmp_path / "config.json"
original = ClaudeTeamConfig(
version=1,
commands=CommandsConfig(claude="/bin/claude", codex="/bin/codex"),
defaults=DefaultsConfig(
agent_type="codex",
skip_permissions=True,
use_worktree=False,
layout="new",
),
terminal=TerminalConfig(backend="tmux"),
events=EventsConfig(max_size_mb=2, recent_hours=12, stale_threshold_minutes=30),
issue_tracker=IssueTrackerConfig(override="beads"),
)
save_config(original, config_path)
loaded = load_config(config_path)
assert loaded.version == original.version
assert loaded.commands.claude == original.commands.claude
assert loaded.commands.codex == original.commands.codex
assert loaded.defaults.agent_type == original.defaults.agent_type
assert loaded.defaults.skip_permissions == original.defaults.skip_permissions
assert loaded.defaults.use_worktree == original.defaults.use_worktree
assert loaded.defaults.layout == original.defaults.layout
assert loaded.terminal.backend == original.terminal.backend
assert loaded.events.max_size_mb == original.events.max_size_mb
assert loaded.events.recent_hours == original.events.recent_hours
assert loaded.events.stale_threshold_minutes == original.events.stale_threshold_minutes
assert loaded.issue_tracker.override == original.issue_tracker.override
class TestJsonValidationErrors:
"""Tests for JSON validation error handling."""
def test_invalid_json_raises_config_error(self, tmp_path: Path):
"""Invalid JSON content raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text("not valid json {")
with pytest.raises(ConfigError, match="Invalid JSON"):
load_config(config_path)
def test_non_object_json_raises_config_error(self, tmp_path: Path):
"""Non-object JSON raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text('"just a string"')
with pytest.raises(ConfigError, match="must contain a JSON object"):
load_config(config_path)
def test_array_json_raises_config_error(self, tmp_path: Path):
"""JSON array raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text("[1, 2, 3]")
with pytest.raises(ConfigError, match="must contain a JSON object"):
load_config(config_path)
def test_unknown_top_level_key_raises_error(self, tmp_path: Path):
"""Unknown keys in config raise ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"unknown_key": "value",
}))
with pytest.raises(ConfigError, match="Unknown keys in config"):
load_config(config_path)
def test_unknown_commands_key_raises_error(self, tmp_path: Path):
"""Unknown keys in commands section raise ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"commands": {"invalid": "value"},
}))
with pytest.raises(ConfigError, match="Unknown keys in commands"):
load_config(config_path)
def test_unknown_defaults_key_raises_error(self, tmp_path: Path):
"""Unknown keys in defaults section raise ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"defaults": {"bad_key": True},
}))
with pytest.raises(ConfigError, match="Unknown keys in defaults"):
load_config(config_path)
def test_unknown_terminal_key_raises_error(self, tmp_path: Path):
"""Unknown keys in terminal section raise ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"terminal": {"unknown": "value"},
}))
with pytest.raises(ConfigError, match="Unknown keys in terminal"):
load_config(config_path)
def test_unknown_events_key_raises_error(self, tmp_path: Path):
"""Unknown keys in events section raise ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"events": {"bad": 123},
}))
with pytest.raises(ConfigError, match="Unknown keys in events"):
load_config(config_path)
def test_unknown_issue_tracker_key_raises_error(self, tmp_path: Path):
"""Unknown keys in issue_tracker section raise ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"issue_tracker": {"invalid": "pebbles"},
}))
with pytest.raises(ConfigError, match="Unknown keys in issue_tracker"):
load_config(config_path)
def test_section_not_object_raises_error(self, tmp_path: Path):
"""Section that is not an object raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"commands": "not an object",
}))
with pytest.raises(ConfigError, match="commands must be a JSON object"):
load_config(config_path)
def test_section_array_raises_error(self, tmp_path: Path):
"""Section that is an array raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"defaults": [1, 2, 3],
}))
with pytest.raises(ConfigError, match="defaults must be a JSON object"):
load_config(config_path)
class TestVersionValidation:
"""Tests for config version validation."""
def test_missing_version_uses_current(self, tmp_path: Path):
"""Missing version field defaults to current version."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({}))
config = load_config(config_path)
assert config.version == CONFIG_VERSION
def test_current_version_accepted(self, tmp_path: Path):
"""Current version is accepted."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"version": CONFIG_VERSION}))
config = load_config(config_path)
assert config.version == CONFIG_VERSION
def test_wrong_version_raises_error(self, tmp_path: Path):
"""Unsupported version raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"version": 999}))
with pytest.raises(ConfigError, match="Unsupported config version 999"):
load_config(config_path)
def test_version_not_integer_raises_error(self, tmp_path: Path):
"""Non-integer version raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"version": "1"}))
with pytest.raises(ConfigError, match="version must be an integer"):
load_config(config_path)
def test_future_version_raises_error(self, tmp_path: Path):
"""Future version raises ConfigError (for migration testing)."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"version": CONFIG_VERSION + 1}))
with pytest.raises(ConfigError, match="Unsupported config version"):
load_config(config_path)
class TestFieldTypeValidation:
"""Tests for field type validation."""
def test_commands_claude_must_be_string(self, tmp_path: Path):
"""commands.claude must be a string."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"commands": {"claude": 123},
}))
with pytest.raises(ConfigError, match="commands.claude must be a string"):
load_config(config_path)
def test_commands_codex_must_be_string(self, tmp_path: Path):
"""commands.codex must be a string."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"commands": {"codex": True},
}))
with pytest.raises(ConfigError, match="commands.codex must be a string"):
load_config(config_path)
def test_commands_empty_string_raises_error(self, tmp_path: Path):
"""Empty command string raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"commands": {"claude": ""},
}))
with pytest.raises(ConfigError, match="commands.claude cannot be empty"):
load_config(config_path)
def test_commands_whitespace_string_raises_error(self, tmp_path: Path):
"""Whitespace-only command string raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"commands": {"claude": " "},
}))
with pytest.raises(ConfigError, match="commands.claude cannot be empty"):
load_config(config_path)
def test_defaults_agent_type_invalid_value(self, tmp_path: Path):
"""Invalid agent_type raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"defaults": {"agent_type": "invalid"},
}))
with pytest.raises(ConfigError, match="defaults.agent_type must be one of"):
load_config(config_path)
def test_defaults_agent_type_not_string(self, tmp_path: Path):
"""Non-string agent_type raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"defaults": {"agent_type": 1},
}))
with pytest.raises(ConfigError, match="defaults.agent_type must be a string"):
load_config(config_path)
def test_defaults_skip_permissions_not_bool(self, tmp_path: Path):
"""Non-boolean skip_permissions raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"defaults": {"skip_permissions": "yes"},
}))
with pytest.raises(ConfigError, match="defaults.skip_permissions must be a boolean"):
load_config(config_path)
def test_defaults_use_worktree_not_bool(self, tmp_path: Path):
"""Non-boolean use_worktree raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"defaults": {"use_worktree": 1},
}))
with pytest.raises(ConfigError, match="defaults.use_worktree must be a boolean"):
load_config(config_path)
def test_defaults_layout_invalid_value(self, tmp_path: Path):
"""Invalid layout raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"defaults": {"layout": "grid"},
}))
with pytest.raises(ConfigError, match="defaults.layout must be one of"):
load_config(config_path)
def test_terminal_backend_invalid_value(self, tmp_path: Path):
"""Invalid terminal backend raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"terminal": {"backend": "kitty"},
}))
with pytest.raises(ConfigError, match="terminal.backend must be one of"):
load_config(config_path)
def test_events_max_size_mb_not_int(self, tmp_path: Path):
"""Non-integer max_size_mb raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"events": {"max_size_mb": "5"},
}))
with pytest.raises(ConfigError, match="events.max_size_mb must be an integer"):
load_config(config_path)
def test_events_max_size_mb_zero_raises_error(self, tmp_path: Path):
"""Zero max_size_mb raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"events": {"max_size_mb": 0},
}))
with pytest.raises(ConfigError, match="events.max_size_mb must be at least 1"):
load_config(config_path)
def test_events_max_size_mb_negative_raises_error(self, tmp_path: Path):
"""Negative max_size_mb raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"events": {"max_size_mb": -1},
}))
with pytest.raises(ConfigError, match="events.max_size_mb must be at least 1"):
load_config(config_path)
def test_events_recent_hours_not_int(self, tmp_path: Path):
"""Non-integer recent_hours raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"events": {"recent_hours": 24.5},
}))
with pytest.raises(ConfigError, match="events.recent_hours must be an integer"):
load_config(config_path)
def test_events_recent_hours_zero_allowed(self, tmp_path: Path):
"""Zero recent_hours is allowed."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"events": {"recent_hours": 0},
}))
config = load_config(config_path)
assert config.events.recent_hours == 0
def test_events_recent_hours_negative_raises_error(self, tmp_path: Path):
"""Negative recent_hours raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"events": {"recent_hours": -1},
}))
with pytest.raises(ConfigError, match="events.recent_hours must be at least 0"):
load_config(config_path)
def test_events_bool_not_accepted_as_int(self, tmp_path: Path):
"""Boolean is not accepted for integer field."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"events": {"max_size_mb": True},
}))
with pytest.raises(ConfigError, match="events.max_size_mb must be an integer"):
load_config(config_path)
def test_stale_threshold_minutes_valid(self, tmp_path: Path):
"""Valid stale_threshold_minutes is accepted."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"events": {"stale_threshold_minutes": 30},
}))
config = load_config(config_path)
assert config.events.stale_threshold_minutes == 30
def test_stale_threshold_minutes_not_int(self, tmp_path: Path):
"""Non-integer stale_threshold_minutes raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"events": {"stale_threshold_minutes": "ten"},
}))
with pytest.raises(ConfigError, match="events.stale_threshold_minutes must be an integer"):
load_config(config_path)
def test_stale_threshold_minutes_zero_raises_error(self, tmp_path: Path):
"""Zero stale_threshold_minutes raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"events": {"stale_threshold_minutes": 0},
}))
with pytest.raises(ConfigError, match="events.stale_threshold_minutes must be at least 1"):
load_config(config_path)
def test_stale_threshold_minutes_negative_raises_error(self, tmp_path: Path):
"""Negative stale_threshold_minutes raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"events": {"stale_threshold_minutes": -5},
}))
with pytest.raises(ConfigError, match="events.stale_threshold_minutes must be at least 1"):
load_config(config_path)
def test_issue_tracker_override_invalid_value(self, tmp_path: Path):
"""Invalid issue_tracker.override raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"issue_tracker": {"override": "jira"},
}))
with pytest.raises(ConfigError, match="issue_tracker.override must be one of"):
load_config(config_path)
class TestValidLiteralValues:
"""Tests for valid literal string values."""
def test_agent_type_claude(self, tmp_path: Path):
"""agent_type 'claude' is valid."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"defaults": {"agent_type": "claude"},
}))
config = load_config(config_path)
assert config.defaults.agent_type == "claude"
def test_agent_type_codex(self, tmp_path: Path):
"""agent_type 'codex' is valid."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"defaults": {"agent_type": "codex"},
}))
config = load_config(config_path)
assert config.defaults.agent_type == "codex"
def test_layout_auto(self, tmp_path: Path):
"""layout 'auto' is valid."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"defaults": {"layout": "auto"},
}))
config = load_config(config_path)
assert config.defaults.layout == "auto"
def test_layout_new(self, tmp_path: Path):
"""layout 'new' is valid."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"defaults": {"layout": "new"},
}))
config = load_config(config_path)
assert config.defaults.layout == "new"
def test_terminal_backend_iterm(self, tmp_path: Path):
"""terminal.backend 'iterm' is valid."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"terminal": {"backend": "iterm"},
}))
config = load_config(config_path)
assert config.terminal.backend == "iterm"
def test_terminal_backend_tmux(self, tmp_path: Path):
"""terminal.backend 'tmux' is valid."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"terminal": {"backend": "tmux"},
}))
config = load_config(config_path)
assert config.terminal.backend == "tmux"
def test_issue_tracker_override_beads(self, tmp_path: Path):
"""issue_tracker.override 'beads' is valid."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"issue_tracker": {"override": "beads"},
}))
config = load_config(config_path)
assert config.issue_tracker.override == "beads"
def test_issue_tracker_override_pebbles(self, tmp_path: Path):
"""issue_tracker.override 'pebbles' is valid."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"version": 1,
"issue_tracker": {"override": "pebbles"},
}))
config = load_config(config_path)
assert config.issue_tracker.override == "pebbles"
class TestIOErrors:
"""Tests for IO error handling."""
def test_unreadable_file_raises_config_error(self, tmp_path: Path):
"""Unreadable config file raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.write_text("{}")
config_path.chmod(0o000)
try:
with pytest.raises(ConfigError, match="Unable to read config file"):
load_config(config_path)
finally:
# Restore permissions for cleanup
config_path.chmod(0o644)
def test_directory_instead_of_file_raises_error(self, tmp_path: Path):
"""Directory path instead of file raises ConfigError."""
config_path = tmp_path / "config.json"
config_path.mkdir()
with pytest.raises(ConfigError, match="Unable to read config file"):
load_config(config_path)
class TestDataclasses:
"""Tests for dataclass behavior."""
def test_commands_config_defaults(self):
"""CommandsConfig has correct defaults."""
config = CommandsConfig()
assert config.claude is None
assert config.codex is None
def test_defaults_config_defaults(self):
"""DefaultsConfig has correct defaults."""
config = DefaultsConfig()
assert config.agent_type == "claude"
assert config.skip_permissions is False
assert config.use_worktree is True
assert config.layout == "auto"
def test_terminal_config_defaults(self):
"""TerminalConfig has correct defaults."""
config = TerminalConfig()
assert config.backend is None
def test_events_config_defaults(self):
"""EventsConfig has correct defaults."""
config = EventsConfig()
assert config.max_size_mb == 1
assert config.recent_hours == 24
assert config.stale_threshold_minutes == 10
def test_issue_tracker_config_defaults(self):
"""IssueTrackerConfig has correct defaults."""
config = IssueTrackerConfig()
assert config.override is None
def test_claude_team_config_defaults(self):
"""ClaudeTeamConfig has correct nested defaults."""
config = ClaudeTeamConfig()
assert config.version == CONFIG_VERSION
assert isinstance(config.commands, CommandsConfig)
assert isinstance(config.defaults, DefaultsConfig)
assert isinstance(config.terminal, TerminalConfig)
assert isinstance(config.events, EventsConfig)
assert isinstance(config.issue_tracker, IssueTrackerConfig)