* feat(blackbox): add package with models, CLI, and HTMX dashboard * test(blackbox): add comprehensive test coverage for dashboard * feat(blackbox): cache session scanning via watcher invalidation * docs(blackbox): add README and use fastapi[standard] for dev server * refactor(blackbox): extract presentation logic into formatter classes * refactor(blackbox): extract classify_error helpers * feat(blackbox): wire analytics into session detail view Show token usage, tool breakdowns, and session stats in a collapsible panel when viewing a session. * feat(blackbox): add codeflash plugin detection Detect codeflash agent names, skills, and commands in transcripts. Surface language, optimization domain, and capability badges in the analytics panel. * refactor(blackbox): remove underscore prefixes from internal functions * chore: add ty python-version to root pyproject.toml * chore(blackbox): fix lint errors in test files * style(blackbox): apply ruff formatting to analytics * feat(blackbox): add Playwright E2E tests for dashboard Refactor app.py to expose create_app() factory accepting a projects_dir override, enabling tests to run against fixture data instead of the real ~/.claude/projects/ directory. Routes now read projects_dir from app.state instead of the module-level constant. Add 26 Playwright tests across 5 files covering dashboard loading, session list, session detail with filters and analytics, sidebar collapse/localStorage persistence, and SSE log streaming. All tests pass on chromium, firefox, and webkit (78 total). CI gets a new e2e-blackbox job with a browser matrix strategy running all three engines in parallel, conditional on blackbox path changes, with trace upload on failure. * fix(ci): sync only blackbox package in e2e job * fix(ci): exclude e2e tests from unit test job The test job doesn't install Playwright browsers, so e2e tests error when pytest collects them. Ignore tests/e2e/ directories in the test job — those are handled by the dedicated e2e-blackbox job.
169 lines
5.4 KiB
Python
169 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
from blackbox.dashboard.watcher import LIVE_THRESHOLD_S, SessionWatcher
|
|
|
|
|
|
class TestSessionWatcherLiveIds:
|
|
def test_empty_initially(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
assert set() == w.live_session_ids()
|
|
|
|
def test_recent_modification_is_live(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
event = MagicMock()
|
|
event.is_directory = False
|
|
event.src_path = str(tmp_path / "abc123.jsonl")
|
|
w.on_modified(event)
|
|
assert {"abc123"} == w.live_session_ids()
|
|
|
|
def test_old_modification_expires(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
with w._lock:
|
|
w._last_modified["old-session"] = time.time() - LIVE_THRESHOLD_S - 10
|
|
assert set() == w.live_session_ids()
|
|
|
|
def test_non_jsonl_ignored(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
event = MagicMock()
|
|
event.is_directory = False
|
|
event.src_path = str(tmp_path / "notes.txt")
|
|
w.on_modified(event)
|
|
assert set() == w.live_session_ids()
|
|
|
|
def test_directory_events_ignored(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
event = MagicMock()
|
|
event.is_directory = True
|
|
event.src_path = str(tmp_path / "somedir")
|
|
w.on_modified(event)
|
|
assert set() == w.live_session_ids()
|
|
|
|
def test_on_created_delegates_to_on_modified(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
event = MagicMock()
|
|
event.is_directory = False
|
|
event.src_path = str(tmp_path / "new-session.jsonl")
|
|
w.on_created(event)
|
|
assert {"new-session"} == w.live_session_ids()
|
|
|
|
def test_expired_entries_cleaned_up(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
with w._lock:
|
|
w._last_modified["expired"] = time.time() - LIVE_THRESHOLD_S - 100
|
|
w._last_modified["fresh"] = time.time()
|
|
live = w.live_session_ids()
|
|
assert {"fresh"} == live
|
|
with w._lock:
|
|
assert "expired" not in w._last_modified
|
|
|
|
|
|
class TestSessionWatcherStartStop:
|
|
def test_start_without_directory_is_noop(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path / "nonexistent")
|
|
w.start()
|
|
assert w._observer is None
|
|
|
|
def test_stop_without_start_is_safe(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
w.stop()
|
|
|
|
def test_start_and_stop(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
w.start()
|
|
assert w._observer is not None
|
|
w.stop()
|
|
assert w._observer is None
|
|
|
|
def test_multiple_stops_safe(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
w.start()
|
|
w.stop()
|
|
w.stop()
|
|
|
|
|
|
class TestSessionWatcherCache:
|
|
def test_get_sessions_calls_scan_on_first_call(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
sentinel: list[object] = []
|
|
result = w.get_sessions(lambda _: sentinel)
|
|
assert result is sentinel
|
|
|
|
def test_get_sessions_returns_cached_on_second_call(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
call_count = 0
|
|
|
|
def counting_scan(_: object) -> list[object]:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return []
|
|
|
|
w.get_sessions(counting_scan)
|
|
w.get_sessions(counting_scan)
|
|
assert 1 == call_count
|
|
|
|
def test_on_modified_invalidates_cache(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
call_count = 0
|
|
|
|
def counting_scan(_: object) -> list[object]:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return []
|
|
|
|
w.get_sessions(counting_scan)
|
|
|
|
event = MagicMock()
|
|
event.is_directory = False
|
|
event.src_path = str(tmp_path / "sess.jsonl")
|
|
w.on_modified(event)
|
|
|
|
w.get_sessions(counting_scan)
|
|
assert 2 == call_count
|
|
|
|
def test_on_created_invalidates_cache(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
w.get_sessions(lambda _: [])
|
|
|
|
event = MagicMock()
|
|
event.is_directory = False
|
|
event.src_path = str(tmp_path / "new.jsonl")
|
|
w.on_created(event)
|
|
|
|
assert w._cached_sessions is None
|
|
|
|
def test_on_deleted_invalidates_cache(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
w.get_sessions(lambda _: [])
|
|
|
|
event = MagicMock()
|
|
event.is_directory = False
|
|
event.src_path = str(tmp_path / "removed.jsonl")
|
|
w.on_deleted(event)
|
|
|
|
assert w._cached_sessions is None
|
|
|
|
def test_on_deleted_ignores_non_jsonl(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
w.get_sessions(lambda _: [])
|
|
|
|
event = MagicMock()
|
|
event.is_directory = False
|
|
event.src_path = str(tmp_path / "notes.txt")
|
|
w.on_deleted(event)
|
|
|
|
assert w._cached_sessions is not None
|
|
|
|
def test_on_deleted_ignores_directories(self, tmp_path: Path) -> None:
|
|
w = SessionWatcher(tmp_path)
|
|
w.get_sessions(lambda _: [])
|
|
|
|
event = MagicMock()
|
|
event.is_directory = True
|
|
event.src_path = str(tmp_path / "somedir")
|
|
w.on_deleted(event)
|
|
|
|
assert w._cached_sessions is not None
|