- Add resolve_plist_name() for flexible plist identifier resolution - Accepts: stem, label, filename, or full path - Provides helpful error messages for ambiguous or missing plists - Update edit, launch, show, unload, delete to use plist names instead of command paths - Enhance list command with rich library for formatted three-column table - Agent Name | Command | Details (args, intervals, calendar) - Add rich>=13.0.0 dependency to script header and pyproject.toml - Update tests to use plist name resolution - Update README with new usage examples Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
980 lines
41 KiB
Python
980 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", "test_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", "test_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", "test_script"])
|
|
assert result.exit_code == 1
|
|
assert ("No plist found" in result.output or
|
|
"does not exist" in result.output or
|
|
"No mcquack-managed LaunchAgents found" 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", "test_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", "test_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", "test_script"])
|
|
assert result.exit_code == 1
|
|
assert ("No plist found" in result.output or
|
|
"does not exist" in result.output or
|
|
"No mcquack-managed LaunchAgents found" 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", "test_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", "test_script"])
|
|
assert result.exit_code == 1
|
|
assert ("No plist found" in result.output or
|
|
"does not exist" in result.output or
|
|
"No mcquack-managed LaunchAgents found" 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", "test_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", "test_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", "test_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()
|