"""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")