mcquack/test_mcquack.py
Erich Blume e55cff15ee Rename 'script' to 'command' with intelligent path resolution
- Rename script argument to command throughout codebase
- Add resolve_command() function for smart path/command handling
- Paths (containing /, ~, or .) are expanded to absolute paths and validated
- Commands (no /) are searched on $PATH and kept as-is in plist
- Add comprehensive test suite with 13 new tests for path resolution
- Verify all resolved paths stay within expected boundaries
- Update README with command resolution details and examples

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-13 10:25:07 -08:00

974 lines
41 KiB
Python

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