mcquack/test_mcquack.py
Erich Blume c7a0b04573 Add uv package structure and pytest test framework
Set up mcquack as a proper uv package with flat layout (no src/ directory).
Uses hatchling build backend to support single-file module structure.
Added pytest as dev dependency with fixtures for testing against
temporary directories instead of actual macOS LaunchAgent paths.

- Rename list() to list_agents() to avoid shadowing builtin (Python 3.14 compat)
- Add mock_dirs fixture that monkeypatches LAUNCH_AGENTS_DIR and LOGS_DIR
- Add mock_script and mock_launchctl fixtures
- 26 tests covering list, create, edit, show, delete commands
- Parameterized tests for argument handling
- XML validation tests independent of plistlib

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:18:17 -08:00

279 lines
10 KiB
Python

"""Tests for mcquack."""
import plistlib
import xml.etree.ElementTree as ET
from pathlib import Path
import pytest
from typer.testing import CliRunner
import mcquack
from mcquack import app
runner = CliRunner()
def get_plist_path(mock_dirs, script_name="test_script"):
"""Helper to get the plist path for a script."""
return mock_dirs["launch_agents_dir"] / f"{mcquack.PLIST_PREFIX}.{script_name}.plist"
def read_plist_args(plist_path):
"""Read ProgramArguments from a plist file."""
with open(plist_path, "rb") as f:
plist = plistlib.load(f)
return plist.get("ProgramArguments", [])
class TestList:
def test_list_empty(self, mock_dirs):
"""Test list command with no agents."""
result = runner.invoke(app, ["list"])
assert result.exit_code == 0
assert "No mcquack-managed LaunchAgents found" in result.stdout
def test_list_shows_agents(self, mock_dirs, mock_script):
"""Test list command shows existing agents."""
# Create a plist file manually
plist_path = get_plist_path(mock_dirs)
plist_data = {
"Label": f"{mcquack.PLIST_PREFIX}.test_script",
"ProgramArguments": [str(mock_script)],
}
with open(plist_path, "wb") as f:
plistlib.dump(plist_data, f)
result = runner.invoke(app, ["list"])
assert result.exit_code == 0
assert "test_script" in result.stdout
class TestCreate:
@pytest.mark.parametrize("script_args,expected_args", [
([], []),
(["--verbose"], ["--verbose"]),
(["--config", "/path/to/config.json"], ["--config", "/path/to/config.json"]),
(["arg1", "arg2", "arg3"], ["arg1", "arg2", "arg3"]),
(["--flag", "value with spaces"], ["--flag", "value with spaces"]),
])
def test_create_with_arguments(self, mock_dirs, mock_script, mock_launchctl, script_args, expected_args):
"""Test creating agents with various argument configurations."""
cmd = ["create", str(mock_script)]
if script_args:
cmd += ["--"] + script_args
result = runner.invoke(app, cmd)
assert result.exit_code == 0
assert "Created:" in result.stdout
assert "Loaded:" in result.stdout
# Verify plist was created with correct arguments
plist_path = get_plist_path(mock_dirs)
assert plist_path.exists()
program_args = read_plist_args(plist_path)
assert program_args[0] == str(mock_script.resolve())
assert program_args[1:] == expected_args
def test_create_nonexistent_script(self, mock_dirs):
"""Test creating agent for nonexistent script."""
result = runner.invoke(app, ["create", "/nonexistent/script.sh"])
assert result.exit_code == 1
assert "does not exist" in result.output
def test_create_non_executable(self, mock_dirs, tmp_path):
"""Test creating agent for non-executable script."""
script = tmp_path / "not_executable.sh"
script.write_text("#!/bin/bash\necho 'hello'\n")
# Don't make it executable
result = runner.invoke(app, ["create", str(script)])
assert result.exit_code == 1
assert "not executable" in result.output
def test_create_already_exists(self, mock_dirs, mock_script, mock_launchctl):
"""Test creating agent that already exists."""
# Create first
runner.invoke(app, ["create", str(mock_script)])
# Try to create again
result = runner.invoke(app, ["create", str(mock_script)])
assert result.exit_code == 1
assert "already exists" in result.output
class TestEdit:
def test_edit_add_arguments(self, mock_dirs, mock_script, mock_launchctl):
"""Test adding arguments to an existing agent."""
# Create without arguments
runner.invoke(app, ["create", str(mock_script)])
# Edit to add arguments
result = runner.invoke(app, ["edit", str(mock_script), "--", "--verbose", "--config", "test.json"])
assert result.exit_code == 0
assert "Updated:" in result.stdout
assert "Reloaded:" in result.stdout
# Verify arguments were added
program_args = read_plist_args(get_plist_path(mock_dirs))
assert program_args[1:] == ["--verbose", "--config", "test.json"]
def test_edit_remove_arguments(self, mock_dirs, mock_script, mock_launchctl):
"""Test removing arguments from an existing agent."""
# Create with arguments
runner.invoke(app, ["create", str(mock_script), "--", "--old-arg", "old-value"])
# Edit with no arguments (clears them)
result = runner.invoke(app, ["edit", str(mock_script)])
assert result.exit_code == 0
# Verify arguments were removed
program_args = read_plist_args(get_plist_path(mock_dirs))
assert len(program_args) == 1 # Only the script path remains
def test_edit_replace_arguments(self, mock_dirs, mock_script, mock_launchctl):
"""Test replacing arguments on an existing agent."""
# Create with initial arguments
runner.invoke(app, ["create", str(mock_script), "--", "--old"])
# Edit to replace with new arguments
result = runner.invoke(app, ["edit", str(mock_script), "--", "--new", "value"])
assert result.exit_code == 0
# Verify arguments were replaced
program_args = read_plist_args(get_plist_path(mock_dirs))
assert program_args[1:] == ["--new", "value"]
def test_edit_nonexistent(self, mock_dirs, mock_script):
"""Test editing nonexistent agent."""
result = runner.invoke(app, ["edit", str(mock_script), "--", "--arg"])
assert result.exit_code == 1
assert "does not exist" in result.output
@pytest.mark.parametrize("new_args", [
["--single"],
["--multi", "arg1", "arg2"],
[],
])
def test_edit_various_arguments(self, mock_dirs, mock_script, mock_launchctl, new_args):
"""Test editing with various argument configurations."""
runner.invoke(app, ["create", str(mock_script), "--", "--initial"])
cmd = ["edit", str(mock_script)]
if new_args:
cmd += ["--"] + new_args
result = runner.invoke(app, cmd)
assert result.exit_code == 0
program_args = read_plist_args(get_plist_path(mock_dirs))
assert program_args[1:] == new_args
class TestShow:
@pytest.mark.parametrize("script_args,expected_display", [
([], "(none)"),
(["--verbose"], "--verbose"),
(["--config", "test.json"], "--config test.json"),
])
def test_show_with_arguments(self, mock_dirs, mock_script, mock_launchctl, script_args, expected_display):
"""Test showing agent details with various arguments."""
cmd = ["create", str(mock_script)]
if script_args:
cmd += ["--"] + script_args
runner.invoke(app, cmd)
result = runner.invoke(app, ["show", str(mock_script)])
assert result.exit_code == 0
assert "Label:" in result.stdout
assert "test_script" in result.stdout
assert f"Arguments: {expected_display}" in result.stdout
def test_show_nonexistent(self, mock_dirs, mock_script):
"""Test showing nonexistent agent."""
result = runner.invoke(app, ["show", str(mock_script)])
assert result.exit_code == 1
assert "does not exist" in result.output
class TestDelete:
def test_delete_agent(self, mock_dirs, mock_script, mock_launchctl):
"""Test deleting an agent."""
runner.invoke(app, ["create", str(mock_script)])
result = runner.invoke(app, ["delete", str(mock_script)])
assert result.exit_code == 0
assert "Deleted:" in result.stdout
# Verify plist was removed
plist_path = get_plist_path(mock_dirs)
assert not plist_path.exists()
def test_delete_nonexistent(self, mock_dirs, mock_script):
"""Test deleting nonexistent agent."""
result = runner.invoke(app, ["delete", str(mock_script)])
assert result.exit_code == 1
assert "does not exist" in result.output
class TestPlistFormat:
"""Tests verifying plist file format independently of plistlib."""
def test_plist_is_valid_xml(self, mock_dirs, mock_script, mock_launchctl):
"""Verify created plist is valid XML (not relying on plistlib)."""
runner.invoke(app, ["create", str(mock_script), "--", "--test-arg", "value"])
plist_path = get_plist_path(mock_dirs)
# Parse as raw XML
tree = ET.parse(plist_path)
root = tree.getroot()
assert root.tag == "plist"
assert root.attrib.get("version") == "1.0"
def test_plist_contains_expected_keys(self, mock_dirs, mock_script, mock_launchctl):
"""Verify plist contains required LaunchAgent keys via XML parsing."""
runner.invoke(app, ["create", str(mock_script), "--", "--arg1", "val1"])
plist_path = get_plist_path(mock_dirs)
tree = ET.parse(plist_path)
root = tree.getroot()
# Find the dict element containing key-value pairs
dict_elem = root.find("dict")
assert dict_elem is not None
# Extract keys from the plist
keys = [elem.text for elem in dict_elem.findall("key")]
assert "Label" in keys
assert "ProgramArguments" in keys
assert "RunAtLoad" in keys
assert "KeepAlive" in keys
assert "StandardOutPath" in keys
assert "StandardErrorPath" in keys
def test_plist_program_arguments_structure(self, mock_dirs, mock_script, mock_launchctl):
"""Verify ProgramArguments array structure via XML parsing."""
runner.invoke(app, ["create", str(mock_script), "--", "--flag", "value"])
plist_path = get_plist_path(mock_dirs)
tree = ET.parse(plist_path)
root = tree.getroot()
dict_elem = root.find("dict")
# Find ProgramArguments key and its following array
children = list(dict_elem)
for i, child in enumerate(children):
if child.tag == "key" and child.text == "ProgramArguments":
array_elem = children[i + 1]
assert array_elem.tag == "array"
# Extract string values from array
args = [s.text for s in array_elem.findall("string")]
assert len(args) == 3 # script path + 2 args
assert args[0] == str(mock_script.resolve())
assert args[1] == "--flag"
assert args[2] == "value"
break
else:
pytest.fail("ProgramArguments key not found")