#!/usr/bin/env python3 """ 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 import subprocess 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, LOG_MAX_FILES, CRON_COMMENT_PREFIX, AGENT_NICK, H5I_AGENT, H5I_BUS_ROOT, 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: def test_hours(self): 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") with pytest.raises(ValueError): parse_interval("1x") with pytest.raises(ValueError): parse_interval("") # ─── Test interval_to_cron ─────────────────────────────────────────────── class TestIntervalToCron: def test_hours(self): 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") # ─── Test interval_to_human ────────────────────────────────────────────── 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" # ─── Test cron_comment ─────────────────────────────────────────────────── class TestCronComment: def test_single_session(self): 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" # ─── Test parse_sessions ───────────────────────────────────────────────── 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"] # ─── Test main functions via subprocess ────────────────────────────────── SCRIPT = "/mnt/dolphinng5_predict/pi_wake_agent.py" def _make_stub_bin(): """Create a tmp bin dir with stub `zjet` (named via ZELIG_BIN) + `h5i`. Hermetic integration tests must never invoke the real zjet/terminal or the real h5i bus. The zjet stub is named after ``pi_wake_agent.ZELIG_BIN`` (read from the module, never typed here) so the script's own ``subprocess.run([ZELIG_BIN, ...])`` resolves to the stub when this dir leads ``PATH``. The h5i stub is a no-op (``h5i`` is a short, typeable name). """ bin_dir = Path(tempfile.mkdtemp(prefix="pi_wake_stub_")) zjet_stub = bin_dir / pi_wake_agent.ZELIG_BIN zjet_stub.write_text( "#!/usr/bin/env python3\n" "import sys\n" "args = sys.argv[1:]; session = action = ''\n" "i = 0\n" "while i < len(args):\n" " a = args[i]\n" " if a == '--session' and i + 1 < len(args): i += 1; session = args[i]\n" " elif a == 'action' and i + 1 < len(args): i += 1; action = args[i]\n" " i += 1\n" "# Minimal zjet stub for hermetic integration tests.\n" "if action == 'list-sessions':\n" " print('test_session')\n" "elif action == 'dump-screen':\n" " print(f'{session} 50.0%/1.0M (auto) (zjet-stub) test_session')\n" "# write-chars / write : no-op (fast, hermetic)\n" "sys.exit(0)\n", ) zjet_stub.chmod(0o755) h5i_stub = bin_dir / "h5i" h5i_stub.write_text("#!/usr/bin/env python3\nimport sys\nsys.exit(0)\n") h5i_stub.chmod(0o755) return bin_dir _STUB_BIN = _make_stub_bin() def _hermetic_env() -> dict: """Explicit env for run_cmd subprocesses: cronicle (tmp-hcl) backend + stub zjet/h5i. Forces the real crontab / cronicle.hcl / zjet / h5i bus to NEVER be touched by integration tests. PI_WAKE_CRONICLE_CONFIG is INHERITED (not overridden) from the autouse fixture's per-test tmp path, so the subprocess's CRONICLE_CONFIG constant matches the path the test asserts against. """ return { **os.environ, # inherit autouse tmp hcl path "PI_WAKE_BACKEND": "cronicle", # cronicle (tmp) backend, never crontab "PATH": ":".join(p for p in [str(_STUB_BIN), os.environ.get("PATH", "")] if p), } def run_cmd(args, env=None, timeout=30): """Run the script and return (returncode, stdout, stderr). Defaults to a fully hermetic env (stub zjet-on-PATH via ZELIG_BIN + stub h5i + cronicle-tmp backend) so integration tests never touch the real crontab, cronicle.hcl, zjet, or h5i bus. """ if env is None: env = _hermetic_env() result = subprocess.run( [sys.executable, SCRIPT] + args, capture_output=True, text=True, timeout=timeout, env=env, ) return result.returncode, result.stdout, result.stderr # ─── Integration Tests ────────────────────────────────────────────────── class TestHelp: def test_help(self): rc, out, err = run_cmd(["--help"]) assert rc == 0 assert "pi_wake_agent.py" in out assert "--install" in out assert "--once" in out 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 setup_method(self): # Clear cronicle config (uses the per-test tmp path) config_path = cronicle_hcl_path() if config_path.exists(): config_path.unlink() # Doctrine: do NOT touch the real crontab here. The autouse fixture pins # the cronicle (tmp-hcl) backend, so the system crontab is irrelevant; # mutating it (`grep -v pi_wake_agent | crontab -`) silently deleted the # live pi_test doorbell during suite runs. def test_list_no_cron(self): rc, out, err = run_cmd(["--list"]) assert rc == 0 assert "(none)" in out or "pi_wake_agent cron entries" in out class TestInstallRemove: def setup_method(self): # Clear cronicle config (per-test tmp path) config_path = cronicle_hcl_path() if config_path.exists(): config_path.unlink() # Doctrine: no real crontab mutation (cronicle tmp-hcl backend pinned by # the autouse fixture). Previously this ran `grep -v pi_wake_agent | crontab -`, # which deleted the live pi_test doorbell during suite runs. def teardown_method(self): config_path = cronicle_hcl_path() if config_path.exists(): config_path.unlink() # Doctrine: no real crontab mutation (cronicle tmp-hcl backend pinned). def test_install_and_list(self): rc, out, err = run_cmd(["--install", "--interval", "1h", "--session", "test_session", "--msg", "test"]) 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, f"install failed: {err}" rc, out, err = run_cmd(["--list"]) assert rc == 0 assert "s1_s2" in out # cronicle format: pi_wake_s1_s2_30m run_cmd(["--remove", "--sessions", "s1,s2", "--interval", "30m"]) class TestValidate: def test_validate_existing(self): # The test session might not exist, so we test the command runs rc, out, err = run_cmd(["--validate", "--session", "pi_test"]) assert rc == 0 # Should not crash class TestOnce: def test_once_short(self): # Use a very short interval rc, out, err = run_cmd(["--once", "--interval", "1s", "--session", "test_session", "--msg", "quick test"], timeout=10) assert rc == 0 # Check log file for the message time.sleep(2) log_content = Path("/tmp/pi_wake_agent.log").read_text() assert "One-shot timer set" in log_content class TestSuccession: def test_succession_short(self): # Test succession mode with very short intervals rc, out, err = run_cmd([ "--succession", "--count", "2", "--interval", "1s", "--session", "test_session", "--msg", "succession test" ], timeout=60) assert rc == 0 # Check logs time.sleep(3) 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 assert rc != 0 or "error" in err.lower() class TestStatus: def test_status(self): rc, out, err = run_cmd(["--status"]) 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): # This is the internal mode called by cron rc, out, err = run_cmd(["--run", "--session", "test_session", "--msg", "test"]) assert rc == 0 # Should succeed even if session doesn't exist 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 class TestSessionParsing: def test_multiple_session_flags(self): rc, out, err = run_cmd(["--install", "--interval", "1h", "--session", "s1", "--session", "s2", "--msg", "test"]) assert rc == 0 run_cmd(["--remove", "--session", "s1", "--interval", "1h"]) run_cmd(["--remove", "--session", "s2", "--interval", "1h"]) class TestRemove: def test_remove_nonexistent(self): # Should not crash rc, out, err = run_cmd(["--remove", "--session", "nonexistent", "--interval", "1h"]) assert rc == 0 # ─── Test Logging ───────────────────────────────────────────────────────── class TestLogging: def test_log_file_created(self): run_cmd(["--install", "--interval", "1h", "--session", "log_test", "--msg", "test"]) 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 class TestContextOccupancy: """Hermetic: parse_context_occupancy + context_warning never call real zjet. Mutation litmus: breaking CONTEXT_RE (the capture group) or swapping CONTEXT_WARN_PCT/CONTEXT_CRIT_PCT must fail these assertions. """ @staticmethod def _mock_stdout(monkeypatch, stdout): m = MagicMock() m.returncode = 0 m.stdout = stdout monkeypatch.setattr( pi_wake_agent.subprocess, "run", MagicMock(side_effect=lambda *a, **k: m), ) def test_parses_pct(self, monkeypatch): self._mock_stdout(monkeypatch, "... 91.3%/1.0M (auto) ...") assert pi_wake_agent.parse_context_occupancy("pi_test", "terminal_1") == 91.3 def test_parses_integer_pct(self, monkeypatch): self._mock_stdout(monkeypatch, "100%/1.0M") assert pi_wake_agent.parse_context_occupancy("pi_test") == 100.0 def test_no_match_returns_none(self, monkeypatch): self._mock_stdout(monkeypatch, "no percentage here") assert pi_wake_agent.parse_context_occupancy("pi_test") is None def test_subprocess_failure_returns_none(self, monkeypatch): monkeypatch.setattr( pi_wake_agent.subprocess, "run", MagicMock(side_effect=FileNotFoundError), ) assert pi_wake_agent.parse_context_occupancy("pi_test") is None def test_warning_critical_at_95(self, monkeypatch): self._mock_stdout(monkeypatch, "95.0%/1.0M (auto) ...") w = pi_wake_agent.context_warning("pi_test", "terminal_1") assert "HEADS UP" in w and "95" in w def test_warning_warn_at_87(self, monkeypatch): self._mock_stdout(monkeypatch, "87.0%/1.0M (auto)") w = pi_wake_agent.context_warning("pi_test") assert "heads up" in w and "87" in w def test_warning_none_when_low(self, monkeypatch): self._mock_stdout(monkeypatch, "40.0%/1.0M (auto)") assert pi_wake_agent.context_warning("pi_test") == "" def test_warning_none_when_no_pct(self, monkeypatch): self._mock_stdout(monkeypatch, "no status bar") assert pi_wake_agent.context_warning("pi_test") == "" # ─── 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", dump_screen="pi_test\n"): # 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 if isinstance(cmd, list) and "dump-screen" in cmd: m = MagicMock() # pi context-occupancy figure (testable) m.stdout = dump_screen 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) def test_context_warning_appended_to_steer(self): # When pi's context is critical (>=95%), the steer must carry a heads-up. mr = self._run(pane="terminal_1", dump_screen="95.0%/1.0M (auto) ...") calls = self._calls(mr) text = [c for c in calls if "write-chars" in c][0][-1] assert "HEADS UP" in text and "95" in text assert "Run: h5i-bus msg inbox" in text # steer-pointer must remain def test_context_warning_omitted_when_low(self): # Below the threshold the steer stays clean (no false panic). mr = self._run(pane="terminal_1", dump_screen="40.0%/1.0M (auto)") calls = self._calls(mr) text = [c for c in calls if "write-chars" in c][0][-1] assert "HEADS UP" not in text assert "heads up" not in text # ─── 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 class TestHermeticity: """Doctrine: integration tests must NOT mutate the live crontab/zjet/h5i. run_cmd now defaults to a hermetic env (stub zjet-on-PATH + stub h5i + cronicle-tmp backend). These assertions are the mutation litmus for that: if a future change drops the hermetic env override, the live crontab would be rewritten and test_live_crontab_unchanged_across_run must fail. """ @staticmethod def _live_crontab(): try: r = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=5) return r.stdout if r.returncode == 0 else "" except Exception: return "" def test_live_crontab_unchanged_across_run(self): before = self._live_crontab() rc, out, err = run_cmd([ "--succession", "--count", "2", "--interval", "1s", "--session", "test_session", "--msg", "hermetic guard" ], timeout=60) after = self._live_crontab() assert before == after, f"integration run mutated the live crontab!\ndiff:\n{before}\n---\n{after}" assert rc == 0 assert "SUCCESSION COMPLETE" in Path("/tmp/pi_wake_agent.log").read_text() def test_hermetic_stubs_override_real_zjet(self): # The zjet stub (named via ZELIG_BIN) leads the hermetic PATH and must # emit the fake status bar so context-occupancy parsing is exercised # with NO real zjet invocation. env = _hermetic_env() stub_dir = env["PATH"].split(":")[0] stub = Path(stub_dir) / pi_wake_agent.ZELIG_BIN assert stub.exists(), f"zjet stub not found at {stub}" r = subprocess.run([str(stub), "--session", "pi_test", "action", "dump-screen", "--pane-id", "terminal_1"], capture_output=True, text=True, timeout=5) assert "50.0%/1.0M" in r.stdout # h5i stub is a no-op (no real bus write) h5i = Path(stub_dir) / "h5i" assert h5i.exists() # ─── Run all tests ──────────────────────────────────────────────────────── if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"])