|
"""Tests for claude_sessions.""" |
|
|
|
from __future__ import annotations |
|
|
|
import json |
|
from datetime import datetime, timezone |
|
from pathlib import Path |
|
|
|
import pytest |
|
|
|
import claude_sessions as cs |
|
|
|
|
|
# --- helpers ---------------------------------------------------------------- |
|
|
|
def _write_jsonl(path: Path, records: list[dict]) -> Path: |
|
path.parent.mkdir(parents=True, exist_ok=True) |
|
with path.open("w", encoding="utf-8") as f: |
|
for r in records: |
|
f.write(json.dumps(r) + "\n") |
|
return path |
|
|
|
|
|
def _rename_event(sid: str, cwd: str, ts: str, args: str) -> dict: |
|
return { |
|
"type": "system", |
|
"subtype": "local_command", |
|
"content": ( |
|
"<command-name>/rename</command-name>\n" |
|
"<command-message>rename</command-message>\n" |
|
f"<command-args>{args}</command-args>" |
|
), |
|
"timestamp": ts, |
|
"cwd": cwd, |
|
"sessionId": sid, |
|
} |
|
|
|
|
|
def _ts(s: str) -> datetime: |
|
return datetime.fromisoformat(s.replace("Z", "+00:00")) |
|
|
|
|
|
# --- parse_jsonl ------------------------------------------------------------ |
|
|
|
def test_parse_jsonl_rename_event(tmp_path: Path) -> None: |
|
f = _write_jsonl(tmp_path / "proj" / "abc.jsonl", [ |
|
{"sessionId": "sid-1", "timestamp": "2026-05-01T10:00:00.000Z", |
|
"cwd": "/Users/x/repo", "type": "user", "message": {}}, |
|
_rename_event("sid-1", "/Users/x/repo", |
|
"2026-05-01T10:05:00.000Z", "myname"), |
|
]) |
|
events = cs.parse_jsonl(f) |
|
assert len(events) == 1 |
|
e = events[0] |
|
assert e.name == "myname" |
|
assert e.source == "rename" |
|
assert e.session_id == "sid-1" |
|
assert e.cwd == "/Users/x/repo" |
|
assert e.ts_utc == _ts("2026-05-01T10:05:00.000Z") |
|
|
|
|
|
def test_parse_jsonl_rename_with_empty_args_yields_cleared(tmp_path: Path) -> None: |
|
f = _write_jsonl(tmp_path / "p" / "s.jsonl", [ |
|
{"sessionId": "sid-c", "timestamp": "2026-05-01T10:00:00.000Z", |
|
"cwd": "/x", "type": "user"}, |
|
_rename_event("sid-c", "/x", "2026-05-01T10:01:00.000Z", ""), |
|
]) |
|
events = cs.parse_jsonl(f) |
|
assert len(events) == 1 |
|
assert events[0].name == "" |
|
assert events[0].source == "rename" |
|
|
|
|
|
def test_parse_jsonl_cli_startup_via_custom_title(tmp_path: Path) -> None: |
|
f = _write_jsonl(tmp_path / "proj" / "n.jsonl", [ |
|
{"type": "last-prompt", "sessionId": "sid-n"}, |
|
{"type": "custom-title", "customTitle": "test-onto", "sessionId": "sid-n"}, |
|
{"type": "agent-name", "agentName": "test-onto", "sessionId": "sid-n"}, |
|
{"type": "attachment", "timestamp": "2026-05-18T13:20:18.287Z", |
|
"cwd": "/Users/x/guide", "sessionId": "sid-n"}, |
|
]) |
|
events = cs.parse_jsonl(f) |
|
assert len(events) == 1 |
|
e = events[0] |
|
assert e.name == "test-onto" |
|
assert e.source == "cli" |
|
assert e.cwd == "/Users/x/guide" |
|
assert e.ts_utc == _ts("2026-05-18T13:20:18.287Z") |
|
|
|
|
|
def test_parse_jsonl_custom_title_with_rename_attributes_to_rename(tmp_path: Path) -> None: |
|
# When both /rename event and custom-title appear, only the rename event |
|
# is emitted (custom-title is just a side-effect of rename). |
|
f = _write_jsonl(tmp_path / "p" / "s.jsonl", [ |
|
{"sessionId": "sid-r", "timestamp": "2026-05-01T10:00:00.000Z", |
|
"cwd": "/r", "type": "user"}, |
|
_rename_event("sid-r", "/r", "2026-05-01T10:01:00.000Z", "foo"), |
|
{"type": "custom-title", "customTitle": "foo", "sessionId": "sid-r"}, |
|
]) |
|
events = cs.parse_jsonl(f) |
|
assert len(events) == 1 |
|
assert events[0].source == "rename" |
|
|
|
|
|
def test_parse_jsonl_no_naming_events_returns_empty(tmp_path: Path) -> None: |
|
f = _write_jsonl(tmp_path / "p" / "s.jsonl", [ |
|
{"sessionId": "sid-x", "timestamp": "2026-05-01T10:00:00.000Z", |
|
"cwd": "/x", "type": "user"}, |
|
]) |
|
assert cs.parse_jsonl(f) == [] |
|
|
|
|
|
def test_parse_jsonl_skips_malformed_lines(tmp_path: Path) -> None: |
|
p = tmp_path / "p" / "s.jsonl" |
|
p.parent.mkdir(parents=True) |
|
with p.open("w") as f: |
|
f.write('not-json\n') |
|
f.write('\n') |
|
f.write(json.dumps({"sessionId": "sid-m", |
|
"timestamp": "2026-05-01T10:00:00.000Z", |
|
"cwd": "/m", "type": "user"}) + "\n") |
|
f.write(json.dumps(_rename_event( |
|
"sid-m", "/m", "2026-05-01T10:01:00.000Z", "ok")) + "\n") |
|
events = cs.parse_jsonl(p) |
|
assert len(events) == 1 |
|
assert events[0].name == "ok" |
|
|
|
|
|
def test_parse_jsonl_missing_file_returns_empty(tmp_path: Path) -> None: |
|
assert cs.parse_jsonl(tmp_path / "nope.jsonl") == [] |
|
|
|
|
|
def test_parse_jsonl_skips_rename_with_bad_timestamp(tmp_path: Path) -> None: |
|
bad = _rename_event("sid-bad", "/x", "not-a-timestamp", "x") |
|
good = _rename_event("sid-bad", "/x", "2026-05-01T10:00:00.000Z", "good") |
|
f = _write_jsonl(tmp_path / "p" / "s.jsonl", [ |
|
{"sessionId": "sid-bad", "timestamp": "2026-05-01T09:59:00.000Z", |
|
"cwd": "/x", "type": "user"}, |
|
bad, |
|
good, |
|
]) |
|
events = cs.parse_jsonl(f) |
|
assert [e.name for e in events] == ["good"] |
|
|
|
|
|
def test_parse_iso_returns_none_for_garbage() -> None: |
|
assert cs._parse_iso("garbage") is None |
|
assert cs._parse_iso("") is None |
|
|
|
|
|
def test_parse_jsonl_cli_falls_back_to_decoded_dir_when_cwd_absent( |
|
tmp_path: Path, |
|
) -> None: |
|
# Encoded project dir, no cwd in any record |
|
f = _write_jsonl( |
|
tmp_path / "-Users-x-foo" / "s.jsonl", |
|
[ |
|
{"sessionId": "sid-d", "timestamp": "2026-05-01T10:00:00.000Z", |
|
"type": "last-prompt"}, |
|
{"type": "custom-title", "customTitle": "n", "sessionId": "sid-d"}, |
|
], |
|
) |
|
events = cs.parse_jsonl(f) |
|
assert len(events) == 1 |
|
assert events[0].cwd == "/Users/x/foo" |
|
|
|
|
|
# --- _decode_project_dir ---------------------------------------------------- |
|
|
|
def test_decode_project_dir_with_leading_dash() -> None: |
|
assert cs._decode_project_dir("-Users-x-y") == "/Users/x/y" |
|
|
|
|
|
def test_decode_project_dir_without_leading_dash() -> None: |
|
assert cs._decode_project_dir("plain") == "plain" |
|
|
|
|
|
# --- collect_events --------------------------------------------------------- |
|
|
|
def test_collect_events_sorts_across_files(tmp_path: Path) -> None: |
|
_write_jsonl(tmp_path / "a" / "1.jsonl", [ |
|
{"sessionId": "s1", "timestamp": "2026-05-02T00:00:00.000Z", |
|
"cwd": "/a", "type": "user"}, |
|
_rename_event("s1", "/a", "2026-05-02T00:01:00.000Z", "later"), |
|
]) |
|
_write_jsonl(tmp_path / "b" / "2.jsonl", [ |
|
{"sessionId": "s2", "timestamp": "2026-05-01T00:00:00.000Z", |
|
"cwd": "/b", "type": "user"}, |
|
_rename_event("s2", "/b", "2026-05-01T00:01:00.000Z", "earlier"), |
|
]) |
|
events = cs.collect_events(tmp_path) |
|
assert [e.name for e in events] == ["earlier", "later"] |
|
|
|
|
|
def test_collect_events_missing_root_returns_empty(tmp_path: Path) -> None: |
|
assert cs.collect_events(tmp_path / "missing") == [] |
|
|
|
|
|
# --- filter_events ---------------------------------------------------------- |
|
|
|
@pytest.fixture |
|
def sample_events() -> list[cs.NamedEvent]: |
|
return [ |
|
cs.NamedEvent(_ts("2026-05-01T00:00:00Z"), "a", "/repo/one", "s1", "rename"), |
|
cs.NamedEvent(_ts("2026-05-02T00:00:00Z"), "b", "/repo/two", "s2", "cli"), |
|
cs.NamedEvent(_ts("2026-05-03T00:00:00Z"), "c", "/other", "s3", "rename"), |
|
] |
|
|
|
|
|
def test_filter_by_cwd_substr(sample_events: list[cs.NamedEvent]) -> None: |
|
out = cs.filter_events(sample_events, cwd_substr="repo") |
|
assert [e.name for e in out] == ["a", "b"] |
|
|
|
|
|
def test_filter_by_since(sample_events: list[cs.NamedEvent]) -> None: |
|
out = cs.filter_events(sample_events, |
|
since=_ts("2026-05-02T00:00:00Z")) |
|
assert [e.name for e in out] == ["b", "c"] |
|
|
|
|
|
def test_filter_by_source(sample_events: list[cs.NamedEvent]) -> None: |
|
out = cs.filter_events(sample_events, source="cli") |
|
assert [e.name for e in out] == ["b"] |
|
|
|
|
|
def test_filter_source_all_is_passthrough(sample_events: list[cs.NamedEvent]) -> None: |
|
out = cs.filter_events(sample_events, source="all") |
|
assert len(out) == 3 |
|
|
|
|
|
# --- latest_per_session ----------------------------------------------------- |
|
|
|
def test_latest_keeps_last_nonempty_per_session() -> None: |
|
events = [ |
|
cs.NamedEvent(_ts("2026-05-01T00:00:00Z"), "v1", "/x", "s1", "rename"), |
|
cs.NamedEvent(_ts("2026-05-02T00:00:00Z"), "v2", "/x", "s1", "rename"), |
|
cs.NamedEvent(_ts("2026-05-01T00:00:00Z"), "other", "/y", "s2", "rename"), |
|
] |
|
out = cs.latest_per_session(events) |
|
assert [(e.session_id, e.name) for e in out] == [("s2", "other"), ("s1", "v2")] |
|
|
|
|
|
def test_latest_drops_sessions_whose_latest_is_cleared() -> None: |
|
events = [ |
|
cs.NamedEvent(_ts("2026-05-01T00:00:00Z"), "v1", "/x", "s1", "rename"), |
|
cs.NamedEvent(_ts("2026-05-02T00:00:00Z"), "", "/x", "s1", "rename"), |
|
] |
|
assert cs.latest_per_session(events) == [] |
|
|
|
|
|
# --- formatters ------------------------------------------------------------- |
|
|
|
def test_format_table_renders_columns(sample_events: list[cs.NamedEvent]) -> None: |
|
out = cs.format_table(sample_events) |
|
assert "time(UTC+8)" in out |
|
# 2026-05-01T00:00:00Z -> Shanghai 2026-05-01 08:00:00 |
|
assert "2026-05-01 08:00:00" in out |
|
assert "s1" in out # truncated id |
|
assert "rename" in out |
|
|
|
|
|
def test_format_table_empty_message() -> None: |
|
assert cs.format_table([]) == "(no named sessions)" |
|
|
|
|
|
def test_format_table_marks_cleared() -> None: |
|
e = cs.NamedEvent(_ts("2026-05-01T00:00:00Z"), "", "/x", "sid", "rename") |
|
assert "<cleared>" in cs.format_table([e]) |
|
|
|
|
|
def test_format_table_full_sid(sample_events: list[cs.NamedEvent]) -> None: |
|
out = cs.format_table(sample_events, full_sid=True) |
|
# session id should appear in full |
|
assert "s1" in out |
|
|
|
|
|
def test_format_json_is_valid(sample_events: list[cs.NamedEvent]) -> None: |
|
payload = json.loads(cs.format_json(sample_events)) |
|
assert len(payload) == 3 |
|
assert payload[0]["name"] == "a" |
|
assert payload[0]["source"] == "rename" |
|
# UTC+8 ISO format |
|
assert payload[0]["time_utc8"].startswith("2026-05-01T08:00:00") |
|
|
|
|
|
def test_home_tilde_replaces_home(monkeypatch: pytest.MonkeyPatch, |
|
tmp_path: Path) -> None: |
|
monkeypatch.setattr(cs.Path, "home", lambda: tmp_path) |
|
assert cs._home_tilde(str(tmp_path)) == "~" |
|
assert cs._home_tilde(str(tmp_path / "x")) == "~/x" |
|
assert cs._home_tilde("/elsewhere") == "/elsewhere" |
|
|
|
|
|
# --- _parse_since ----------------------------------------------------------- |
|
|
|
def test_parse_since_converts_shanghai_midnight_to_utc() -> None: |
|
dt = cs._parse_since("2026-05-18") |
|
# 2026-05-18 00:00 UTC+8 == 2026-05-17 16:00 UTC |
|
assert dt == datetime(2026, 5, 17, 16, 0, tzinfo=timezone.utc) |
|
|
|
|
|
def test_parse_since_rejects_bad_input() -> None: |
|
with pytest.raises(ValueError): |
|
cs._parse_since("not-a-date") |
|
|
|
|
|
# --- main / CLI ------------------------------------------------------------- |
|
|
|
def test_main_table_output(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: |
|
_write_jsonl(tmp_path / "p" / "s.jsonl", [ |
|
{"sessionId": "sidmain1", "timestamp": "2026-05-01T10:00:00.000Z", |
|
"cwd": "/repo", "type": "user"}, |
|
_rename_event("sidmain1", "/repo", "2026-05-01T10:01:00.000Z", "demo"), |
|
]) |
|
rc = cs.main(["--root", str(tmp_path)]) |
|
assert rc == 0 |
|
out = capsys.readouterr().out |
|
assert "demo" in out |
|
assert "time(UTC+8)" in out |
|
|
|
|
|
def test_main_json_output(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: |
|
_write_jsonl(tmp_path / "p" / "s.jsonl", [ |
|
{"sessionId": "sidmain2", "timestamp": "2026-05-01T10:00:00.000Z", |
|
"cwd": "/repo", "type": "user"}, |
|
_rename_event("sidmain2", "/repo", "2026-05-01T10:01:00.000Z", "demo"), |
|
]) |
|
rc = cs.main(["--root", str(tmp_path), "--json"]) |
|
assert rc == 0 |
|
payload = json.loads(capsys.readouterr().out) |
|
assert payload[0]["name"] == "demo" |
|
|
|
|
|
def test_main_latest_flag(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: |
|
_write_jsonl(tmp_path / "p" / "s.jsonl", [ |
|
{"sessionId": "sidmain3", "timestamp": "2026-05-01T10:00:00.000Z", |
|
"cwd": "/r", "type": "user"}, |
|
_rename_event("sidmain3", "/r", "2026-05-01T10:01:00.000Z", "first"), |
|
_rename_event("sidmain3", "/r", "2026-05-02T10:01:00.000Z", "second"), |
|
]) |
|
rc = cs.main(["--root", str(tmp_path), "--latest", "--json"]) |
|
assert rc == 0 |
|
payload = json.loads(capsys.readouterr().out) |
|
assert len(payload) == 1 |
|
assert payload[0]["name"] == "second" |
|
|
|
|
|
def test_main_invalid_since(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: |
|
rc = cs.main(["--root", str(tmp_path), "--since", "garbage"]) |
|
assert rc == 2 |
|
err = capsys.readouterr().err |
|
assert "invalid --since" in err |
|
|
|
|
|
def test_main_empty_root_prints_placeholder( |
|
tmp_path: Path, capsys: pytest.CaptureFixture[str] |
|
) -> None: |
|
rc = cs.main(["--root", str(tmp_path / "missing")]) |
|
assert rc == 0 |
|
assert "no named sessions" in capsys.readouterr().out |