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>
372 lines
15 KiB
Python
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")
|