mcquack/test_mcquack.py
Erich Blume aaaf9dac20 Add pre-commit hooks with ty type checking
Set up pre-commit with basic hooks (trailing whitespace, end-of-file,
large files) and ty for type checking via uvx. Fix type error in test
where root.find() could return None.

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

373 lines
15 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 TestArgumentSeparator:
"""Tests verifying that '--' separator is required for passing arguments to scripts.
This ensures that:
- 'mcquack create script.sh --help' shows help for the create command
- 'mcquack create script.sh -- --help' creates a plist with --help as a script arg
"""
def test_create_help_without_separator(self, mock_dirs, mock_script, mock_launchctl):
"""Verify that --help without -- shows create command help, not passed to script."""
result = runner.invoke(app, ["create", str(mock_script), "--help"])
assert result.exit_code == 0
# Should show help text for create command
assert "Create and load a LaunchAgent" in result.stdout or "Usage:" in result.stdout
# Should NOT have created a plist
plist_path = get_plist_path(mock_dirs)
assert not plist_path.exists()
def test_create_help_with_separator(self, mock_dirs, mock_script, mock_launchctl):
"""Verify that -- --help passes --help as an argument to the script."""
result = runner.invoke(app, ["create", str(mock_script), "--", "--help"])
assert result.exit_code == 0
assert "Created:" in result.stdout
# Verify --help is in the plist as a script argument
plist_path = get_plist_path(mock_dirs)
assert plist_path.exists()
program_args = read_plist_args(plist_path)
assert "--help" in program_args
assert program_args[1] == "--help"
def test_create_verbose_without_separator(self, mock_dirs, mock_script, mock_launchctl):
"""Verify that --verbose without -- is treated as unknown option, not script arg."""
result = runner.invoke(app, ["create", str(mock_script), "--verbose"])
# Typer should reject unknown options
assert result.exit_code != 0
assert "No such option" in result.output or "no such option" in result.output.lower()
# Should NOT have created a plist
plist_path = get_plist_path(mock_dirs)
assert not plist_path.exists()
def test_create_verbose_with_separator(self, mock_dirs, mock_script, mock_launchctl):
"""Verify that -- --verbose passes --verbose as an argument to the script."""
result = runner.invoke(app, ["create", str(mock_script), "--", "--verbose"])
assert result.exit_code == 0
plist_path = get_plist_path(mock_dirs)
program_args = read_plist_args(plist_path)
assert "--verbose" in program_args
def test_edit_help_without_separator(self, mock_dirs, mock_script, mock_launchctl):
"""Verify that edit --help shows edit command help."""
# First create an agent
runner.invoke(app, ["create", str(mock_script)])
result = runner.invoke(app, ["edit", str(mock_script), "--help"])
assert result.exit_code == 0
assert "Edit the arguments" in result.stdout or "Usage:" in result.stdout
def test_edit_help_with_separator(self, mock_dirs, mock_script, mock_launchctl):
"""Verify that edit -- --help passes --help as a new argument."""
# First create an agent
runner.invoke(app, ["create", str(mock_script)])
result = runner.invoke(app, ["edit", str(mock_script), "--", "--help"])
assert result.exit_code == 0
plist_path = get_plist_path(mock_dirs)
program_args = read_plist_args(plist_path)
assert "--help" in program_args
def test_create_mixed_args_with_separator(self, mock_dirs, mock_script, mock_launchctl):
"""Verify multiple dash-prefixed args after -- are all passed to script."""
result = runner.invoke(app, ["create", str(mock_script), "--", "--config", "/path", "--debug", "-v"])
assert result.exit_code == 0
plist_path = get_plist_path(mock_dirs)
program_args = read_plist_args(plist_path)
# All args after -- should be in the plist
assert program_args[1:] == ["--config", "/path", "--debug", "-v"]
def test_create_double_dash_as_argument(self, mock_dirs, mock_script, mock_launchctl):
"""Verify that -- can be passed as a script argument using -- --."""
result = runner.invoke(app, ["create", str(mock_script), "--", "--", "arg_after_double_dash"])
assert result.exit_code == 0
plist_path = get_plist_path(mock_dirs)
program_args = read_plist_args(plist_path)
# The first -- is consumed by typer, the second becomes an argument
assert "--" in program_args
assert "arg_after_double_dash" in program_args
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")
assert dict_elem is not None
# 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")