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