Closes bd-3ny. Added mousedown listener that dismisses the dropdown when clicking outside both the dropdown and textarea. Uses early return to avoid registering listeners when dropdown is already closed.
362 lines
15 KiB
Python
362 lines
15 KiB
Python
"""Tests for mixins/discovery.py edge cases.
|
|
|
|
Unit tests for Codex session discovery and pane matching.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
from amc_server.mixins.discovery import SessionDiscoveryMixin
|
|
from amc_server.mixins.parsing import SessionParsingMixin
|
|
|
|
|
|
class DummyDiscoveryHandler(SessionDiscoveryMixin, SessionParsingMixin):
|
|
"""Minimal handler for testing discovery mixin."""
|
|
pass
|
|
|
|
|
|
class TestGetCodexPaneInfo(unittest.TestCase):
|
|
"""Tests for _get_codex_pane_info edge cases."""
|
|
|
|
def setUp(self):
|
|
self.handler = DummyDiscoveryHandler()
|
|
# Clear cache before each test
|
|
from amc_server.context import _codex_pane_cache
|
|
_codex_pane_cache["expires"] = 0
|
|
_codex_pane_cache["pid_info"] = {}
|
|
_codex_pane_cache["cwd_map"] = {}
|
|
|
|
def test_pgrep_failure_returns_empty(self):
|
|
failed = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
|
|
|
|
with patch("amc_server.mixins.discovery.subprocess.run", return_value=failed):
|
|
pid_info, cwd_map = self.handler._get_codex_pane_info()
|
|
|
|
self.assertEqual(pid_info, {})
|
|
self.assertEqual(cwd_map, {})
|
|
|
|
def test_no_codex_processes_returns_empty(self):
|
|
no_results = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
|
|
with patch("amc_server.mixins.discovery.subprocess.run", return_value=no_results):
|
|
pid_info, cwd_map = self.handler._get_codex_pane_info()
|
|
|
|
self.assertEqual(pid_info, {})
|
|
self.assertEqual(cwd_map, {})
|
|
|
|
def test_extracts_zellij_env_vars(self):
|
|
pgrep_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="12345\n", stderr="")
|
|
ps_result = subprocess.CompletedProcess(
|
|
args=[], returncode=0,
|
|
stdout="codex ZELLIJ_PANE_ID=7 ZELLIJ_SESSION_NAME=myproject",
|
|
stderr=""
|
|
)
|
|
lsof_result = subprocess.CompletedProcess(
|
|
args=[], returncode=0,
|
|
stdout="p12345\nn/Users/test/project",
|
|
stderr=""
|
|
)
|
|
|
|
def mock_run(args, **kwargs):
|
|
if args[0] == "pgrep":
|
|
return pgrep_result
|
|
elif args[0] == "ps":
|
|
return ps_result
|
|
elif args[0] == "lsof":
|
|
return lsof_result
|
|
return subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
|
|
|
|
with patch("amc_server.mixins.discovery.subprocess.run", side_effect=mock_run):
|
|
pid_info, cwd_map = self.handler._get_codex_pane_info()
|
|
|
|
self.assertIn("12345", pid_info)
|
|
self.assertEqual(pid_info["12345"]["pane_id"], "7")
|
|
self.assertEqual(pid_info["12345"]["zellij_session"], "myproject")
|
|
|
|
def test_cache_used_when_fresh(self):
|
|
from amc_server.context import _codex_pane_cache
|
|
_codex_pane_cache["pid_info"] = {"cached": {"pane_id": "1", "zellij_session": "s"}}
|
|
_codex_pane_cache["cwd_map"] = {"/cached/path": {"session": "s", "pane_id": "1"}}
|
|
_codex_pane_cache["expires"] = time.time() + 100
|
|
|
|
# Should not call subprocess
|
|
with patch("amc_server.mixins.discovery.subprocess.run") as mock_run:
|
|
pid_info, cwd_map = self.handler._get_codex_pane_info()
|
|
|
|
mock_run.assert_not_called()
|
|
self.assertEqual(pid_info, {"cached": {"pane_id": "1", "zellij_session": "s"}})
|
|
|
|
def test_timeout_handled_gracefully(self):
|
|
with patch("amc_server.mixins.discovery.subprocess.run",
|
|
side_effect=subprocess.TimeoutExpired("cmd", 2)):
|
|
pid_info, cwd_map = self.handler._get_codex_pane_info()
|
|
|
|
self.assertEqual(pid_info, {})
|
|
self.assertEqual(cwd_map, {})
|
|
|
|
|
|
class TestMatchCodexSessionToPane(unittest.TestCase):
|
|
"""Tests for _match_codex_session_to_pane edge cases."""
|
|
|
|
def setUp(self):
|
|
self.handler = DummyDiscoveryHandler()
|
|
|
|
def test_lsof_match_found(self):
|
|
"""When lsof finds a PID with the session file open, use that match."""
|
|
pid_info = {
|
|
"12345": {"pane_id": "7", "zellij_session": "project"},
|
|
}
|
|
cwd_map = {}
|
|
|
|
lsof_result = subprocess.CompletedProcess(
|
|
args=[], returncode=0, stdout="12345\n", stderr=""
|
|
)
|
|
|
|
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
|
|
session, pane = self.handler._match_codex_session_to_pane(
|
|
Path("/some/session.jsonl"), "/project", pid_info, cwd_map
|
|
)
|
|
|
|
self.assertEqual(session, "project")
|
|
self.assertEqual(pane, "7")
|
|
|
|
def test_cwd_fallback_when_lsof_fails(self):
|
|
"""When lsof doesn't find a match, fall back to CWD matching."""
|
|
pid_info = {}
|
|
cwd_map = {
|
|
"/home/user/project": {"session": "myproject", "pane_id": "3"},
|
|
}
|
|
|
|
lsof_result = subprocess.CompletedProcess(
|
|
args=[], returncode=1, stdout="", stderr=""
|
|
)
|
|
|
|
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
|
|
session, pane = self.handler._match_codex_session_to_pane(
|
|
Path("/some/session.jsonl"), "/home/user/project", pid_info, cwd_map
|
|
)
|
|
|
|
self.assertEqual(session, "myproject")
|
|
self.assertEqual(pane, "3")
|
|
|
|
def test_no_match_returns_empty_strings(self):
|
|
pid_info = {}
|
|
cwd_map = {}
|
|
|
|
lsof_result = subprocess.CompletedProcess(
|
|
args=[], returncode=1, stdout="", stderr=""
|
|
)
|
|
|
|
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
|
|
session, pane = self.handler._match_codex_session_to_pane(
|
|
Path("/some/session.jsonl"), "/unmatched/path", pid_info, cwd_map
|
|
)
|
|
|
|
self.assertEqual(session, "")
|
|
self.assertEqual(pane, "")
|
|
|
|
def test_cwd_normalized_for_matching(self):
|
|
"""CWD paths should be normalized for comparison."""
|
|
pid_info = {}
|
|
cwd_map = {
|
|
"/home/user/project": {"session": "proj", "pane_id": "1"},
|
|
}
|
|
|
|
lsof_result = subprocess.CompletedProcess(
|
|
args=[], returncode=1, stdout="", stderr=""
|
|
)
|
|
|
|
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
|
|
# Session CWD has trailing slash and extra dots
|
|
session, pane = self.handler._match_codex_session_to_pane(
|
|
Path("/some/session.jsonl"), "/home/user/./project/", pid_info, cwd_map
|
|
)
|
|
|
|
self.assertEqual(session, "proj")
|
|
|
|
def test_empty_session_cwd_no_match(self):
|
|
pid_info = {}
|
|
cwd_map = {"/some/path": {"session": "s", "pane_id": "1"}}
|
|
|
|
lsof_result = subprocess.CompletedProcess(
|
|
args=[], returncode=1, stdout="", stderr=""
|
|
)
|
|
|
|
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
|
|
session, pane = self.handler._match_codex_session_to_pane(
|
|
Path("/some/session.jsonl"), "", pid_info, cwd_map
|
|
)
|
|
|
|
self.assertEqual(session, "")
|
|
self.assertEqual(pane, "")
|
|
|
|
|
|
class TestDiscoverActiveCodexSessions(unittest.TestCase):
|
|
"""Tests for _discover_active_codex_sessions edge cases."""
|
|
|
|
def setUp(self):
|
|
self.handler = DummyDiscoveryHandler()
|
|
# Clear caches
|
|
from amc_server.context import _codex_transcript_cache, _dismissed_codex_ids
|
|
_codex_transcript_cache.clear()
|
|
_dismissed_codex_ids.clear()
|
|
|
|
def test_skips_when_codex_sessions_dir_missing(self):
|
|
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", Path("/nonexistent")):
|
|
# Should not raise
|
|
self.handler._discover_active_codex_sessions()
|
|
|
|
def test_skips_old_files(self):
|
|
"""Files older than CODEX_ACTIVE_WINDOW should be skipped."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
codex_dir = Path(tmpdir)
|
|
sessions_dir = Path(tmpdir) / "sessions"
|
|
sessions_dir.mkdir()
|
|
|
|
# Create an old transcript file
|
|
old_file = codex_dir / "old-12345678-1234-1234-1234-123456789abc.jsonl"
|
|
old_file.write_text('{"type": "session_meta", "payload": {"cwd": "/test"}}\n')
|
|
# Set mtime to 2 hours ago
|
|
old_time = time.time() - 7200
|
|
os.utime(old_file, (old_time, old_time))
|
|
|
|
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
|
|
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
|
|
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
|
|
self.handler._discover_active_codex_sessions()
|
|
|
|
# Should not have created a session file
|
|
self.assertEqual(list(sessions_dir.glob("*.json")), [])
|
|
|
|
def test_skips_dismissed_sessions(self):
|
|
"""Sessions in _dismissed_codex_ids should be skipped."""
|
|
from amc_server.context import _dismissed_codex_ids
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
codex_dir = Path(tmpdir)
|
|
sessions_dir = Path(tmpdir) / "sessions"
|
|
sessions_dir.mkdir()
|
|
|
|
# Create a recent transcript file
|
|
session_id = "12345678-1234-1234-1234-123456789abc"
|
|
transcript = codex_dir / f"session-{session_id}.jsonl"
|
|
transcript.write_text('{"type": "session_meta", "payload": {"cwd": "/test"}}\n')
|
|
|
|
# Mark as dismissed
|
|
_dismissed_codex_ids[session_id] = True
|
|
|
|
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
|
|
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
|
|
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
|
|
self.handler._discover_active_codex_sessions()
|
|
|
|
# Should not have created a session file
|
|
self.assertEqual(list(sessions_dir.glob("*.json")), [])
|
|
|
|
def test_skips_non_uuid_filenames(self):
|
|
"""Files without a UUID in the name should be skipped."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
codex_dir = Path(tmpdir)
|
|
sessions_dir = Path(tmpdir) / "sessions"
|
|
sessions_dir.mkdir()
|
|
|
|
# Create a file without a UUID
|
|
no_uuid = codex_dir / "random-name.jsonl"
|
|
no_uuid.write_text('{"type": "session_meta", "payload": {"cwd": "/test"}}\n')
|
|
|
|
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
|
|
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
|
|
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
|
|
self.handler._discover_active_codex_sessions()
|
|
|
|
self.assertEqual(list(sessions_dir.glob("*.json")), [])
|
|
|
|
def test_skips_non_session_meta_first_line(self):
|
|
"""Files without session_meta as first line should be skipped."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
codex_dir = Path(tmpdir)
|
|
sessions_dir = Path(tmpdir) / "sessions"
|
|
sessions_dir.mkdir()
|
|
|
|
session_id = "12345678-1234-1234-1234-123456789abc"
|
|
transcript = codex_dir / f"session-{session_id}.jsonl"
|
|
# First line is not session_meta
|
|
transcript.write_text('{"type": "response_item", "payload": {}}\n')
|
|
|
|
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
|
|
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
|
|
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
|
|
self.handler._discover_active_codex_sessions()
|
|
|
|
self.assertEqual(list(sessions_dir.glob("*.json")), [])
|
|
|
|
def test_creates_session_file_for_valid_transcript(self):
|
|
"""Valid recent transcripts should create session files."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
codex_dir = Path(tmpdir)
|
|
sessions_dir = Path(tmpdir) / "sessions"
|
|
sessions_dir.mkdir()
|
|
|
|
session_id = "12345678-1234-1234-1234-123456789abc"
|
|
transcript = codex_dir / f"session-{session_id}.jsonl"
|
|
transcript.write_text(json.dumps({
|
|
"type": "session_meta",
|
|
"payload": {"cwd": "/test/project", "timestamp": "2024-01-01T00:00:00Z"}
|
|
}) + "\n")
|
|
|
|
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
|
|
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
|
|
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
|
|
self.handler._match_codex_session_to_pane = MagicMock(return_value=("proj", "5"))
|
|
self.handler._get_cached_context_usage = MagicMock(return_value=None)
|
|
self.handler._discover_active_codex_sessions()
|
|
|
|
session_file = sessions_dir / f"{session_id}.json"
|
|
self.assertTrue(session_file.exists())
|
|
|
|
data = json.loads(session_file.read_text())
|
|
self.assertEqual(data["session_id"], session_id)
|
|
self.assertEqual(data["agent"], "codex")
|
|
self.assertEqual(data["project"], "project")
|
|
self.assertEqual(data["zellij_session"], "proj")
|
|
self.assertEqual(data["zellij_pane"], "5")
|
|
|
|
def test_determines_status_by_file_age(self):
|
|
"""Recent files should be 'active', older ones 'done'."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
codex_dir = Path(tmpdir)
|
|
sessions_dir = Path(tmpdir) / "sessions"
|
|
sessions_dir.mkdir()
|
|
|
|
session_id = "12345678-1234-1234-1234-123456789abc"
|
|
transcript = codex_dir / f"session-{session_id}.jsonl"
|
|
transcript.write_text(json.dumps({
|
|
"type": "session_meta",
|
|
"payload": {"cwd": "/test"}
|
|
}) + "\n")
|
|
|
|
# Set mtime to 3 minutes ago (> 2 min threshold)
|
|
old_time = time.time() - 180
|
|
os.utime(transcript, (old_time, old_time))
|
|
|
|
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
|
|
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
|
|
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
|
|
self.handler._match_codex_session_to_pane = MagicMock(return_value=("", ""))
|
|
self.handler._get_cached_context_usage = MagicMock(return_value=None)
|
|
self.handler._discover_active_codex_sessions()
|
|
|
|
session_file = sessions_dir / f"{session_id}.json"
|
|
data = json.loads(session_file.read_text())
|
|
self.assertEqual(data["status"], "done")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|