mcquack/test_mcquack.py
Erich Blume 5b78165e33 Rework edit command to open plist in $EDITOR
Instead of a CLI for editing plist arguments programmatically, the edit
command now opens the plist file directly in the user's $EDITOR (falls
back to vi). This provides more flexibility for editing any plist field.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-13 09:02:48 -08:00

372 lines
15 KiB
Python

"""Tests for mcquack."""
import plistlib
import subprocess
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_opens_editor(self, mock_dirs, mock_script, mock_launchctl, monkeypatch):
"""Test that edit opens the plist in $EDITOR."""
# Create an agent first
runner.invoke(app, ["create", str(mock_script)])
# Track editor calls
editor_calls = []
def mock_editor_run(args, **kwargs):
editor_calls.append(args)
return subprocess.CompletedProcess(args, 0)
# Mock subprocess.run to capture editor call
original_run = subprocess.run
def patched_run(args, **kwargs):
if args and args[0] == "test-editor":
return mock_editor_run(args, **kwargs)
return original_run(args, **kwargs)
monkeypatch.setattr(subprocess, "run", patched_run)
monkeypatch.setenv("EDITOR", "test-editor")
result = runner.invoke(app, ["edit", str(mock_script)])
assert result.exit_code == 0
assert "Edited:" in result.stdout
assert "Reloaded:" in result.stdout
# Verify editor was called with the plist path
assert len(editor_calls) == 1
assert editor_calls[0][0] == "test-editor"
assert str(get_plist_path(mock_dirs)) in editor_calls[0][1]
def test_edit_uses_vi_as_default(self, mock_dirs, mock_script, mock_launchctl, monkeypatch):
"""Test that edit falls back to vi if $EDITOR is not set."""
runner.invoke(app, ["create", str(mock_script)])
editor_calls = []
original_run = subprocess.run
def patched_run(args, **kwargs):
if args and args[0] == "vi":
editor_calls.append(args)
return subprocess.CompletedProcess(args, 0)
return original_run(args, **kwargs)
monkeypatch.setattr(subprocess, "run", patched_run)
monkeypatch.delenv("EDITOR", raising=False)
result = runner.invoke(app, ["edit", str(mock_script)])
assert result.exit_code == 0
assert len(editor_calls) == 1
assert editor_calls[0][0] == "vi"
def test_edit_nonexistent(self, mock_dirs, mock_script):
"""Test editing nonexistent agent."""
result = runner.invoke(app, ["edit", str(mock_script)])
assert result.exit_code == 1
assert "does not exist" in result.output
def test_edit_editor_failure(self, mock_dirs, mock_script, mock_launchctl, monkeypatch):
"""Test that edit fails if editor exits with error."""
runner.invoke(app, ["create", str(mock_script)])
original_run = subprocess.run
def patched_run(args, **kwargs):
if args and args[0] == "failing-editor":
return subprocess.CompletedProcess(args, 1)
return original_run(args, **kwargs)
monkeypatch.setattr(subprocess, "run", patched_run)
monkeypatch.setenv("EDITOR", "failing-editor")
result = runner.invoke(app, ["edit", str(mock_script)])
assert result.exit_code == 1
assert "editor exited with code" in result.output
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(self, mock_dirs, mock_script, mock_launchctl):
"""Verify that edit --help shows edit command help."""
result = runner.invoke(app, ["edit", "--help"])
assert result.exit_code == 0
assert "Edit a plist" in result.stdout or "Usage:" in result.stdout
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")