"""Tests for mcquack.""" import os 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") class TestStartInterval: """Tests for StartInterval scheduling.""" def test_create_with_interval(self, mock_dirs, mock_script, mock_launchctl): """Test creating agent with StartInterval.""" result = runner.invoke(app, ["create", str(mock_script), "--interval", "300"]) assert result.exit_code == 0 assert "Created:" in result.stdout # Verify plist contains StartInterval plist_path = get_plist_path(mock_dirs) with open(plist_path, "rb") as f: plist = plistlib.load(f) assert plist.get("StartInterval") == 300 assert plist.get("KeepAlive") is False assert plist.get("RunAtLoad") is True def test_create_with_interval_and_args(self, mock_dirs, mock_script, mock_launchctl): """Test creating agent with StartInterval and script arguments.""" result = runner.invoke(app, ["create", str(mock_script), "--interval", "600", "--", "--config", "test.json"]) assert result.exit_code == 0 plist_path = get_plist_path(mock_dirs) with open(plist_path, "rb") as f: plist = plistlib.load(f) assert plist.get("StartInterval") == 600 program_args = plist.get("ProgramArguments", []) assert program_args[1:] == ["--config", "test.json"] def test_interval_must_be_positive(self, mock_dirs, mock_script, mock_launchctl): """Test that interval must be a positive integer.""" result = runner.invoke(app, ["create", str(mock_script), "--interval", "-1"]) assert result.exit_code != 0 result = runner.invoke(app, ["create", str(mock_script), "--interval", "0"]) assert result.exit_code != 0 def test_interval_xml_format(self, mock_dirs, mock_script, mock_launchctl): """Verify StartInterval is correctly formatted in XML.""" runner.invoke(app, ["create", str(mock_script), "--interval", "120"]) 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 StartInterval key and verify it's an integer children = list(dict_elem) for i, child in enumerate(children): if child.tag == "key" and child.text == "StartInterval": value_elem = children[i + 1] assert value_elem.tag == "integer" assert value_elem.text == "120" break else: pytest.fail("StartInterval key not found") def test_show_displays_interval(self, mock_dirs, mock_script, mock_launchctl): """Test that show command displays StartInterval.""" runner.invoke(app, ["create", str(mock_script), "--interval", "300"]) result = runner.invoke(app, ["show", str(mock_script)]) assert result.exit_code == 0 assert "StartInterval: 300" in result.stdout or "300" in result.stdout class TestThrottleInterval: """Tests for ThrottleInterval.""" def test_create_with_throttle_and_interval(self, mock_dirs, mock_script, mock_launchctl): """Test creating agent with both ThrottleInterval and StartInterval.""" result = runner.invoke(app, ["create", str(mock_script), "--interval", "300", "--throttle", "60"]) assert result.exit_code == 0 plist_path = get_plist_path(mock_dirs) with open(plist_path, "rb") as f: plist = plistlib.load(f) assert plist.get("StartInterval") == 300 assert plist.get("ThrottleInterval") == 60 assert plist.get("KeepAlive") is False def test_throttle_must_be_positive(self, mock_dirs, mock_script, mock_launchctl): """Test that throttle must be a positive integer.""" result = runner.invoke(app, ["create", str(mock_script), "--interval", "300", "--throttle", "-1"]) assert result.exit_code != 0 result = runner.invoke(app, ["create", str(mock_script), "--interval", "300", "--throttle", "0"]) assert result.exit_code != 0 def test_throttle_xml_format(self, mock_dirs, mock_script, mock_launchctl): """Verify ThrottleInterval is correctly formatted in XML.""" runner.invoke(app, ["create", str(mock_script), "--interval", "300", "--throttle", "45"]) 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 ThrottleInterval key and verify it's an integer children = list(dict_elem) for i, child in enumerate(children): if child.tag == "key" and child.text == "ThrottleInterval": value_elem = children[i + 1] assert value_elem.tag == "integer" assert value_elem.text == "45" break else: pytest.fail("ThrottleInterval key not found") def test_show_displays_throttle(self, mock_dirs, mock_script, mock_launchctl): """Test that show command displays ThrottleInterval.""" runner.invoke(app, ["create", str(mock_script), "--interval", "300", "--throttle", "60"]) result = runner.invoke(app, ["show", str(mock_script)]) assert result.exit_code == 0 assert "ThrottleInterval: 60" in result.stdout class TestCommandResolution: """Tests for command argument resolution (path vs PATH lookup).""" def test_absolute_path_resolved(self, mock_dirs, mock_launchctl, tmp_path): """Test that absolute paths are resolved (symlinks, etc).""" # Create a script in a subdirectory subdir = tmp_path / "scripts" subdir.mkdir() script = subdir / "myscript.sh" script.write_text("#!/bin/bash\necho 'hello'\n") script.chmod(script.stat().st_mode | 0o111) # Create command should work with absolute path result = runner.invoke(app, ["create", str(script)]) assert result.exit_code == 0 # Verify plist contains the resolved absolute path plist_path = get_plist_path(mock_dirs, script_name="myscript") program_args = read_plist_args(plist_path) # Should be absolute and resolved assert Path(program_args[0]).is_absolute() assert program_args[0] == str(script.resolve()) # Verify resolved path is within tmp_path assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) def test_relative_path_expanded(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): """Test that relative paths are expanded to absolute paths.""" # Create a script in a subdirectory subdir = tmp_path / "scripts" subdir.mkdir() script = subdir / "myscript.sh" script.write_text("#!/bin/bash\necho 'hello'\n") script.chmod(script.stat().st_mode | 0o111) # Change to tmp_path so we can use relative path monkeypatch.chdir(tmp_path) # Use relative path result = runner.invoke(app, ["create", "scripts/myscript.sh"]) assert result.exit_code == 0 # Verify plist contains absolute path plist_path = get_plist_path(mock_dirs, script_name="myscript") program_args = read_plist_args(plist_path) assert Path(program_args[0]).is_absolute() assert program_args[0] == str(script.resolve()) # Verify resolved path is within tmp_path assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) def test_tilde_path_expanded(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): """Test that ~ in paths is expanded.""" # Create a script script = tmp_path / "myscript.sh" script.write_text("#!/bin/bash\necho 'hello'\n") script.chmod(script.stat().st_mode | 0o111) # Use path with ~ (mock HOME to tmp_path) monkeypatch.setenv("HOME", str(tmp_path)) result = runner.invoke(app, ["create", "~/myscript.sh"]) assert result.exit_code == 0 # Verify plist contains expanded absolute path plist_path = get_plist_path(mock_dirs, script_name="myscript") program_args = read_plist_args(plist_path) assert Path(program_args[0]).is_absolute() assert "~" not in program_args[0] # Verify resolved path is within tmp_path (our mocked HOME) assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) def test_dotdot_path_resolved(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): """Test that .. in paths is resolved.""" # Create directory structure: tmp_path/a/b/ and tmp_path/scripts/ a_dir = tmp_path / "a" / "b" a_dir.mkdir(parents=True) scripts_dir = tmp_path / "scripts" scripts_dir.mkdir() script = scripts_dir / "myscript.sh" script.write_text("#!/bin/bash\necho 'hello'\n") script.chmod(script.stat().st_mode | 0o111) # Change to a/b and reference script with ../.. monkeypatch.chdir(a_dir) result = runner.invoke(app, ["create", "../../scripts/myscript.sh"]) assert result.exit_code == 0 # Verify plist contains resolved path without .. plist_path = get_plist_path(mock_dirs, script_name="myscript") program_args = read_plist_args(plist_path) assert ".." not in program_args[0] assert program_args[0] == str(script.resolve()) # Verify resolved path is within tmp_path assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) def test_command_on_path_not_expanded(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): """Test that command names (no /) found on PATH are NOT expanded to absolute paths.""" # Create a script in a directory that will be on PATH bin_dir = tmp_path / "bin" bin_dir.mkdir() script = bin_dir / "mycommand" script.write_text("#!/bin/bash\necho 'hello'\n") script.chmod(script.stat().st_mode | 0o111) # Add bin_dir to PATH monkeypatch.setenv("PATH", str(bin_dir) + ":" + os.environ.get("PATH", "")) # Use just the command name result = runner.invoke(app, ["create", "mycommand"]) assert result.exit_code == 0 # Verify plist contains just the command name, NOT the absolute path plist_path = get_plist_path(mock_dirs, script_name="mycommand") program_args = read_plist_args(plist_path) assert program_args[0] == "mycommand" assert not Path(program_args[0]).is_absolute() def test_command_not_on_path_fails(self, mock_dirs, mock_launchctl): """Test that command names (no /) NOT on PATH fail.""" result = runner.invoke(app, ["create", "nonexistent_command"]) assert result.exit_code == 1 assert "not found" in result.output.lower() or "does not exist" in result.output.lower() def test_command_in_cwd_as_path(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): """Test that executable in CWD accessed as path (./script) is expanded.""" script = tmp_path / "myscript.sh" script.write_text("#!/bin/bash\necho 'hello'\n") script.chmod(script.stat().st_mode | 0o111) monkeypatch.chdir(tmp_path) # Use ./ prefix to indicate it's a path result = runner.invoke(app, ["create", "./myscript.sh"]) assert result.exit_code == 0 # Should be expanded to absolute path plist_path = get_plist_path(mock_dirs, script_name="myscript") program_args = read_plist_args(plist_path) assert Path(program_args[0]).is_absolute() assert program_args[0] == str(script.resolve()) # Verify resolved path is within tmp_path assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) def test_command_in_cwd_and_path_prefers_path(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): """Test that when a command is in both CWD and PATH, using command name finds PATH version.""" # Create script in CWD cwd_script = tmp_path / "cwd" cwd_script.mkdir() cwd_executable = cwd_script / "mycommand" cwd_executable.write_text("#!/bin/bash\necho 'from cwd'\n") cwd_executable.chmod(cwd_executable.stat().st_mode | 0o111) # Create script on PATH path_dir = tmp_path / "bin" path_dir.mkdir() path_executable = path_dir / "mycommand" path_executable.write_text("#!/bin/bash\necho 'from path'\n") path_executable.chmod(path_executable.stat().st_mode | 0o111) # Set PATH and CWD monkeypatch.setenv("PATH", str(path_dir) + ":" + os.environ.get("PATH", "")) monkeypatch.chdir(cwd_script) # Use just the command name - should find PATH version result = runner.invoke(app, ["create", "mycommand"]) assert result.exit_code == 0 # Should NOT be expanded (stays as command name) plist_path = get_plist_path(mock_dirs, script_name="mycommand") program_args = read_plist_args(plist_path) assert program_args[0] == "mycommand" def test_command_in_cwd_not_on_path_as_path_works(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): """Test that executable in CWD but not on PATH works when accessed as path.""" script = tmp_path / "localscript" script.write_text("#!/bin/bash\necho 'hello'\n") script.chmod(script.stat().st_mode | 0o111) monkeypatch.chdir(tmp_path) # Use ./localscript to indicate it's a path result = runner.invoke(app, ["create", "./localscript"]) assert result.exit_code == 0 # Should be expanded plist_path = get_plist_path(mock_dirs, script_name="localscript") program_args = read_plist_args(plist_path) assert Path(program_args[0]).is_absolute() # Verify resolved path is within tmp_path assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) def test_symlink_resolved(self, mock_dirs, mock_launchctl, tmp_path): """Test that symlinks are resolved to their targets.""" # Create actual script real_script = tmp_path / "real_script.sh" real_script.write_text("#!/bin/bash\necho 'hello'\n") real_script.chmod(real_script.stat().st_mode | 0o111) # Create symlink symlink = tmp_path / "link_to_script" symlink.symlink_to(real_script) result = runner.invoke(app, ["create", str(symlink)]) assert result.exit_code == 0 # Plist should contain resolved path (to real_script) plist_path = get_plist_path(mock_dirs, script_name="link_to_script") program_args = read_plist_args(plist_path) assert program_args[0] == str(real_script.resolve()) # Verify resolved path is within tmp_path assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) def test_tilde_with_subdirectory_navigation(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): """Test that ~/subdir/.. patterns resolve correctly within tmp_path.""" # Create directory structure: tmp_path/scripts/bin/ and tmp_path/tools/ scripts_dir = tmp_path / "scripts" / "bin" scripts_dir.mkdir(parents=True) tools_dir = tmp_path / "tools" tools_dir.mkdir() script = tools_dir / "mytool.sh" script.write_text("#!/bin/bash\necho 'hello'\n") script.chmod(script.stat().st_mode | 0o111) # Mock HOME to tmp_path monkeypatch.setenv("HOME", str(tmp_path)) # Use ~/scripts/../tools/mytool.sh (navigates through ~ and ..) result = runner.invoke(app, ["create", "~/scripts/../tools/mytool.sh"]) assert result.exit_code == 0 # Verify plist contains resolved absolute path plist_path = get_plist_path(mock_dirs, script_name="mytool") program_args = read_plist_args(plist_path) assert Path(program_args[0]).is_absolute() assert "~" not in program_args[0] assert ".." not in program_args[0] assert program_args[0] == str(script.resolve()) # Critical: verify resolved path is within tmp_path assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) def test_complex_dotdot_navigation_stays_in_tmpdir(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): """Test that complex .. navigation resolves within tmp_path.""" # Create structure: tmp_path/a/b/c/d/ and tmp_path/x/y/ deep_dir = tmp_path / "a" / "b" / "c" / "d" deep_dir.mkdir(parents=True) target_dir = tmp_path / "x" / "y" target_dir.mkdir(parents=True) script = target_dir / "target.sh" script.write_text("#!/bin/bash\necho 'hello'\n") script.chmod(script.stat().st_mode | 0o111) # Change to deep directory monkeypatch.chdir(deep_dir) # Use ../../../../x/y/target.sh to navigate up and across result = runner.invoke(app, ["create", "../../../../x/y/target.sh"]) assert result.exit_code == 0 # Verify plist contains resolved path plist_path = get_plist_path(mock_dirs, script_name="target") program_args = read_plist_args(plist_path) assert Path(program_args[0]).is_absolute() assert ".." not in program_args[0] assert program_args[0] == str(script.resolve()) # Critical: verify resolved path is within tmp_path assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) def test_tilde_and_dotdot_combination(self, mock_dirs, mock_launchctl, tmp_path, monkeypatch): """Test combining ~ and .. in the same path resolves within tmp_path.""" # Create structure: tmp_path/home/user/bin/ and tmp_path/opt/ user_bin = tmp_path / "home" / "user" / "bin" user_bin.mkdir(parents=True) opt_dir = tmp_path / "opt" opt_dir.mkdir() script = opt_dir / "script.sh" script.write_text("#!/bin/bash\necho 'hello'\n") script.chmod(script.stat().st_mode | 0o111) # Mock HOME to tmp_path/home/user home_dir = tmp_path / "home" / "user" monkeypatch.setenv("HOME", str(home_dir)) # Use ~/bin/../../../opt/script.sh (~ expands, then navigate with .. back to tmp_path) # ~/bin = tmp_path/home/user/bin # ../../.. navigates: bin -> user -> home -> tmp_path # then opt/script.sh = tmp_path/opt/script.sh result = runner.invoke(app, ["create", "~/bin/../../../opt/script.sh"]) assert result.exit_code == 0 # Verify resolution plist_path = get_plist_path(mock_dirs, script_name="script") program_args = read_plist_args(plist_path) assert Path(program_args[0]).is_absolute() assert "~" not in program_args[0] assert ".." not in program_args[0] assert program_args[0] == str(script.resolve()) # Critical: verify resolved path is within tmp_path assert Path(program_args[0]).resolve().is_relative_to(tmp_path.resolve()) class TestStartCalendarInterval: """Tests for StartCalendarInterval scheduling.""" def test_create_with_calendar_simple(self, mock_dirs, mock_script, mock_launchctl): """Test creating agent with simple calendar interval (every day at 2 AM).""" result = runner.invoke(app, ["create", str(mock_script), "--calendar", "Hour=2,Minute=0"]) assert result.exit_code == 0 plist_path = get_plist_path(mock_dirs) with open(plist_path, "rb") as f: plist = plistlib.load(f) assert "StartCalendarInterval" in plist calendar = plist["StartCalendarInterval"] assert isinstance(calendar, dict) assert calendar.get("Hour") == 2 assert calendar.get("Minute") == 0 assert plist.get("KeepAlive") is False assert plist.get("RunAtLoad") is True def test_create_with_calendar_all_fields(self, mock_dirs, mock_script, mock_launchctl): """Test calendar with all possible fields.""" result = runner.invoke( app, ["create", str(mock_script), "--calendar", "Month=12,Day=25,Hour=9,Minute=30,Weekday=1"] ) assert result.exit_code == 0 plist_path = get_plist_path(mock_dirs) with open(plist_path, "rb") as f: plist = plistlib.load(f) calendar = plist["StartCalendarInterval"] assert calendar.get("Month") == 12 assert calendar.get("Day") == 25 assert calendar.get("Hour") == 9 assert calendar.get("Minute") == 30 assert calendar.get("Weekday") == 1 def test_create_with_multiple_calendar_entries(self, mock_dirs, mock_script, mock_launchctl): """Test creating agent with multiple calendar intervals.""" result = runner.invoke( app, ["create", str(mock_script), "--calendar", "Weekday=1,Hour=9,Minute=0", "--calendar", "Weekday=5,Hour=9,Minute=0"] ) assert result.exit_code == 0 plist_path = get_plist_path(mock_dirs) with open(plist_path, "rb") as f: plist = plistlib.load(f) calendar = plist["StartCalendarInterval"] assert isinstance(calendar, list) assert len(calendar) == 2 assert calendar[0] == {"Weekday": 1, "Hour": 9, "Minute": 0} assert calendar[1] == {"Weekday": 5, "Hour": 9, "Minute": 0} def test_calendar_with_script_args(self, mock_dirs, mock_script, mock_launchctl): """Test calendar interval combined with script arguments.""" result = runner.invoke( app, ["create", str(mock_script), "--calendar", "Hour=3,Minute=0", "--", "--config", "test.json"] ) assert result.exit_code == 0 plist_path = get_plist_path(mock_dirs) with open(plist_path, "rb") as f: plist = plistlib.load(f) program_args = plist.get("ProgramArguments", []) assert program_args[1:] == ["--config", "test.json"] assert plist["StartCalendarInterval"]["Hour"] == 3 def test_calendar_invalid_format(self, mock_dirs, mock_script, mock_launchctl): """Test that invalid calendar format is rejected.""" # Missing equals sign result = runner.invoke(app, ["create", str(mock_script), "--calendar", "Hour2"]) assert result.exit_code != 0 # Invalid key result = runner.invoke(app, ["create", str(mock_script), "--calendar", "InvalidKey=5"]) assert result.exit_code != 0 # Non-integer value result = runner.invoke(app, ["create", str(mock_script), "--calendar", "Hour=abc"]) assert result.exit_code != 0 def test_calendar_invalid_values(self, mock_dirs, mock_script, mock_launchctl): """Test that invalid calendar values are rejected.""" # Hour out of range result = runner.invoke(app, ["create", str(mock_script), "--calendar", "Hour=25"]) assert result.exit_code != 0 # Minute out of range result = runner.invoke(app, ["create", str(mock_script), "--calendar", "Minute=60"]) assert result.exit_code != 0 # Month out of range result = runner.invoke(app, ["create", str(mock_script), "--calendar", "Month=13"]) assert result.exit_code != 0 # Day out of range result = runner.invoke(app, ["create", str(mock_script), "--calendar", "Day=32"]) assert result.exit_code != 0 # Weekday out of range result = runner.invoke(app, ["create", str(mock_script), "--calendar", "Weekday=8"]) assert result.exit_code != 0 def test_calendar_xml_format_single(self, mock_dirs, mock_script, mock_launchctl): """Verify single calendar entry is correctly formatted in XML.""" runner.invoke(app, ["create", str(mock_script), "--calendar", "Hour=14,Minute=30"]) 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 StartCalendarInterval key children = list(dict_elem) for i, child in enumerate(children): if child.tag == "key" and child.text == "StartCalendarInterval": value_elem = children[i + 1] # Single entry should be a dict assert value_elem.tag == "dict" break else: pytest.fail("StartCalendarInterval key not found") def test_calendar_xml_format_multiple(self, mock_dirs, mock_script, mock_launchctl): """Verify multiple calendar entries are correctly formatted in XML as array.""" runner.invoke( app, ["create", str(mock_script), "--calendar", "Hour=9,Minute=0", "--calendar", "Hour=17,Minute=0"] ) 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 StartCalendarInterval key children = list(dict_elem) for i, child in enumerate(children): if child.tag == "key" and child.text == "StartCalendarInterval": value_elem = children[i + 1] # Multiple entries should be an array assert value_elem.tag == "array" dicts_in_array = value_elem.findall("dict") assert len(dicts_in_array) == 2 break else: pytest.fail("StartCalendarInterval key not found") def test_show_displays_calendar(self, mock_dirs, mock_script, mock_launchctl): """Test that show command displays StartCalendarInterval.""" runner.invoke(app, ["create", str(mock_script), "--calendar", "Hour=2,Minute=0"]) result = runner.invoke(app, ["show", str(mock_script)]) assert result.exit_code == 0 assert "StartCalendarInterval" in result.stdout def test_calendar_and_interval_mutually_exclusive(self, mock_dirs, mock_script, mock_launchctl): """Test that --calendar and --interval cannot be used together.""" result = runner.invoke( app, ["create", str(mock_script), "--interval", "300", "--calendar", "Hour=2,Minute=0"] ) assert result.exit_code != 0 assert "mutually exclusive" in result.output.lower() or "cannot" in result.output.lower()