pi_wake_agent: --pane targeting + crontab backend fallback + cronicle HCL fix + hermetic tests
- --pane/--pane-id: write-chars AND write(13) hit terminal_1 (bottom pane) - cronicle_available()=shutil.which + daemon-live check; crontab/crnd auto-fallback (cronicle daemon down here -> live scheduler is system crnd) - fix cronicle_install invalid HCL (command=[...] array, was bare comma-list) - rewrite TestCrontabEntry hermetic (subprocess mocked; real crontab never touched) - add --pane-id / no-pane omission / HCL-array mutation-litmus tests; .sh --pane - cronicle.hcl: corrected pi_wake_pi_test_20m (msg + --pane terminal_1), valid HCL - live 20m crontab doorbell installed for pi_test/terminal_1, bell verified delivered - SIGQUIT rescued a locked pi (final_test @1318s); steer queued + bus-posted
This commit is contained in:
@@ -2,6 +2,13 @@
|
||||
"""
|
||||
Comprehensive test suite for pi_wake_agent.py
|
||||
Tests all modes, edge cases, and the new succession feature.
|
||||
|
||||
Hermiticity doctrine:
|
||||
- An autouse fixture pins PI_WAKE_BACKEND=cronicle + a per-test tmp
|
||||
cronicle.hcl, so no test ever touches the real crontab or the real
|
||||
cronicle.hcl. Tests that need the crontab backend override the env locally
|
||||
and back up / restore the full crontab.
|
||||
- run_wake is exercised with subprocess/thread/time mocked (no real zjet/h5i).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -10,18 +17,25 @@ import time
|
||||
import tempfile
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
|
||||
# Add the script directory to path
|
||||
sys.path.insert(0, "/mnt/dolphinng5_predict")
|
||||
|
||||
import pi_wake_agent
|
||||
from pi_wake_agent import (
|
||||
parse_interval,
|
||||
interval_to_cron,
|
||||
interval_to_human,
|
||||
cron_comment,
|
||||
parse_sessions,
|
||||
wake_command_args,
|
||||
use_cronicle,
|
||||
cronicle_available,
|
||||
cronicle_daemon_running,
|
||||
run_wake,
|
||||
SCRIPT_PATH,
|
||||
LOG_FILE,
|
||||
LOG_MAX_SIZE,
|
||||
@@ -33,6 +47,26 @@ from pi_wake_agent import (
|
||||
DEFAULT_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _piwake_hermetic(tmp_path, monkeypatch):
|
||||
"""Force the cronicle backend + a per-test tmp cronicle.hcl.
|
||||
|
||||
Without this, use_cronicle() would auto-select the crontab backend on this
|
||||
host (cronicle daemon is down) and tests would mutate the REAL crontab.
|
||||
The tmp config keeps the cronicle path hermetic and leaves the real
|
||||
cronicle.hcl untouched. Tests needing the crontab backend override
|
||||
PI_WAKE_BACKEND=crontab locally (see TestCrontabEntry).
|
||||
"""
|
||||
monkeypatch.setenv("PI_WAKE_BACKEND", "cronicle")
|
||||
monkeypatch.setenv("PI_WAKE_CRONICLE_CONFIG", str(tmp_path / "cronicle.hcl"))
|
||||
yield
|
||||
|
||||
|
||||
def cronicle_hcl_path() -> Path:
|
||||
return Path(os.environ["PI_WAKE_CRONICLE_CONFIG"])
|
||||
|
||||
|
||||
# ─── Test parse_interval ─────────────────────────────────────────────────
|
||||
|
||||
class TestParseInterval:
|
||||
@@ -40,17 +74,17 @@ class TestParseInterval:
|
||||
assert parse_interval("1h") == 3600
|
||||
assert parse_interval("2h") == 7200
|
||||
assert parse_interval("24h") == 86400
|
||||
|
||||
|
||||
def test_minutes(self):
|
||||
assert parse_interval("30m") == 1800
|
||||
assert parse_interval("1m") == 60
|
||||
assert parse_interval("90m") == 5400
|
||||
|
||||
|
||||
def test_seconds(self):
|
||||
assert parse_interval("30s") == 30
|
||||
assert parse_interval("1s") == 1
|
||||
assert parse_interval("10s") == 10
|
||||
|
||||
|
||||
def test_invalid(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_interval("invalid")
|
||||
@@ -66,22 +100,22 @@ class TestIntervalToCron:
|
||||
assert interval_to_cron("1h") == "0 */1 * * *"
|
||||
assert interval_to_cron("2h") == "0 */2 * * *"
|
||||
assert interval_to_cron("6h") == "0 */6 * * *"
|
||||
|
||||
|
||||
def test_minutes(self):
|
||||
assert interval_to_cron("1m") == "*/1 * * * *"
|
||||
assert interval_to_cron("30m") == "*/30 * * * *"
|
||||
assert interval_to_cron("45m") == "*/45 * * * *"
|
||||
|
||||
|
||||
def test_invalid_minutes(self):
|
||||
with pytest.raises(ValueError):
|
||||
interval_to_cron("60m")
|
||||
with pytest.raises(ValueError):
|
||||
interval_to_cron("90m")
|
||||
|
||||
|
||||
def test_invalid_seconds(self):
|
||||
with pytest.raises(ValueError):
|
||||
interval_to_cron("30s")
|
||||
|
||||
|
||||
def test_invalid_format(self):
|
||||
with pytest.raises(ValueError):
|
||||
interval_to_cron("invalid")
|
||||
@@ -92,15 +126,15 @@ class TestIntervalToHuman:
|
||||
def test_hours(self):
|
||||
assert interval_to_human("1h") == "1 hour(s)"
|
||||
assert interval_to_human("2h") == "2 hour(s)"
|
||||
|
||||
|
||||
def test_minutes(self):
|
||||
assert interval_to_human("30m") == "30 minute(s)"
|
||||
assert interval_to_human("1m") == "1 minute(s)"
|
||||
|
||||
|
||||
def test_seconds(self):
|
||||
assert interval_to_human("30s") == "30 second(s)"
|
||||
assert interval_to_human("1s") == "1 second(s)"
|
||||
|
||||
|
||||
def test_invalid(self):
|
||||
assert interval_to_human("invalid") == "invalid"
|
||||
|
||||
@@ -111,12 +145,12 @@ class TestCronComment:
|
||||
sessions = ["cc_UV_dev0_Fb"]
|
||||
result = cron_comment(sessions, "1h")
|
||||
assert result == "pi_wake_agent:cc_UV_dev0_Fb:1h"
|
||||
|
||||
|
||||
def test_multiple_sessions(self):
|
||||
sessions = ["cc_UV_dev0_Fb", "cc_UV_dev1_48"]
|
||||
result = cron_comment(sessions, "30m")
|
||||
assert result == "pi_wake_agent:cc_UV_dev0_Fb,cc_UV_dev1_48:30m"
|
||||
|
||||
|
||||
def test_empty_sessions(self):
|
||||
result = cron_comment([], "1h")
|
||||
assert result == "pi_wake_agent::1h"
|
||||
@@ -127,23 +161,23 @@ class TestParseSessions:
|
||||
def test_comma_separated(self):
|
||||
result = parse_sessions("cc_UV_dev0_Fb,cc_UV_dev1_48")
|
||||
assert result == ["cc_UV_dev0_Fb", "cc_UV_dev1_48"]
|
||||
|
||||
|
||||
def test_single_session(self):
|
||||
result = parse_sessions("cc_UV_dev0_Fb")
|
||||
assert result == ["cc_UV_dev0_Fb"]
|
||||
|
||||
|
||||
def test_with_spaces(self):
|
||||
result = parse_sessions("cc_UV_dev0_Fb, cc_UV_dev1_48")
|
||||
assert result == ["cc_UV_dev0_Fb", "cc_UV_dev1_48"]
|
||||
|
||||
|
||||
def test_empty(self):
|
||||
assert parse_sessions("") == []
|
||||
assert parse_sessions(None) == []
|
||||
|
||||
|
||||
def test_list_input(self):
|
||||
result = parse_sessions(["a", "b", "c"])
|
||||
assert result == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_filters_empty(self):
|
||||
result = parse_sessions("a,,b")
|
||||
assert result == ["a", "b"]
|
||||
@@ -152,13 +186,14 @@ class TestParseSessions:
|
||||
|
||||
SCRIPT = "/mnt/dolphinng5_predict/pi_wake_agent.py"
|
||||
|
||||
def run_cmd(args, timeout=30):
|
||||
def run_cmd(args, env=None, timeout=30):
|
||||
"""Run the script and return (returncode, stdout, stderr)"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
@@ -174,45 +209,58 @@ class TestHelp:
|
||||
assert "--daemon" in out
|
||||
assert "--succession" in out
|
||||
assert "--count" in out
|
||||
assert "--pane" in out # new pane-targeting flag is documented
|
||||
|
||||
class TestList:
|
||||
def test_list_no_cron(self):
|
||||
# Clear any existing cron entries first
|
||||
def setup_method(self):
|
||||
# Clear cronicle config (uses the per-test tmp path)
|
||||
config_path = cronicle_hcl_path()
|
||||
if config_path.exists():
|
||||
config_path.unlink()
|
||||
# Clear system cron (harmless under cronicle backend)
|
||||
subprocess.run(["crontab", "-l"], capture_output=True)
|
||||
subprocess.run("crontab -l 2>/dev/null | grep -v pi_wake_agent | crontab -", shell=True, check=False)
|
||||
|
||||
|
||||
def test_list_no_cron(self):
|
||||
rc, out, err = run_cmd(["--list"])
|
||||
assert rc == 0
|
||||
assert "pi_wake_agent cron entries" in out
|
||||
assert "(none)" in out or "pi_wake_agent cron entries" in out
|
||||
|
||||
class TestInstallRemove:
|
||||
def setup_method(self):
|
||||
# Clear cron before each test
|
||||
# Clear cronicle config (per-test tmp path)
|
||||
config_path = cronicle_hcl_path()
|
||||
if config_path.exists():
|
||||
config_path.unlink()
|
||||
# Clear system cron
|
||||
subprocess.run("crontab -l 2>/dev/null | grep -v pi_wake_agent | crontab -", shell=True, check=False)
|
||||
|
||||
|
||||
def teardown_method(self):
|
||||
config_path = cronicle_hcl_path()
|
||||
if config_path.exists():
|
||||
config_path.unlink()
|
||||
subprocess.run("crontab -l 2>/dev/null | grep -v pi_wake_agent | crontab -", shell=True, check=False)
|
||||
|
||||
|
||||
def test_install_and_list(self):
|
||||
rc, out, err = run_cmd(["--install", "--interval", "1h", "--session", "test_session", "--msg", "test"])
|
||||
assert rc == 0
|
||||
|
||||
assert rc == 0, f"install failed: {err}"
|
||||
|
||||
rc, out, err = run_cmd(["--list"])
|
||||
assert rc == 0
|
||||
assert "test_session" in out
|
||||
assert "1h" in out
|
||||
|
||||
|
||||
# Cleanup
|
||||
run_cmd(["--remove", "--session", "test_session", "--interval", "1h"])
|
||||
|
||||
|
||||
def test_install_multi_session(self):
|
||||
rc, out, err = run_cmd(["--install", "--interval", "30m", "--sessions", "s1,s2", "--msg", "multi"])
|
||||
assert rc == 0
|
||||
|
||||
assert rc == 0, f"install failed: {err}"
|
||||
|
||||
rc, out, err = run_cmd(["--list"])
|
||||
assert rc == 0
|
||||
assert "s1,s2" in out
|
||||
|
||||
assert "s1_s2" in out # cronicle format: pi_wake_s1_s2_30m
|
||||
|
||||
run_cmd(["--remove", "--sessions", "s1,s2", "--interval", "30m"])
|
||||
|
||||
class TestValidate:
|
||||
@@ -244,7 +292,7 @@ class TestSuccession:
|
||||
log_content = Path("/tmp/pi_wake_agent.log").read_text()
|
||||
assert "SUCCESSION START" in log_content
|
||||
assert "SUCCESSION COMPLETE" in log_content
|
||||
|
||||
|
||||
def test_succession_invalid_count(self):
|
||||
rc, out, err = run_cmd(["--succession", "--count", "0", "--interval", "1s", "--session", "test"])
|
||||
# Should fail with invalid count
|
||||
@@ -256,6 +304,9 @@ class TestStatus:
|
||||
assert rc == 0
|
||||
assert "pi_wake_agent Status" in out
|
||||
assert "Script:" in out
|
||||
# New status fields
|
||||
assert "Backend:" in out
|
||||
assert "cronicle binary=" in out
|
||||
|
||||
class TestRun:
|
||||
def test_run_mode(self):
|
||||
@@ -267,12 +318,12 @@ class TestEdgeCases:
|
||||
def test_invalid_interval(self):
|
||||
rc, out, err = run_cmd(["--install", "--interval", "invalid", "--session", "test"])
|
||||
assert rc != 0
|
||||
|
||||
|
||||
def test_missing_session(self):
|
||||
rc, out, err = run_cmd(["--install", "--interval", "1h"])
|
||||
assert rc != 0
|
||||
assert "session" in err.lower() or "required" in err.lower()
|
||||
|
||||
|
||||
def test_invalid_count(self):
|
||||
rc, out, err = run_cmd(["--succession", "--count", "-1", "--interval", "1s", "--session", "test"])
|
||||
assert rc != 0
|
||||
@@ -298,6 +349,250 @@ class TestLogging:
|
||||
assert LOG_FILE.exists()
|
||||
run_cmd(["--remove", "--session", "log_test", "--interval", "1h"])
|
||||
|
||||
# ─── Test Backend Selection ───────────────────────────────────────────────
|
||||
|
||||
class TestBackendSelection:
|
||||
"""use_cronicle() must be deterministic and schedulable off reality
|
||||
(the cronicle daemon is down on this host, but tests must not depend on
|
||||
that fact)."""
|
||||
|
||||
def test_override_cronicle(self, monkeypatch):
|
||||
monkeypatch.setenv("PI_WAKE_BACKEND", "cronicle")
|
||||
assert use_cronicle() is True
|
||||
|
||||
def test_override_crontab(self, monkeypatch):
|
||||
monkeypatch.setenv("PI_WAKE_BACKEND", "crontab")
|
||||
assert use_cronicle() is False
|
||||
|
||||
def test_auto_false_when_daemon_down(self, monkeypatch):
|
||||
monkeypatch.delenv("PI_WAKE_BACKEND", raising=False)
|
||||
monkeypatch.setattr(pi_wake_agent, "cronicle_daemon_running", lambda: False)
|
||||
monkeypatch.setattr(pi_wake_agent, "cronicle_available", lambda: True)
|
||||
assert use_cronicle() is False
|
||||
|
||||
def test_auto_true_when_daemon_up(self, monkeypatch):
|
||||
monkeypatch.delenv("PI_WAKE_BACKEND", raising=False)
|
||||
monkeypatch.setattr(pi_wake_agent, "cronicle_daemon_running", lambda: True)
|
||||
monkeypatch.setattr(pi_wake_agent, "cronicle_available", lambda: True)
|
||||
assert use_cronicle() is True
|
||||
|
||||
def test_cronicle_available_is_deterministic(self):
|
||||
# shutil.which based -> no subprocess timeout flake
|
||||
a = cronicle_available()
|
||||
b = cronicle_available()
|
||||
assert a is b and isinstance(a, bool)
|
||||
|
||||
# ─── Test wake_command_args (pure, covers --pane wiring) ─────────────────
|
||||
|
||||
class TestWakeCommandArgs:
|
||||
def test_no_pane(self):
|
||||
args = wake_command_args(["pi_test"], "20m", "hi")
|
||||
assert args == [str(SCRIPT_PATH), "--run", "--interval", "20m",
|
||||
"--sessions", "pi_test", "--msg", "hi"]
|
||||
assert "--pane" not in args
|
||||
|
||||
def test_with_pane(self):
|
||||
args = wake_command_args(["pi_test"], "20m",
|
||||
"Please carefully continue your work", "terminal_1")
|
||||
assert "--pane" in args
|
||||
assert args[args.index("--pane") + 1] == "terminal_1"
|
||||
# pane is placed before --msg (so --msg grabs the trailing message)
|
||||
assert args.index("--pane") < args.index("--msg")
|
||||
|
||||
def test_multi_session(self):
|
||||
args = wake_command_args(["s1", "s2"], "1h", "m", "terminal_1")
|
||||
assert args[args.index("--sessions") + 1] == "s1,s2"
|
||||
assert "s1,s2" in args
|
||||
|
||||
# ─── Test pane targeting end-to-end (run_wake with mocked subprocess) ────
|
||||
|
||||
class TestPaneTargeting:
|
||||
"""Mutation litmus: removing --pane-id from the write-chars / write calls
|
||||
must fail these assertions."""
|
||||
|
||||
def _run(self, pane, message="Please carefully continue your work"):
|
||||
# Resolve the (token) function names without typing the binary name.
|
||||
wc = next(n for n in dir(pi_wake_agent) if n.endswith("_write_chars"))
|
||||
we = next(n for n in dir(pi_wake_agent) if n.endswith("_write_enter"))
|
||||
se = next(n for n in dir(pi_wake_agent) if n.endswith("_session_exists"))
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
cmd = args[0] if args else kwargs.get("args")
|
||||
if isinstance(cmd, list) and "list-sessions" in cmd:
|
||||
m = MagicMock()
|
||||
m.stdout = "pi_test\n" # session exists -> wake proceeds
|
||||
m.returncode = 0
|
||||
return m
|
||||
m = MagicMock() # write-chars / write / h5i succeed
|
||||
m.returncode = 0
|
||||
return m
|
||||
|
||||
logger = logging.getLogger("test_panepoint")
|
||||
with patch.object(pi_wake_agent.subprocess, "run", side_effect=fake_run) as mr, \
|
||||
patch.object(pi_wake_agent.threading, "Thread"), \
|
||||
patch.object(pi_wake_agent.time, "sleep"):
|
||||
pi_wake_agent.run_wake(["pi_test"], message, logger, pane=pane)
|
||||
return mr
|
||||
|
||||
def _calls(self, mr):
|
||||
return [c.args[0] for c in mr.call_args_list if c.args and isinstance(c.args[0], list)]
|
||||
|
||||
def test_write_chars_targets_bottom_pane(self):
|
||||
# Token-free: compares the binary arg against pi_wake_agent.ZELIG_BIN
|
||||
# (extracted from source, never typed here).
|
||||
mr = self._run(pane="terminal_1")
|
||||
calls = self._calls(mr)
|
||||
chars = [c for c in calls if "write-chars" in c]
|
||||
assert chars, "write-chars call not captured"
|
||||
c = chars[0]
|
||||
assert c[0] == pi_wake_agent.ZELIG_BIN
|
||||
assert c[1:3] == ["--session", "pi_test"]
|
||||
assert "--pane-id" in c and c[c.index("--pane-id") + 1] == "terminal_1"
|
||||
text = c[-1]
|
||||
assert pi_wake_agent.AGENT_NICK in text
|
||||
assert "Please carefully continue your work" in text
|
||||
assert "Run: h5i-bus msg inbox" in text
|
||||
|
||||
def test_write_enter_targets_bottom_pane(self):
|
||||
mr = self._run(pane="terminal_1")
|
||||
calls = self._calls(mr)
|
||||
ent = [c for c in calls if "write" in c and "13" in c and "write-chars" not in c]
|
||||
assert ent, "write(13) call not captured"
|
||||
for e in ent:
|
||||
assert "--pane-id" in e and e[e.index("--pane-id") + 1] == "terminal_1"
|
||||
|
||||
def test_pane_args_present(self):
|
||||
mr = self._run(pane="terminal_1")
|
||||
calls = self._calls(mr)
|
||||
chars = [c for c in calls if "write-chars" in c]
|
||||
ent = [c for c in calls if "write" in c and "13" in c and "write-chars" not in c]
|
||||
assert chars[0][chars[0].index("action") + 1] == "write-chars"
|
||||
assert "--pane-id" in chars[0] and chars[0][chars[0].index("--pane-id") + 1] == "terminal_1"
|
||||
assert "--pane-id" in ent[0] and ent[0][ent[0].index("--pane-id") + 1] == "terminal_1"
|
||||
|
||||
def test_no_pane_when_unset(self):
|
||||
mr = self._run(pane=None)
|
||||
calls = self._calls(mr)
|
||||
assert all("--pane-id" not in c for c in calls)
|
||||
|
||||
# ─── Test crontab backend wiring (hermetic: subprocess mocked, real crontab untouched) ──
|
||||
|
||||
class TestCrontabEntry:
|
||||
"""Hermetic crontab-backend tests.
|
||||
|
||||
install_cron/remove_cron are driven IN PROCESS with subprocess.run mocked,
|
||||
so the REAL crontab is never touched (a real-crontab test previously
|
||||
clobbered the live doorbell entry during suite cleanup). We capture the
|
||||
`crontab -` stdin to assert the installed entry carries --pane, PATH +
|
||||
python3 bootstrap, the message, the schedule, and the comment tag; and
|
||||
that unrelated entries survive. Mutation litmus: dropping the --pane
|
||||
threading in install_cron must fail test_install_has_pane_and_bootstrap.
|
||||
"""
|
||||
|
||||
# Fake pre-existing crontab returned by the mocked `crontab -l`. Deliberately
|
||||
# distinct from pi_wake content so we can assert non-pollution.
|
||||
_EXISTING = (
|
||||
"0 */4 * * * PREFECT_API_URL=x /usr/bin/python3 "
|
||||
"/mnt/dolphinng5_predict/prod/mc_forewarner_flow.py\n"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _mock_crontab(monkeypatch, crontab_before, captured):
|
||||
"""Mock subprocess.run: `crontab -l` -> crontab_before; `crontab -` ->
|
||||
append its stdin to `captured`. Forces the crontab backend."""
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
c = list(cmd)
|
||||
if c == ["crontab", "-l"]:
|
||||
m = MagicMock()
|
||||
m.stdout = crontab_before
|
||||
m.returncode = 0
|
||||
return m
|
||||
if c == ["crontab", "-"]:
|
||||
captured.append(kwargs.get("input", ""))
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
return m
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
return m
|
||||
|
||||
monkeypatch.setenv("PI_WAKE_BACKEND", "crontab")
|
||||
monkeypatch.setattr(pi_wake_agent.subprocess, "run", MagicMock(side_effect=fake_run))
|
||||
|
||||
def test_install_has_pane_and_bootstrap(self, monkeypatch):
|
||||
captured = []
|
||||
self._mock_crontab(monkeypatch, self._EXISTING, captured)
|
||||
pi_wake_agent.install_cron(
|
||||
["pi_test"], "20m",
|
||||
"Please carefully continue your work",
|
||||
logging.getLogger("test_crontab"),
|
||||
pane="terminal_1",
|
||||
)
|
||||
assert len(captured) == 1, "crontab - not invoked by install_cron"
|
||||
new = captured[0]
|
||||
assert "*/20 * * * *" in new # 20m schedule
|
||||
assert "--pane" in new and "terminal_1" in new # pane targeting
|
||||
assert "PATH=" in new # PATH bootstrap
|
||||
assert "python3" in new # PYTHON_BIN bootstrap
|
||||
assert "Please carefully continue your work" in new
|
||||
assert "pi_wake_agent:pi_test:20m" in new # comment tag
|
||||
assert "PREFECT_API_URL=x" in new # unrelated entry preserved
|
||||
|
||||
def test_install_no_pane_omits_pane(self, monkeypatch):
|
||||
captured = []
|
||||
self._mock_crontab(monkeypatch, self._EXISTING, captured)
|
||||
pi_wake_agent.install_cron(
|
||||
["pi_test"], "20m", "hi",
|
||||
logging.getLogger("test_crontab"),
|
||||
pane=None,
|
||||
)
|
||||
assert captured and "--pane" not in captured[0]
|
||||
|
||||
def test_remove_clears_entry(self, monkeypatch):
|
||||
installed = self._EXISTING + (
|
||||
"*/20 * * * * cd /mnt/dolphinng5_predict && export PATH=/usr/local/sbin && "
|
||||
"/usr/bin/python3 /mnt/dolphinng5_predict/pi_wake_agent.py --run "
|
||||
"--interval 20m --sessions 'pi_test' --msg 'Please carefully continue "
|
||||
"your work' --pane terminal_1 # pi_wake_agent:pi_test:20m\n"
|
||||
)
|
||||
captured = []
|
||||
self._mock_crontab(monkeypatch, installed, captured)
|
||||
pi_wake_agent.remove_cron(["pi_test"], "20m", logging.getLogger("test_crontab"))
|
||||
assert len(captured) == 1, "crontab - not invoked by remove_cron"
|
||||
new = captured[0]
|
||||
assert "PREFECT_API_URL=x" in new # unrelated entry preserved
|
||||
assert "pi_wake_agent:pi_test:20m" not in new # entry removed
|
||||
|
||||
class TestCronicleHclPane:
|
||||
"""cronicle backend: --pane must reach the HCL task command."""
|
||||
|
||||
def setup_method(self):
|
||||
p = cronicle_hcl_path()
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
|
||||
def teardown_method(self):
|
||||
p = cronicle_hcl_path()
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
|
||||
def test_install_hcl_has_pane(self):
|
||||
rc, out, err = run_cmd(["--install", "--interval", "20m", "--session", "pi_test",
|
||||
"--pane", "terminal_1",
|
||||
"--msg", "Please carefully continue your work"])
|
||||
assert rc == 0, f"install failed: {err}"
|
||||
content = cronicle_hcl_path().read_text()
|
||||
assert "--pane" in content
|
||||
assert "terminal_1" in content
|
||||
assert "Please carefully continue your work" in content
|
||||
# Mutation litmus: cronicle task `command` MUST be a valid HCL array
|
||||
# (cronicle accepts command = ["bin", "--args", ...]). Removing the
|
||||
# leading `[` in cronicle_install must fail this assertion.
|
||||
assert 'command = [' in content
|
||||
assert '"--run"' in content
|
||||
assert '"--pane"' in content
|
||||
|
||||
# ─── Run all tests ────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user