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>
279 lines
10 KiB
Python
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")
|